visualproof 0.0.0-stage → 0.2.0
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 +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3270 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +780 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1406 -0
- package/dist/server/mcptools.js +496 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +159 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +50 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +164 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +241 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +124 -0
- package/dist/shared/doc/outline.js +45 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +244 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +103 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +818 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +281 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +300 -0
- package/dist/shared/viewgraph.js +157 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +52671 -0
- package/dist/web/bundle/edges.js +407 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +257 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +804 -0
- package/dist/web/style.css +2548 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +125 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// visualproof serve <path> — read a built Lean project and serve its declaration graph
|
|
3
|
+
// visualproof export <path> — the same page, frozen into one HTML file
|
|
4
|
+
import { historyAnswer } from "./historyapi.js";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { extname, join, resolve } from "node:path";
|
|
9
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
10
|
+
import { WEB, WEBFONTS, bundleDir, bundleDirIn, ensureBundle, prepare, useBundleDir } from "./pipeline.js";
|
|
11
|
+
import { SiteBusy, cacheHome, dataHome, lockSite, openSite, projectDirs } from "./dirs.js";
|
|
12
|
+
import { ProjectError, openProject, targetsInWords } from "./project.js";
|
|
13
|
+
import { Live } from "./live.js";
|
|
14
|
+
import { ReviewStore } from "./review.js";
|
|
15
|
+
import { isDeclName, LeanHover } from "./hover.js";
|
|
16
|
+
import { Identities, layoutAuthor } from "./identity.js";
|
|
17
|
+
import { LAYOUT_VERSION } from "../shared/graph.js";
|
|
18
|
+
import { MAX_MEMORY_BYTES, Views } from "./views.js";
|
|
19
|
+
import { cleanView, defaultAgentName, validSession, withoutArtwork } from "../shared/collab.js";
|
|
20
|
+
import { writeSnapshot } from "./snapshot.js";
|
|
21
|
+
import { lakeTextHash } from "./status.js";
|
|
22
|
+
import { invalidTemplate } from "../shared/editor.js";
|
|
23
|
+
import { packEdges } from "../shared/edgecodec.js";
|
|
24
|
+
import { Access, KEY_PATH, loadKey } from "./access.js";
|
|
25
|
+
import { INVITE_PATH, invalidPersonName, Invites, taken } from "./invites.js";
|
|
26
|
+
import { AGENT_LEVELS, AgentApi, lower, needOf, refusal } from "./agent.js";
|
|
27
|
+
import { AgentTokens, bearerOf, DEFAULT_DAYS, invalidAgentName, looksLikeToken, TOKEN_DAYS } from "./tokens.js";
|
|
28
|
+
import { agentPrompt } from "./prompt.js";
|
|
29
|
+
import { MAX_PERSON } from "./talk.js";
|
|
30
|
+
import { DEFAULT_JOBS, UsagePass, mergeUsage, saveMerged } from "./usage.js";
|
|
31
|
+
import { extract } from "./extract.js";
|
|
32
|
+
import { Exporter } from "./export.js";
|
|
33
|
+
import { Library, libraryApiOf } from "./library.js";
|
|
34
|
+
import { libraryRoute, pdfjsAsset, readUpload } from "./libraryroute.js";
|
|
35
|
+
import { UPLOAD_PATH } from "./agentlibrary.js";
|
|
36
|
+
import { DEFAULT_LIBRARY_LIMIT, validSha } from "../shared/library.js";
|
|
37
|
+
import { doctor, packageVersion, printReport } from "./doctor.js";
|
|
38
|
+
import { READONLY_PATH } from "./readonly.js";
|
|
39
|
+
import { finalAnswer, readOnlyAllows, ReadOnlyBounds } from "./readonlygate.js";
|
|
40
|
+
/** The usage, to stderr with status 2 for a command line it cannot read; asked for (`--help`),
|
|
41
|
+
* to stdout with status 0. */
|
|
42
|
+
function usage(code = 2) {
|
|
43
|
+
(code === 0 ? console.log : console.error)(`usage: visualproof serve <lean-project> [--root Name]... [--port 4173] [--host 127.0.0.1]
|
|
44
|
+
[--site dir] [--cache dir] [--graph saved.json]
|
|
45
|
+
[--identity-header X-Forwarded-User] [--editor URL-template]
|
|
46
|
+
[--agents read|advise|full] [--access-key-file file] [--public] [--jobs ${DEFAULT_JOBS}]
|
|
47
|
+
[--public-url https://host] [--library-limit GB]
|
|
48
|
+
visualproof purge-file <lean-project> <sha256> [--site dir] [--cache dir]
|
|
49
|
+
visualproof export <lean-project> -o page.html [--label text] [--root Name]... [--graph saved.json]
|
|
50
|
+
[--jobs ${DEFAULT_JOBS}] [--site dir] [--cache dir]
|
|
51
|
+
visualproof doctor [<lean-project>] [--root Name]... [--site dir] [--cache dir]
|
|
52
|
+
visualproof --version
|
|
53
|
+
|
|
54
|
+
Reads what \`lake build\` left in <lean-project>/.lake. It never builds the project, and writes
|
|
55
|
+
nothing into it.
|
|
56
|
+
|
|
57
|
+
--root what to read: a library of the lakefile (the modules Lake builds of it) or a module;
|
|
58
|
+
repeatable. By default the lakefile's default targets.
|
|
59
|
+
--site the site's folder: the review's logs and what this running site accumulates — the
|
|
60
|
+
history of builds, the library's files, the views and their layouts, the timeline,
|
|
61
|
+
conversations with agents, agents' tokens and personal links. It cannot be derived
|
|
62
|
+
again and holds secrets: back it up, never commit it. One server at a time uses a
|
|
63
|
+
site. Relative to the current directory, or absolute; default:
|
|
64
|
+
${join(dataHome(), "sites", "<project>-<hash>")}
|
|
65
|
+
--cache the root of the caches, which a rebuild makes again: the project's (extractions, the
|
|
66
|
+
usage pass's results, prepared views, typeset documents, Lean's language server's
|
|
67
|
+
folder) in projects/<project>-<hash>/, the Lean programs compiled per toolchain in
|
|
68
|
+
lean/, the page's bundle in web/. Default: ${cacheHome()}
|
|
69
|
+
(XDG_DATA_HOME and XDG_CACHE_HOME move both homes, on any platform)
|
|
70
|
+
|
|
71
|
+
--graph use a saved extraction (a projects/*/extract/*.json of the cache) instead of reading
|
|
72
|
+
the build; the project path still supplies the sources
|
|
73
|
+
export writes one self-contained HTML file: the page, its data and the sources, frozen
|
|
74
|
+
|
|
75
|
+
--identity-header behind a proxy that authenticates (Tailscale, oauth2-proxy, …): take each
|
|
76
|
+
reader's name from this header instead of the name the page gives. Only use it when
|
|
77
|
+
the proxy is the only way to reach the server: the header is believed as it comes.
|
|
78
|
+
|
|
79
|
+
--editor link each declaration to its place in an editor, through a URL template:
|
|
80
|
+
"vscode://file/{path}:{line}:{col}" for VS Code on this machine, or
|
|
81
|
+
"vscode://vscode-remote/ssh-remote+HOST{path}:{line}" for VS Code over SSH to it.
|
|
82
|
+
{path} is the file on this machine, {rel} the file in the project, {line} and {col}
|
|
83
|
+
where the declaration starts. Each reader can set their own in the page (?).
|
|
84
|
+
|
|
85
|
+
--agents what AI agents (requests with X-VisualProof-Kind: agent; their API is /api/agent) may
|
|
86
|
+
change: read — nothing; advise — give advice and leave notes; full (the default) — also
|
|
87
|
+
whatever a person can do through the page (targets, key declarations, trust, views,
|
|
88
|
+
pins), each recorded as the agent's. An agent's opinion is advice whatever the level:
|
|
89
|
+
shown apart, never counted; agent tokens stay with people.
|
|
90
|
+
|
|
91
|
+
--jobs how many modules the usage pass elaborates at once (default ${DEFAULT_JOBS}; 0: no pass). The
|
|
92
|
+
pass finds which notation each declaration is written in and the exact context it is
|
|
93
|
+
written in (TCBSpec.md, decisions 2 and 4); its findings follow the extractor's graph
|
|
94
|
+
as its next version. Each worker takes about 400 MB of its own and the import of a
|
|
95
|
+
module is bound by memory bandwidth: past about a quarter of the cores it gains little.
|
|
96
|
+
|
|
97
|
+
--access-key-file let in only those given the link http://<host>:<port>/k/<key>, the key being
|
|
98
|
+
what the file holds (one is made there, readable by its owner alone, if there is none).
|
|
99
|
+
The link leaves a cookie in the browser; agents send Authorization: Bearer <key>.
|
|
100
|
+
Anything else is answered 404. Over plain HTTP the key crosses the network in the
|
|
101
|
+
clear: it keeps out who was not given the link, not who can watch the traffic.
|
|
102
|
+
Share also gives an AI agent a token of its own (vpa_…), which reaches /api/agent only,
|
|
103
|
+
and a person a link of their own (/i/vpp_…), which lets them in and says who they are,
|
|
104
|
+
and anyone a read-only link (/r/vpr_…), which lets them see and change nothing shared.
|
|
105
|
+
|
|
106
|
+
--public with --access-key-file: whoever comes without the key or a link of their own is let
|
|
107
|
+
in read-only, rather than answered 404: they see the project, its review and its
|
|
108
|
+
history, change nothing that is shared, and are seen by nobody. For a project anyone
|
|
109
|
+
may read (a showcase); never for a private one.
|
|
110
|
+
|
|
111
|
+
--public-url the address people reach this server at, when it is not the one their browser
|
|
112
|
+
sends (a proxy in front): the prompt Share makes for an agent tells it this address.
|
|
113
|
+
|
|
114
|
+
--library-limit the most the project's library of papers and images may take, in GB (default
|
|
115
|
+
${DEFAULT_LIBRARY_LIMIT / 1024 ** 3}); its files are kept in the site directory, by their hash.
|
|
116
|
+
|
|
117
|
+
purge-file deletes a file's bytes from the library for good (a file added by mistake), and what
|
|
118
|
+
was read of it; its record and its links stay in the review, saying this server lacks it.
|
|
119
|
+
Nothing else deletes a file: removing one from the page archives it.
|
|
120
|
+
|
|
121
|
+
doctor checks what VisualProof needs on this machine (Node, elan, Poppler, XeLaTeX) and, given
|
|
122
|
+
a project, its toolchain, its build and its site, and says what to do; it changes
|
|
123
|
+
nothing.`);
|
|
124
|
+
process.exit(code);
|
|
125
|
+
}
|
|
126
|
+
function parseArgs(argv) {
|
|
127
|
+
const [cmd, ...rest] = argv;
|
|
128
|
+
if (cmd === "--help" || cmd === "-h" || cmd === "help")
|
|
129
|
+
usage(0);
|
|
130
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
131
|
+
console.log(packageVersion());
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor")
|
|
135
|
+
usage();
|
|
136
|
+
const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false };
|
|
137
|
+
for (let i = 0; i < rest.length; i++) {
|
|
138
|
+
const a = rest[i];
|
|
139
|
+
const val = () => rest[++i] ?? usage();
|
|
140
|
+
if (a === "--root")
|
|
141
|
+
o.roots.push(val());
|
|
142
|
+
else if (a === "--port")
|
|
143
|
+
o.port = Number(val());
|
|
144
|
+
else if (a === "--host")
|
|
145
|
+
o.host = val();
|
|
146
|
+
else if (a === "--site")
|
|
147
|
+
o.site = val();
|
|
148
|
+
else if (a === "--cache")
|
|
149
|
+
o.cache = val();
|
|
150
|
+
else if (a === "--graph")
|
|
151
|
+
o.graph = resolve(val());
|
|
152
|
+
else if (a === "-o" || a === "--out")
|
|
153
|
+
o.out = resolve(val());
|
|
154
|
+
else if (a === "--label")
|
|
155
|
+
o.label = val();
|
|
156
|
+
else if (a === "--identity-header")
|
|
157
|
+
o.identityHeader = val();
|
|
158
|
+
else if (a === "--editor")
|
|
159
|
+
o.editor = val();
|
|
160
|
+
else if (a === "--access-key-file")
|
|
161
|
+
o.accessKeyFile = resolve(val());
|
|
162
|
+
else if (a === "--public")
|
|
163
|
+
o.publicRead = true;
|
|
164
|
+
else if (a === "--public-url") {
|
|
165
|
+
const u = val();
|
|
166
|
+
if (!/^https?:\/\/[^\s/?#]+\/?$/.test(u)) {
|
|
167
|
+
console.error(`visualproof: --public-url ${u}: give the scheme and the host (and port), e.g. https://example.org`);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
o.publicUrl = u.replace(/\/+$/, "");
|
|
171
|
+
}
|
|
172
|
+
else if (a === "--jobs") {
|
|
173
|
+
const n = Number(val());
|
|
174
|
+
if (!Number.isInteger(n) || n < 0 || n > 256)
|
|
175
|
+
usage();
|
|
176
|
+
o.jobs = n;
|
|
177
|
+
}
|
|
178
|
+
else if (a === "--agents") {
|
|
179
|
+
const level = val();
|
|
180
|
+
if (!AGENT_LEVELS.includes(level))
|
|
181
|
+
usage();
|
|
182
|
+
o.agents = level;
|
|
183
|
+
}
|
|
184
|
+
else if (a === "--library-limit") {
|
|
185
|
+
const gb = Number(val());
|
|
186
|
+
if (!Number.isFinite(gb) || gb <= 0 || gb > 10000)
|
|
187
|
+
usage();
|
|
188
|
+
o.libraryLimit = Math.round(gb * 1024 ** 3);
|
|
189
|
+
}
|
|
190
|
+
else if (a.startsWith("-"))
|
|
191
|
+
usage();
|
|
192
|
+
else if (!o.path)
|
|
193
|
+
o.path = a;
|
|
194
|
+
else if (cmd === "purge-file" && !o.sha)
|
|
195
|
+
o.sha = a;
|
|
196
|
+
else
|
|
197
|
+
usage();
|
|
198
|
+
}
|
|
199
|
+
if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out))
|
|
200
|
+
usage();
|
|
201
|
+
// read-only for whoever comes, beside those the key or a link lets in: without a key, everyone is in
|
|
202
|
+
// already; behind a sign-in, there is nobody else
|
|
203
|
+
if (o.publicRead && (!o.accessKeyFile || o.identityHeader)) {
|
|
204
|
+
console.error("visualproof: --public lets in read-only whoever has no key: it needs --access-key-file, and does not go with --identity-header");
|
|
205
|
+
process.exit(2);
|
|
206
|
+
}
|
|
207
|
+
if (cmd === "purge-file" && !validSha(o.sha)) {
|
|
208
|
+
console.error("visualproof purge-file: give the file's SHA-256, 64 hex digits, as the page's library shows it.");
|
|
209
|
+
process.exit(2);
|
|
210
|
+
}
|
|
211
|
+
// a template that cannot make a link is said at the start, not found out by a reader's click
|
|
212
|
+
const why = o.editor === null ? null : invalidTemplate(o.editor);
|
|
213
|
+
if (why) {
|
|
214
|
+
console.error(`visualproof: --editor ${o.editor}: ${why}`);
|
|
215
|
+
process.exit(2);
|
|
216
|
+
}
|
|
217
|
+
return o;
|
|
218
|
+
}
|
|
219
|
+
/** The server's answers to requests: exported so that the tests serve them as the server does. */
|
|
220
|
+
export class App {
|
|
221
|
+
live;
|
|
222
|
+
hover;
|
|
223
|
+
ids;
|
|
224
|
+
/** the agents' API (server/agent.ts), and what agents may change */
|
|
225
|
+
agents;
|
|
226
|
+
/** who may come in at all (server/access.ts); null: anyone who reaches the port */
|
|
227
|
+
access;
|
|
228
|
+
/** the exported document's typesetting (server/export.ts, ExportSpec.md); null: no export here */
|
|
229
|
+
exporter;
|
|
230
|
+
/** the address people reach the server at, for the prompt Share makes (--public-url); null: the
|
|
231
|
+
* one the person's browser used */
|
|
232
|
+
publicUrl;
|
|
233
|
+
/** --public: whoever comes without the key or a link of their own reads only (CollabSpec
|
|
234
|
+
* decision 11) */
|
|
235
|
+
publicRead;
|
|
236
|
+
/** the read-only readers' requests, by the read-only link they came by, or "public" */
|
|
237
|
+
readOnlyOf = new WeakMap();
|
|
238
|
+
/** what read-only readers may cost the server: Lean's answers, their documents */
|
|
239
|
+
bounds;
|
|
240
|
+
constructor(live, hover, ids, agents, access, exporter = null, publicUrl = null, publicRead = false) {
|
|
241
|
+
this.live = live;
|
|
242
|
+
this.hover = hover;
|
|
243
|
+
this.ids = ids;
|
|
244
|
+
this.agents = agents;
|
|
245
|
+
this.access = access;
|
|
246
|
+
// a personal link says who its holder is (CollabSpec decision 9); behind a proxy that signs
|
|
247
|
+
// people in, the proxy does, and there are no personal links
|
|
248
|
+
ids.invites = ids.header ? null : live.invites;
|
|
249
|
+
this.exporter = exporter;
|
|
250
|
+
// agents typeset as people do (ExportSpec decision 11), through their API
|
|
251
|
+
agents.exporter = exporter;
|
|
252
|
+
this.publicUrl = publicUrl;
|
|
253
|
+
this.publicRead = publicRead && access !== null;
|
|
254
|
+
this.bounds = new ReadOnlyBounds((id) => this.exporter?.job(id)?.state === "running");
|
|
255
|
+
}
|
|
256
|
+
/** The exported document (ExportSpec decisions 5–16): POST /api/export starts typesetting the
|
|
257
|
+
* document of a view, `{view, settings, version?}` (a build the Time Machine shows the view at;
|
|
258
|
+
* left out, the view's own), and answers with its job at once; the page asks how the
|
|
259
|
+
* job is doing (GET /api/export/job) and fetches the PDF and the LaTeX sources once it is done
|
|
260
|
+
* (GET /api/export/file). The answers are the agents' API's (server/agent.ts), which has the same
|
|
261
|
+
* routes under /api/agent/; an agent that comes here is held to its one job at a time there too.
|
|
262
|
+
* The window shows the title and the author line the document will have (GET /api/export/head
|
|
263
|
+
* ?view=&version=: ExportSpec decisions 14 and 15), which only the server can tell: who dropped
|
|
264
|
+
* each pin is never sent to pages. */
|
|
265
|
+
async export(req, res, url, agent, readOnly = false) {
|
|
266
|
+
const path = url.pathname;
|
|
267
|
+
const api = this.agents;
|
|
268
|
+
// the document's outline, which the linear form draws: nothing is typeset for it
|
|
269
|
+
if (path === "/api/export/outline") {
|
|
270
|
+
const o = api.exportOutline(url.searchParams.get("view") ?? "", url.searchParams.get("version") ?? undefined);
|
|
271
|
+
return sendJson(res, o.body, o.status, o.headers);
|
|
272
|
+
}
|
|
273
|
+
if (!this.exporter)
|
|
274
|
+
return sendJson(res, { error: "This server does not export documents." }, 404);
|
|
275
|
+
let a;
|
|
276
|
+
if (path === "/api/export" && req.method === "POST") {
|
|
277
|
+
const caller = agent ? { name: this.ids.who(req), kind: "agent" } : null;
|
|
278
|
+
if (caller && !caller.name)
|
|
279
|
+
return sendJson(res, { error: "Say who you are first.", who: true }, 401);
|
|
280
|
+
const r = await this.json(req, 1 << 20);
|
|
281
|
+
if (r.error !== undefined)
|
|
282
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
283
|
+
// read-only readers typeset one document at a time among them (CollabSpec decision 11): while
|
|
284
|
+
// one is being typeset, the others wait, even one the export's cache holds. The place is
|
|
285
|
+
// taken before the job starts, so that two requests at once cannot both start one.
|
|
286
|
+
if (readOnly) {
|
|
287
|
+
if (!this.bounds.mayExport())
|
|
288
|
+
return sendJson(res, { error: "Another document is being typeset for readers of this page: try again in a little while." }, 429, { "retry-after": "5" });
|
|
289
|
+
this.bounds.reserveExport();
|
|
290
|
+
}
|
|
291
|
+
let started;
|
|
292
|
+
try {
|
|
293
|
+
started = await api.exportStart(r.body, caller);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
if (readOnly)
|
|
297
|
+
this.bounds.exportStarted(started?.body);
|
|
298
|
+
}
|
|
299
|
+
a = started;
|
|
300
|
+
}
|
|
301
|
+
else if (path === "/api/export/head")
|
|
302
|
+
a = api.exportHead(url.searchParams.get("view") ?? "", url.searchParams.get("version") ?? undefined);
|
|
303
|
+
else if (path === "/api/export/job")
|
|
304
|
+
a = api.exportJob(url.searchParams.get("id") ?? "", false);
|
|
305
|
+
else if (path === "/api/export/file")
|
|
306
|
+
a = api.exportFile(url.searchParams.get("id") ?? "", url.searchParams.get("what"));
|
|
307
|
+
else
|
|
308
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
309
|
+
if (a.file)
|
|
310
|
+
return sendDownload(res, a.file);
|
|
311
|
+
// the agents' answer as it is: the page shows its error, and an agent that asked here its hint
|
|
312
|
+
sendJson(res, a.body, a.status, a.headers);
|
|
313
|
+
}
|
|
314
|
+
/** A request to the agents' API: read here, answered there. An agent with a token of its own is
|
|
315
|
+
* who its token says, whatever its headers say (server/tokens.ts). */
|
|
316
|
+
async agent(req, res, url, token = null) {
|
|
317
|
+
const said = this.ids.who(req);
|
|
318
|
+
const caller = token
|
|
319
|
+
? { name: token.name, kind: "agent", token: { id: token.id, by: token.by, level: token.level, expires: token.expires }, ...(said && said !== token.name ? { ignoredName: said } : {}) }
|
|
320
|
+
: { name: said, kind: this.ids.kind(req) };
|
|
321
|
+
let body = null;
|
|
322
|
+
if (req.method === "POST" && url.pathname.replace(/\/+$/, "") === UPLOAD_PATH) {
|
|
323
|
+
// a file for the library: its bytes as they come (server/agentlibrary.ts)
|
|
324
|
+
const bytes = await readUpload(req, res);
|
|
325
|
+
if (!bytes)
|
|
326
|
+
return;
|
|
327
|
+
body = { bytes };
|
|
328
|
+
}
|
|
329
|
+
else if (req.method === "POST") {
|
|
330
|
+
const r = await this.json(req, AGENT_BODY);
|
|
331
|
+
if (r.error !== undefined)
|
|
332
|
+
return sendJson(res, { error: `The body is ${r.error}.`, hint: "Send a JSON object, with content-type: application/json." }, 400);
|
|
333
|
+
body = r.body;
|
|
334
|
+
}
|
|
335
|
+
// a request that waits (the inbox, the companion) learns that its asker went away: an agent's
|
|
336
|
+
// wait for messages ends with its connection, so that none is handed to nobody
|
|
337
|
+
const away = new AbortController();
|
|
338
|
+
res.on("close", () => {
|
|
339
|
+
if (!res.writableFinished)
|
|
340
|
+
away.abort();
|
|
341
|
+
});
|
|
342
|
+
// the session an MCP server says it is (since 0.4.0): a wait from another is a new session's
|
|
343
|
+
const session = req.headers["x-visualproof-session"];
|
|
344
|
+
const a = await this.agents.handle({ method: req.method ?? "GET", path: url.pathname, query: url.searchParams, body, caller, base: this.publicUrl ?? `http://${hostOf(req)}`, signal: away.signal, ...(validSession(session) ? { session } : {}) });
|
|
345
|
+
if (away.signal.aborted)
|
|
346
|
+
return;
|
|
347
|
+
if (a.text)
|
|
348
|
+
return sendText(req, res, a.text.body, a.text.type, a.status);
|
|
349
|
+
if (a.file)
|
|
350
|
+
return sendDownload(res, a.file);
|
|
351
|
+
if (a.gz)
|
|
352
|
+
return sendGz(req, res, a.gz.body, a.gz.type);
|
|
353
|
+
sendJson(res, a.body, a.status, a.headers);
|
|
354
|
+
}
|
|
355
|
+
/** Talking with an agent (AgentSpec decision 8), for pages: an agent's conversation (GET), and a
|
|
356
|
+
* message to it (POST) — from a person with a name, to an agent that is not offline (409
|
|
357
|
+
* otherwise: one listening has it at once, one thinking or working at its next wait), at most so
|
|
358
|
+
* many a minute. The agent is named as presence names it. */
|
|
359
|
+
async chat(req, res, url) {
|
|
360
|
+
const live = this.live;
|
|
361
|
+
if (req.method === "GET") {
|
|
362
|
+
const id = url.searchParams.get("agent") ?? "";
|
|
363
|
+
const tok = live.presence.tokenOf(id);
|
|
364
|
+
if (!tok)
|
|
365
|
+
return sendJson(res, { error: "There is no such agent: only an agent with a token of its own can be talked to." }, 404);
|
|
366
|
+
return sendJson(res, { agent: { id, name: live.tokens.get(tok)?.name ?? null, talk: live.talk.talk(tok) }, messages: live.talk.thread(tok) });
|
|
367
|
+
}
|
|
368
|
+
if (req.method !== "POST")
|
|
369
|
+
return sendJson(res, { error: "/api/chat answers GET and POST." }, 405);
|
|
370
|
+
if (this.ids.kind(req) === "agent")
|
|
371
|
+
return sendJson(res, { error: "Agents answer through their API.", hint: "POST /api/agent/reply." }, 403);
|
|
372
|
+
const me = this.ids.who(req);
|
|
373
|
+
if (!me)
|
|
374
|
+
return sendJson(res, { error: "Say who you are first: a message is sent under your name.", who: true }, 401);
|
|
375
|
+
const r = await this.json(req, 1 << 16);
|
|
376
|
+
if (r.error !== undefined)
|
|
377
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
378
|
+
const b = (r.body ?? {});
|
|
379
|
+
const tok = typeof b.agent === "string" ? live.presence.tokenOf(b.agent) : null;
|
|
380
|
+
if (!tok)
|
|
381
|
+
return sendJson(res, { error: "There is no such agent: only an agent with a token of its own can be talked to." }, 404);
|
|
382
|
+
const name = live.tokens.get(tok)?.name ?? "The agent";
|
|
383
|
+
if (live.talk.state(tok) === "offline")
|
|
384
|
+
return sendJson(res, { error: "offline", message: `${name} is offline: it can be written to once it listens again.` }, 409);
|
|
385
|
+
const text = typeof b.text === "string" ? b.text.trim() : "";
|
|
386
|
+
if (!text)
|
|
387
|
+
return sendJson(res, { error: "The message is empty." }, 400);
|
|
388
|
+
if (text.length > MAX_PERSON)
|
|
389
|
+
return sendJson(res, { error: `A message is at most ${MAX_PERSON.toLocaleString("en")} characters.` }, 400);
|
|
390
|
+
const wait = live.talk.allow("person", me);
|
|
391
|
+
if (wait)
|
|
392
|
+
return sendJson(res, { error: `Too many messages at once: wait ${wait} s.`, retryAfter: wait }, 429, { "retry-after": String(wait) });
|
|
393
|
+
// where the writer was, as the page says it, bounded: a selection can be a long query; a private
|
|
394
|
+
// reader's message says nothing of where they are (CollabSpec decision 8), only who wrote it
|
|
395
|
+
const view = this.ids.privateOf(req) === null ? cleanView(b.place) : null;
|
|
396
|
+
const at = view ? this.agents.placeOf(view) : null;
|
|
397
|
+
const clip = (x, n) => (x !== null && x.length > n ? x.slice(0, n - 1) + "…" : x);
|
|
398
|
+
const where = at ? { inWords: clip(at.inWords, 600), view: at.view, declaration: clip(at.declaration, 300), selection: clip(at.selection, 300) } : null;
|
|
399
|
+
const message = live.talk.post(tok, { from: me, kind: "person", text, where });
|
|
400
|
+
return sendJson(res, { ok: true, message });
|
|
401
|
+
}
|
|
402
|
+
/** A new conversation with an agent (AgentSpec decision 8, the owner's decision of 2026-10-11):
|
|
403
|
+
* a divider, from anyone who may write to it — a person with a name — and held to the rate of
|
|
404
|
+
* their messages; an offline agent's too, so that its next session starts clean. One right after
|
|
405
|
+
* another divides nothing: it is answered `unchanged`, and neither written nor counted. */
|
|
406
|
+
async chatNew(req, res) {
|
|
407
|
+
const live = this.live;
|
|
408
|
+
if (req.method !== "POST")
|
|
409
|
+
return sendJson(res, { error: "/api/chat/new answers POST." }, 405);
|
|
410
|
+
if (this.ids.kind(req) === "agent")
|
|
411
|
+
return sendJson(res, { error: "A new conversation is for people to start." }, 403);
|
|
412
|
+
const me = this.ids.who(req);
|
|
413
|
+
if (!me)
|
|
414
|
+
return sendJson(res, { error: "Say who you are first: a new conversation is started under your name.", who: true }, 401);
|
|
415
|
+
const r = await this.json(req, 1 << 12);
|
|
416
|
+
if (r.error !== undefined)
|
|
417
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
418
|
+
const b = (r.body ?? {});
|
|
419
|
+
const tok = typeof b.agent === "string" ? live.presence.tokenOf(b.agent) : null;
|
|
420
|
+
if (!tok)
|
|
421
|
+
return sendJson(res, { error: "There is no such agent: only an agent with a token of its own can be talked to." }, 404);
|
|
422
|
+
if (live.talk.fresh(tok))
|
|
423
|
+
return sendJson(res, { ok: true, unchanged: true, divider: null, message: "Nothing was said since the conversation began: it is new already." });
|
|
424
|
+
const wait = live.talk.allow("person", me);
|
|
425
|
+
if (wait)
|
|
426
|
+
return sendJson(res, { error: `Too many at once: wait ${wait} s.`, retryAfter: wait }, 429, { "retry-after": String(wait) });
|
|
427
|
+
return sendJson(res, { ok: true, divider: live.talk.restart(tok, me) });
|
|
428
|
+
}
|
|
429
|
+
/** Asking others to follow (CollabSpec decision 10, the owner's decision of 2026-10-12), for pages:
|
|
430
|
+
* POST /api/ask {token, to?} — the page whose event stream holds `token` asks everyone on the
|
|
431
|
+
* server, or the person whose page is listed as `to`, to follow its reader, who has a name and is
|
|
432
|
+
* visible: a private reader cannot be followed; POST /api/ask/answer {token, id, answer} — a page
|
|
433
|
+
* asked answers, whoever its reader is. The server relays both (server/askfollow.ts); agents ask
|
|
434
|
+
* through their API (POST /api/agent/ask-follow). */
|
|
435
|
+
async ask(req, res, url) {
|
|
436
|
+
const live = this.live;
|
|
437
|
+
if (req.method !== "POST")
|
|
438
|
+
return sendJson(res, { error: `${url.pathname} answers POST.` }, 405);
|
|
439
|
+
if (this.ids.kind(req) === "agent")
|
|
440
|
+
return sendJson(res, { error: "Agents ask through their API.", hint: "POST /api/agent/ask-follow." }, 403);
|
|
441
|
+
const r = await this.json(req, 4096);
|
|
442
|
+
if (r.error !== undefined)
|
|
443
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
444
|
+
const b = (r.body ?? {});
|
|
445
|
+
const page = live.pageStream(b.token);
|
|
446
|
+
if (!page)
|
|
447
|
+
return sendJson(res, { error: "This request is not from an open event stream of this page's: it is sent with the token the stream says hello with.", stream: false }, 403);
|
|
448
|
+
if (url.pathname === "/api/ask/answer") {
|
|
449
|
+
const a = live.asks.answer(b.token, b.id, b.answer);
|
|
450
|
+
return sendJson(res, a.body, a.status);
|
|
451
|
+
}
|
|
452
|
+
if (url.pathname !== "/api/ask")
|
|
453
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
454
|
+
const me = this.ids.who(req);
|
|
455
|
+
if (!me)
|
|
456
|
+
return sendJson(res, this.nameless(req, "a request to follow you says who asks"), 401);
|
|
457
|
+
if (page.private || this.ids.privateOf(req) !== null)
|
|
458
|
+
return sendJson(res, { error: "You are private: nobody can follow you. Go visible (the menu under your name) to ask." }, 409);
|
|
459
|
+
if (b.to !== undefined && b.to !== null && typeof b.to !== "string")
|
|
460
|
+
return sendJson(res, { error: "to is a reader's id, as presence lists them; left out, everyone." }, 400);
|
|
461
|
+
const person = this.ids.person(req);
|
|
462
|
+
// one request every 30 s per person — their personal link, else their name — and per page
|
|
463
|
+
const key = person ? `p:${person.id}` : `n:${me.trim().normalize("NFKC").toLowerCase()}`;
|
|
464
|
+
const a = live.asks.ask({ name: me, verified: !!person, agent: false, from: page.id, stream: b.token, rate: [key, `s:${b.token}`] }, typeof b.to === "string" ? { page: b.to } : { everyone: true });
|
|
465
|
+
if ("ok" in a) {
|
|
466
|
+
const { report: _, ...sent } = a.ok;
|
|
467
|
+
return sendJson(res, { ok: true, ...sent });
|
|
468
|
+
}
|
|
469
|
+
return sendJson(res, { error: a.error, ...(a.retryAfter ? { retryAfter: a.retryAfter } : {}) }, a.status, a.retryAfter ? { "retry-after": String(a.retryAfter) } : undefined);
|
|
470
|
+
}
|
|
471
|
+
/** Sharing with an AI agent (AgentSpec decision 7), for people: the tokens in force (GET), a new
|
|
472
|
+
* one with its prompt (POST), a token's prompt shown again (POST …/prompt), and revoking one
|
|
473
|
+
* (POST …/revoke). All but the list need a name: a token is made under it, and its prompt holds
|
|
474
|
+
* the token. The prompt is asked for by POST, as making one is, so that the browser sends its
|
|
475
|
+
* Origin and the prompt names the server as the page reached it, the same both times. */
|
|
476
|
+
async shareAgents(req, res, url) {
|
|
477
|
+
// agent tokens stay with people, at every level (the owner's decision, AgentSpec decision 9):
|
|
478
|
+
// an agent that could make, show or revoke tokens could outlive its own revocation. A token
|
|
479
|
+
// never gets here (it reaches the agents' API alone), and a request that says it is an agent's
|
|
480
|
+
// is refused
|
|
481
|
+
if (this.ids.kind(req) === "agent")
|
|
482
|
+
return sendJson(res, { error: "Agent tokens are for people: an agent cannot make, show or revoke them, at any level." }, 403);
|
|
483
|
+
const tokens = this.live.tokens;
|
|
484
|
+
const top = this.agents.level;
|
|
485
|
+
const me = this.ids.who(req);
|
|
486
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
487
|
+
if (path === "/api/share/agents" && req.method === "GET")
|
|
488
|
+
return sendJson(res, { level: top, levels: AGENT_LEVELS.slice(0, AGENT_LEVELS.indexOf(top) + 1), defaultLevel: lower("advise", top), days: TOKEN_DAYS, defaultDays: DEFAULT_DAYS, tokens: tokens.inForce().map(AgentTokens.info) });
|
|
489
|
+
if (req.method !== "POST")
|
|
490
|
+
return sendJson(res, { error: `${path} answers POST.` }, 405);
|
|
491
|
+
if (!me)
|
|
492
|
+
return sendJson(res, { error: "Say who you are first: a token is made, shown or revoked under your name.", who: true }, 401);
|
|
493
|
+
const r = await this.json(req, 1 << 16);
|
|
494
|
+
if (r.error !== undefined)
|
|
495
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
496
|
+
const b = (r.body ?? {});
|
|
497
|
+
if (path === "/api/share/agents/revoke") {
|
|
498
|
+
if (typeof b.id !== "string" || !tokens.revoke(b.id, me))
|
|
499
|
+
return sendJson(res, { error: "There is no such token in force." }, 404);
|
|
500
|
+
return sendJson(res, { ok: true, tokens: tokens.inForce().map(AgentTokens.info) });
|
|
501
|
+
}
|
|
502
|
+
// the server as the prompt names it
|
|
503
|
+
const address = this.publicUrl ?? reachedAt(req);
|
|
504
|
+
if (path === "/api/share/agents/prompt") {
|
|
505
|
+
// a token that ended has no prompt: it lets nothing in
|
|
506
|
+
const t = typeof b.id === "string" ? tokens.token(b.id) : null;
|
|
507
|
+
if (!t)
|
|
508
|
+
return sendJson(res, { error: "There is no such token in force." }, 404);
|
|
509
|
+
return sendJson(res, { token: AgentTokens.info(t.record), prompt: agentPrompt({ url: address, token: t.token, record: t.record }) });
|
|
510
|
+
}
|
|
511
|
+
if (path !== "/api/share/agents")
|
|
512
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
513
|
+
// the name given is the agent's as it is; none, the person's AI ("Alice's AI")
|
|
514
|
+
const name = b.name === undefined || b.name === null || (typeof b.name === "string" && !b.name.trim()) ? defaultAgentName(me) : b.name;
|
|
515
|
+
const bad = invalidAgentName(name);
|
|
516
|
+
if (bad)
|
|
517
|
+
return sendJson(res, { error: bad }, 400);
|
|
518
|
+
const level = (b.level ?? lower("advise", top));
|
|
519
|
+
if (!AGENT_LEVELS.includes(level))
|
|
520
|
+
return sendJson(res, { error: "level is read, advise or full." }, 400);
|
|
521
|
+
if (AGENT_LEVELS.indexOf(level) > AGENT_LEVELS.indexOf(top))
|
|
522
|
+
return sendJson(res, { error: `This server lets agents do at most ${top} (--agents ${top}): a token cannot give more.` }, 400);
|
|
523
|
+
const days = b.days ?? DEFAULT_DAYS;
|
|
524
|
+
if (!TOKEN_DAYS.includes(days))
|
|
525
|
+
return sendJson(res, { error: `days is one of ${TOKEN_DAYS.join(", ")}.` }, 400);
|
|
526
|
+
const made = tokens.create({ name: name.trim(), by: me, level, days: days });
|
|
527
|
+
const prompt = agentPrompt({ url: address, token: made.token, record: made.record });
|
|
528
|
+
return sendJson(res, { token: AgentTokens.info(made.record), prompt, tokens: tokens.inForce().map(AgentTokens.info) });
|
|
529
|
+
}
|
|
530
|
+
/** Inviting a person (CollabSpec decision 9), for people: the personal links in force (GET), a new
|
|
531
|
+
* one for a person named in the body (POST), and revoking one (POST …/revoke). Making and
|
|
532
|
+
* revoking need a name: who made a link, and who revoked it, is said in Share. The link itself
|
|
533
|
+
* is answered once, to whoever made it: the server keeps only its digest (server/invites.ts). */
|
|
534
|
+
async sharePeople(req, res, url) {
|
|
535
|
+
if (this.ids.kind(req) === "agent")
|
|
536
|
+
return sendJson(res, { error: "Personal links are for people: an agent cannot make, list or revoke them." }, 403);
|
|
537
|
+
if (this.ids.header)
|
|
538
|
+
return sendJson(res, { error: "Names come from the sign-in in front of this server: it has no personal links." }, 404);
|
|
539
|
+
const invites = this.live.invites;
|
|
540
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
541
|
+
const list = () => invites.inForce().map(Invites.info);
|
|
542
|
+
if (path === "/api/share/people" && req.method === "GET")
|
|
543
|
+
return sendJson(res, { invites: list() });
|
|
544
|
+
if (req.method !== "POST")
|
|
545
|
+
return sendJson(res, { error: `${path} answers POST.` }, 405);
|
|
546
|
+
const me = this.ids.who(req);
|
|
547
|
+
if (!me)
|
|
548
|
+
return sendJson(res, this.nameless(req, "a personal link is made or revoked under your name"), 401);
|
|
549
|
+
const r = await this.json(req, 4096);
|
|
550
|
+
if (r.error !== undefined)
|
|
551
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
552
|
+
const b = (r.body ?? {});
|
|
553
|
+
if (path === "/api/share/people/revoke") {
|
|
554
|
+
if (typeof b.id !== "string" || !invites.revoke(b.id, me))
|
|
555
|
+
return sendJson(res, { error: "There is no such personal link in force." }, 404);
|
|
556
|
+
return sendJson(res, { ok: true, invites: list() });
|
|
557
|
+
}
|
|
558
|
+
if (path !== "/api/share/people")
|
|
559
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
560
|
+
const bad = invalidPersonName(b.name);
|
|
561
|
+
if (bad)
|
|
562
|
+
return sendJson(res, { error: bad }, 400);
|
|
563
|
+
const made = invites.create({ name: b.name, by: me });
|
|
564
|
+
if (typeof made === "string")
|
|
565
|
+
return sendJson(res, { error: made, taken: true }, 409);
|
|
566
|
+
// the link as the person will open it: the server's address as this page reached it
|
|
567
|
+
const link = `${this.publicUrl ?? reachedAt(req)}${INVITE_PATH}${made.token}`;
|
|
568
|
+
return sendJson(res, { invite: Invites.info(made.record), link, invites: list() });
|
|
569
|
+
}
|
|
570
|
+
/** Read-only links (CollabSpec decision 11), for people: every link (GET), a new one (POST), and
|
|
571
|
+
* revoking one (POST …/revoke). Anyone let in may make one; revoking needs a name, which Share
|
|
572
|
+
* says beside the link. A server open to all, or behind a sign-in, has none. */
|
|
573
|
+
async shareReadOnly(req, res, url) {
|
|
574
|
+
if (this.ids.kind(req) === "agent")
|
|
575
|
+
return sendJson(res, { error: "Read-only links are for people: an agent cannot make, list or revoke them." }, 403);
|
|
576
|
+
if (!this.access || this.ids.header)
|
|
577
|
+
return sendJson(res, { error: this.ids.header ? "Who comes in is the sign-in's, in front of this server: it has no read-only links." : "This server lets in whoever reaches it: a read-only link would let in no one more." }, 404);
|
|
578
|
+
const links = this.live.readOnly;
|
|
579
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
580
|
+
const address = this.publicUrl ?? reachedAt(req);
|
|
581
|
+
const list = () => links.list().map((l) => ({ ...l, link: `${address}${l.path}` }));
|
|
582
|
+
if (path === "/api/share/readonly" && req.method === "GET")
|
|
583
|
+
return sendJson(res, { links: list() });
|
|
584
|
+
if (req.method !== "POST")
|
|
585
|
+
return sendJson(res, { error: `${path} answers POST.` }, 405);
|
|
586
|
+
const me = this.ids.who(req);
|
|
587
|
+
if (path === "/api/share/readonly/revoke") {
|
|
588
|
+
if (!me)
|
|
589
|
+
return sendJson(res, this.nameless(req, "a read-only link is revoked under your name"), 401);
|
|
590
|
+
const r = await this.json(req, 4096);
|
|
591
|
+
if (r.error !== undefined)
|
|
592
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
593
|
+
const id = r.body?.id;
|
|
594
|
+
if (typeof id !== "string" || !links.revoke(id, me))
|
|
595
|
+
return sendJson(res, { error: "There is no such read-only link in force." }, 404);
|
|
596
|
+
return sendJson(res, { ok: true, links: list() });
|
|
597
|
+
}
|
|
598
|
+
if (path !== "/api/share/readonly")
|
|
599
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
600
|
+
const made = links.create(me);
|
|
601
|
+
return sendJson(res, { made: { ...made, link: `${address}${made.path}` }, links: list() });
|
|
602
|
+
}
|
|
603
|
+
/** A personal link opened (CollabSpec decision 9): the browser is given the cookie that lets its
|
|
604
|
+
* holder in, as who the link says, and goes on to the page — the token leaves the address, and a
|
|
605
|
+
* place after it (a fragment) comes along. A revoked link says so; anything else is answered as
|
|
606
|
+
* any address nobody was given. */
|
|
607
|
+
enter(req, res, token) {
|
|
608
|
+
const c = this.ids.header ? null : this.live.invites.check(token);
|
|
609
|
+
if (c && "ok" in c) {
|
|
610
|
+
res.writeHead(303, { location: "/", "set-cookie": this.live.invites.cookie(c.ok), "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
611
|
+
return void res.end();
|
|
612
|
+
}
|
|
613
|
+
if (c) {
|
|
614
|
+
const r = c.record;
|
|
615
|
+
return sendText(req, res, `This personal link was revoked by ${r.revokedBy} on ${r.revoked.slice(0, 10)}. Ask whoever invited you for a new one.\n`, "text/plain; charset=utf-8", 410);
|
|
616
|
+
}
|
|
617
|
+
sendJson(res, { error: "not found" }, 404);
|
|
618
|
+
}
|
|
619
|
+
/** A read-only link opened (CollabSpec decision 11): the browser is given the cookie that lets
|
|
620
|
+
* its holder read, and goes on to the page — the token leaves the address, and a place after it
|
|
621
|
+
* (a fragment) comes along. A revoked link says so; anything else, and any link on a server
|
|
622
|
+
* open to all or behind a sign-in, which have none, is answered as an address nobody was given. */
|
|
623
|
+
enterReadOnly(req, res, token) {
|
|
624
|
+
const c = this.access && !this.ids.header ? this.live.readOnly.check(token) : null;
|
|
625
|
+
if (c && "ok" in c) {
|
|
626
|
+
res.writeHead(303, { location: "/", "set-cookie": this.live.readOnly.cookie(c.ok.id), "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
627
|
+
return void res.end();
|
|
628
|
+
}
|
|
629
|
+
if (c) {
|
|
630
|
+
const l = c.link;
|
|
631
|
+
return sendText(req, res, `This read-only link was revoked by ${l.revokedBy} on ${l.revoked.slice(0, 10)}. Ask whoever gave it to you for a new one.\n`, "text/plain; charset=utf-8", 410);
|
|
632
|
+
}
|
|
633
|
+
sendJson(res, { error: "not found" }, 404);
|
|
634
|
+
}
|
|
635
|
+
/** Who the reader is (POST /api/identity {name}): the name someone invited by a personal link goes
|
|
636
|
+
* by from now on — kept with their link, and said in their review log, so that what they wrote
|
|
637
|
+
* shows under it — or, for a guest, whether a name is theirs to take: one a link's holder goes
|
|
638
|
+
* by is not (409). A guest's name is their page's own; nothing of it is kept here. */
|
|
639
|
+
async identity(req, res) {
|
|
640
|
+
if (this.ids.kind(req) === "agent")
|
|
641
|
+
return sendJson(res, { error: "An agent's name is its own: its token's, or the one it sends." }, 403);
|
|
642
|
+
if (this.ids.header)
|
|
643
|
+
return sendJson(res, { error: "Your name is the sign-in's, in front of this server: it cannot be changed here." }, 403);
|
|
644
|
+
const r = await this.json(req, 4096);
|
|
645
|
+
if (r.error !== undefined)
|
|
646
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
647
|
+
const name = r.body?.name;
|
|
648
|
+
const bad = invalidPersonName(name);
|
|
649
|
+
if (bad)
|
|
650
|
+
return sendJson(res, { error: bad }, 400);
|
|
651
|
+
const n = name.trim();
|
|
652
|
+
const p = this.ids.person(req);
|
|
653
|
+
if (!p) {
|
|
654
|
+
const holder = this.live.invites.holder(n);
|
|
655
|
+
return holder ? sendJson(res, { error: taken(holder), taken: true }, 409) : sendJson(res, { ok: true, name: n });
|
|
656
|
+
}
|
|
657
|
+
const was = p.name;
|
|
658
|
+
const done = this.live.invites.rename(p.id, n);
|
|
659
|
+
if (typeof done === "string")
|
|
660
|
+
return sendJson(res, { error: done, taken: true }, 409);
|
|
661
|
+
// their log says so, where they have one: what they wrote shows under the new name everywhere.
|
|
662
|
+
// A log that cannot be written to now (a line to mend by hand) says it with their next line,
|
|
663
|
+
// which carries the name it is written under
|
|
664
|
+
if (done.name !== was)
|
|
665
|
+
this.live.review.named(p.id, done.name);
|
|
666
|
+
return sendJson(res, { ok: true, name: done.name, person: Invites.info(done) });
|
|
667
|
+
}
|
|
668
|
+
/** The answer to a request that needs a name and has none: the page asks for one (`who`). Where
|
|
669
|
+
* the name the page gives is a personal link holder's, it says so, and the page asks for
|
|
670
|
+
* another. */
|
|
671
|
+
nameless(req, what) {
|
|
672
|
+
const holder = this.ids.taken(req);
|
|
673
|
+
return { error: holder ? `${taken(holder)} (Click your name.)` : `Say who you are first: ${what}.`, who: true };
|
|
674
|
+
}
|
|
675
|
+
/** The library's operations (server/library.ts), shared with the agents' API. */
|
|
676
|
+
library() {
|
|
677
|
+
return libraryApiOf(this.live, () => ({
|
|
678
|
+
review: this.live.review,
|
|
679
|
+
project: this.live.project,
|
|
680
|
+
library: this.live.library,
|
|
681
|
+
graph: () => {
|
|
682
|
+
const v = this.agents.reviews.graph();
|
|
683
|
+
return "status" in v ? null : v;
|
|
684
|
+
},
|
|
685
|
+
// every file the library takes shows on the Project: a note carries it, on the ring
|
|
686
|
+
fileAdded: (f, who, note) => void this.live.views.fileAdded(f, { by: who.name, agent: who.agent, person: who.person ?? null }, note),
|
|
687
|
+
}));
|
|
688
|
+
}
|
|
689
|
+
/** A request about views (server/views.ts): read here, answered there. A private page's is
|
|
690
|
+
* answered as its, recorded without its name (CollabSpec decision 8). */
|
|
691
|
+
async views(req, res, url) {
|
|
692
|
+
const priv = this.ids.privateOf(req);
|
|
693
|
+
const person = this.ids.person(req);
|
|
694
|
+
const caller = { name: this.ids.who(req), kind: this.ids.kind(req), ...(person ? { person: person.id } : {}), ...(priv !== null ? { private: { session: priv || null } } : {}) };
|
|
695
|
+
let body = null;
|
|
696
|
+
if (req.method === "POST") {
|
|
697
|
+
// a board's layout memory is as large as the board; nothing else about views is
|
|
698
|
+
const r = await this.json(req, url.pathname.replace(/\/+$/, "") === "/api/views/memory" ? MAX_MEMORY_BYTES : 1 << 20);
|
|
699
|
+
if (r.error !== undefined)
|
|
700
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
701
|
+
body = r.body;
|
|
702
|
+
}
|
|
703
|
+
const a = this.live.views.handle({ method: req.method ?? "GET", path: url.pathname, query: url.searchParams, body, caller }, this.agents.level);
|
|
704
|
+
sendJson(res, a.body, a.status, a.headers);
|
|
705
|
+
}
|
|
706
|
+
/** A request's JSON body, or why it has none. A body larger than `limit` is answered here and
|
|
707
|
+
* now, 413 (as the agents' API words its errors under /api/agent), and the connection closed
|
|
708
|
+
* after it, without reading the rest; whoever asked for the body is told `too large`, and what
|
|
709
|
+
* it answers after is not sent (sendJson). */
|
|
710
|
+
async json(req, limit) {
|
|
711
|
+
let text;
|
|
712
|
+
try {
|
|
713
|
+
text = await readBody(req, limit);
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
const res = this.responses.get(req);
|
|
717
|
+
if (!(e instanceof TooLarge) || !res)
|
|
718
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
719
|
+
const size = limit >= 1 << 20 ? `${Math.round(limit / (1 << 20))} MB` : `${Math.round(limit / 1024)} KB`;
|
|
720
|
+
const agent = (req.url ?? "").startsWith("/api/agent");
|
|
721
|
+
tooLarge(req, res, { error: `The body is larger than ${size}, as much as this request takes.`, ...(agent ? { hint: "Send less at once: fewer items per request (POST /api/agent/opinions takes 100 at most), or split the request." } : {}) });
|
|
722
|
+
return { error: "too large" };
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
return { body: JSON.parse(text) };
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
return { error: "not JSON" };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/** each request's response, for what answers a request while reading it (a body too large) */
|
|
732
|
+
responses = new WeakMap();
|
|
733
|
+
/** Answer a request; whatever goes wrong in answering it is answered too, 500 with what it was,
|
|
734
|
+
* rather than left hanging or taking the server down. */
|
|
735
|
+
handle(req, res) {
|
|
736
|
+
this.responses.set(req, res);
|
|
737
|
+
this.route(req, res).catch((e) => {
|
|
738
|
+
console.error(`${req.method} ${req.url?.slice(0, 200)}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
|
|
739
|
+
const agent = (req.url ?? "").startsWith("/api/agent");
|
|
740
|
+
if (!res.headersSent)
|
|
741
|
+
sendJson(res, { error: e instanceof Error ? e.message : String(e), ...(agent ? { hint: "This is the server's fault; the request may work again." } : {}) }, 500);
|
|
742
|
+
else
|
|
743
|
+
res.destroy();
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async route(req, res) {
|
|
747
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
748
|
+
const path = url.pathname;
|
|
749
|
+
const live = this.live;
|
|
750
|
+
// every route; whatever one throws, handle() answers
|
|
751
|
+
{
|
|
752
|
+
// an agent's own token (server/tokens.ts) lets it into its API alone: the page and the page's
|
|
753
|
+
// API answer it as they answer an outsider. A token that ended says so, to whoever held it.
|
|
754
|
+
const bearer = bearerOf(req.headers.authorization);
|
|
755
|
+
if (bearer && looksLikeToken(bearer)) {
|
|
756
|
+
const t = live.tokens.check(bearer);
|
|
757
|
+
if (!t)
|
|
758
|
+
return sendJson(res, this.access ? { error: "not found" } : { error: "This token is not one of this server's.", hint: "Check that it was copied whole (vpa_…), or ask the person who shared with you for a new one." }, this.access ? 404 : 401);
|
|
759
|
+
if ("ended" in t)
|
|
760
|
+
return sendJson(res, {
|
|
761
|
+
error: t.ended === "revoked" ? `This token was revoked by ${t.record.revokedBy} at ${t.record.revoked}.` : `This token expired at ${t.record.expires}.`,
|
|
762
|
+
hint: "Ask the person who shared with you for a new one (Share, then AI agent, in the page).",
|
|
763
|
+
}, 401);
|
|
764
|
+
if (path !== "/api/agent" && !path.startsWith("/api/agent/"))
|
|
765
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
766
|
+
return this.agent(req, res, url, t.ok);
|
|
767
|
+
}
|
|
768
|
+
// a personal link (CollabSpec decision 9, server/invites.ts): it lets its holder in, as who
|
|
769
|
+
// it says, and its cookie does from then on; a revoked one's cookie is forgotten, and lets
|
|
770
|
+
// nobody in
|
|
771
|
+
if (path.startsWith(INVITE_PATH))
|
|
772
|
+
return this.enter(req, res, path.slice(INVITE_PATH.length));
|
|
773
|
+
// a read-only link (CollabSpec decision 11, server/readonly.ts): its cookie lets its holder
|
|
774
|
+
// read, where nothing else lets them in
|
|
775
|
+
if (path.startsWith(READONLY_PATH))
|
|
776
|
+
return this.enterReadOnly(req, res, path.slice(READONLY_PATH.length));
|
|
777
|
+
const link = this.ids.link(req);
|
|
778
|
+
const cookies = link && "ended" in link ? [live.invites.forget()] : [];
|
|
779
|
+
const ro = this.access && !this.ids.header ? live.readOnly.ofCookie(req.headers.cookie) : null;
|
|
780
|
+
if (ro && "ended" in ro)
|
|
781
|
+
cookies.push(live.readOnly.forget());
|
|
782
|
+
if (this.access) {
|
|
783
|
+
const a = link && "ok" in link && !path.startsWith(KEY_PATH) ? "in" : this.access.admit(req, path);
|
|
784
|
+
if (a === "out") {
|
|
785
|
+
// nothing lets them in to write: their read-only link does, or --public, to read
|
|
786
|
+
const reader = ro && "ok" in ro ? ro.ok.id : this.publicRead && !this.ids.header ? "public" : null;
|
|
787
|
+
if (reader === null || this.ids.kind(req) === "agent") {
|
|
788
|
+
if (cookies.length)
|
|
789
|
+
res.setHeader("set-cookie", cookies);
|
|
790
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
791
|
+
}
|
|
792
|
+
this.readOnlyOf.set(req, reader);
|
|
793
|
+
// the page renews a read-only link's cookie, as a personal link's
|
|
794
|
+
if (reader !== "public" && (path === "/" || path === "/index.html"))
|
|
795
|
+
cookies.push(live.readOnly.cookie(reader));
|
|
796
|
+
// what a read-only reader may ask; the rest is refused, whatever the page sends
|
|
797
|
+
if (!readOnlyAllows(req.method ?? "GET", path)) {
|
|
798
|
+
if (cookies.length)
|
|
799
|
+
res.setHeader("set-cookie", cookies);
|
|
800
|
+
return sendJson(res, { error: "This page is read-only: it changes nothing that is shared.", readOnly: true }, 403);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else if (a !== "in") {
|
|
804
|
+
// the link: the browser keeps the cookie, and the key leaves the address (a fragment
|
|
805
|
+
// after the key, a place in the page, comes along to "/")
|
|
806
|
+
res.writeHead(303, { location: "/", "set-cookie": [a.enter, ...cookies], "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
807
|
+
return void res.end();
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
// let in by the cookie every server used to share: the browser is given this one's own
|
|
811
|
+
const renew = this.access.renewal(req);
|
|
812
|
+
if (renew)
|
|
813
|
+
cookies.push(renew);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// the page renews a personal link's cookie: its holder stays in as long as they come back
|
|
817
|
+
if (link && "ok" in link && (path === "/" || path === "/index.html"))
|
|
818
|
+
cookies.push(live.invites.cookie(link.ok));
|
|
819
|
+
if (cookies.length)
|
|
820
|
+
res.setHeader("set-cookie", cookies);
|
|
821
|
+
if (path === "/" || path === "/index.html")
|
|
822
|
+
return sendFile(req, res, join(WEB, "index.html"));
|
|
823
|
+
if (path === "/style.css")
|
|
824
|
+
return sendFile(req, res, join(WEB, "style.css"));
|
|
825
|
+
// the fonts an artwork is drawn in (ExportSpec decision 19)
|
|
826
|
+
if (FONT_PATH.test(path))
|
|
827
|
+
return sendFile(req, res, join(WEBFONTS, path.slice("/fonts/".length)));
|
|
828
|
+
// the page, and its workers: the one that lays out the Time Machine's past graphs
|
|
829
|
+
// (web/src/layoutworker.ts), the one that chooses the overview's edges (web/src/edgeworker.ts),
|
|
830
|
+
// and pdf.js's, which reads the library's PDFs for the reference window (AttachmentSpec decision 11)
|
|
831
|
+
if (/^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path))
|
|
832
|
+
return sendFile(req, res, join(bundleDir(), path.slice(1)));
|
|
833
|
+
if (path.startsWith("/pdfjs/"))
|
|
834
|
+
return pdfjsAsset(res, path);
|
|
835
|
+
// with Lean's language server as it is (server/hover.ts): what is open, what crashed
|
|
836
|
+
if (path === "/api/status")
|
|
837
|
+
return sendJson(res, { ...live.status(), lean: this.hover.state() });
|
|
838
|
+
// the link that lets someone in, for a page that shares where it is (web/src/share.ts); a
|
|
839
|
+
// request gets here only once it is in
|
|
840
|
+
// a read-only reader shares the link they came by, or the address alone (--public)
|
|
841
|
+
const reader = this.readOnlyOf.get(req);
|
|
842
|
+
if (path === "/api/share") {
|
|
843
|
+
if (reader !== undefined)
|
|
844
|
+
return sendJson(res, { path: reader === "public" ? "/" : (live.readOnly.get(reader)?.path ?? null), readOnly: true });
|
|
845
|
+
return sendJson(res, { path: this.access?.link ?? null, readOnlyLinks: !!this.access && !this.ids.header, public: this.publicRead });
|
|
846
|
+
}
|
|
847
|
+
if (path === "/api/share/readonly" || path.startsWith("/api/share/readonly/"))
|
|
848
|
+
return this.shareReadOnly(req, res, url);
|
|
849
|
+
if (path.startsWith("/api/share/agents"))
|
|
850
|
+
return this.shareAgents(req, res, url);
|
|
851
|
+
if (path === "/api/share/people" || path.startsWith("/api/share/people/"))
|
|
852
|
+
return this.sharePeople(req, res, url);
|
|
853
|
+
if (path === "/api/chat")
|
|
854
|
+
return this.chat(req, res, url);
|
|
855
|
+
if (path === "/api/chat/new")
|
|
856
|
+
return this.chatNew(req, res);
|
|
857
|
+
if (path === "/api/ask" || path === "/api/ask/answer")
|
|
858
|
+
return this.ask(req, res, url);
|
|
859
|
+
if (path === "/api/agent" || path.startsWith("/api/agent/"))
|
|
860
|
+
return this.agent(req, res, url);
|
|
861
|
+
// the views, each a shared board (ViewSpec.md)
|
|
862
|
+
if (path === "/api/views" || path.startsWith("/api/views/"))
|
|
863
|
+
return this.views(req, res, url);
|
|
864
|
+
// the snapshots of the graph, one per build (HistorySpec.md): the Time Machine reads them; the
|
|
865
|
+
// moves, the lost-and-found and its archive
|
|
866
|
+
if (path === "/api/history" || path.startsWith("/api/history/")) {
|
|
867
|
+
let body = null;
|
|
868
|
+
if (req.method === "POST") {
|
|
869
|
+
// a name's every entry archived at once is a few hundred keys at most
|
|
870
|
+
const r = await this.json(req, 1 << 20);
|
|
871
|
+
if (r.error !== undefined)
|
|
872
|
+
return sendJson(res, { error: `The body is ${r.error}.` }, 400);
|
|
873
|
+
body = r.body;
|
|
874
|
+
}
|
|
875
|
+
// a move, an archiving or a putting back is recorded as the review is: under the reader's
|
|
876
|
+
// name — a private reader's too (CollabSpec decision 8) — with the personal link of someone
|
|
877
|
+
// invited by one, whose moves go to their link's log (decision 9); by an agent at level full
|
|
878
|
+
const person = this.ids.person(req)?.id;
|
|
879
|
+
const a = historyAnswer(live, { method: req.method ?? "GET", path, query: url.searchParams, body, caller: { name: this.ids.who(req), kind: this.ids.kind(req), ...(person ? { person } : {}) } }, (q) => q.caller.kind === "agent" ? refusal(this.agents.level, "full") : null);
|
|
880
|
+
// a snapshot's graph in parts, as the Time Machine takes it: compressed once, and tagged
|
|
881
|
+
if (a.gz)
|
|
882
|
+
return sendGz(req, res, a.gz.body, a.gz.type);
|
|
883
|
+
return sendJson(res, a.body, a.status, a.headers);
|
|
884
|
+
}
|
|
885
|
+
// the library of papers and images, and the links into them (AttachmentSpec.md,
|
|
886
|
+
// server/libraryroute.ts)
|
|
887
|
+
if (path === "/api/library" || path.startsWith("/api/library/"))
|
|
888
|
+
return libraryRoute({ ids: this.ids, level: this.agents.level, api: this.library() }, req, res, url);
|
|
889
|
+
// an agent may use the page's own endpoints too: the same rules hold there (AgentSpec)
|
|
890
|
+
const agent = this.ids.kind(req) === "agent";
|
|
891
|
+
// the review (ReviewSpec.md): the whole of it, as a page takes it on, with the lines of its
|
|
892
|
+
// logs that cannot be read
|
|
893
|
+
if (path === "/api/review" && req.method === "GET")
|
|
894
|
+
return sendJson(res, live.review.whole());
|
|
895
|
+
if (path === "/api/review" && req.method === "POST") {
|
|
896
|
+
// an opinion, a target, a trusted prefix or a key declaration is recorded under the
|
|
897
|
+
// reader's name (CollabSpec decision 3), not one in the body; what it is about is
|
|
898
|
+
// bound by the server (server/reviewapi.ts)
|
|
899
|
+
const me = this.ids.who(req);
|
|
900
|
+
if (!me)
|
|
901
|
+
return sendJson(res, this.nameless(req, "what you record in the review is recorded under your name"), 401);
|
|
902
|
+
const person = this.ids.person(req)?.id ?? null;
|
|
903
|
+
return this.json(req, 1 << 20).then(({ body, error }) => {
|
|
904
|
+
if (error)
|
|
905
|
+
return sendJson(res, { error: `The body is ${error}.` }, 400);
|
|
906
|
+
// an agent records here what its level allows, as through its own API (AgentSpec decision
|
|
907
|
+
// 9): an opinion as advice, the rest at full; in its own log, marked as an agent's
|
|
908
|
+
const no = agent ? refusal(this.agents.level, needOf(body)) : null;
|
|
909
|
+
if (no)
|
|
910
|
+
return sendJson(res, no.body, no.status);
|
|
911
|
+
// someone invited by a personal link writes to the log of their link (shared/review.ts)
|
|
912
|
+
const a = this.agents.reviews.post(body, { name: me, agent, person });
|
|
913
|
+
sendJson(res, a.body, a.status);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
if (path === "/api/export" || path.startsWith("/api/export/"))
|
|
917
|
+
return this.export(req, res, url, agent, reader !== undefined);
|
|
918
|
+
// who the reader is (CollabSpec decisions 3 and 9): the name their page gives, the proxy's, or
|
|
919
|
+
// their personal link's — with the link, and whether the page's own name is a link holder's
|
|
920
|
+
// and not theirs; a link revoked says by whom
|
|
921
|
+
if (path === "/api/identity" && req.method === "GET") {
|
|
922
|
+
const person = this.ids.person(req);
|
|
923
|
+
const holder = this.ids.taken(req);
|
|
924
|
+
const ended = link && "ended" in link ? link.record : null;
|
|
925
|
+
if (reader !== undefined)
|
|
926
|
+
return sendJson(res, { mode: this.ids.mode, name: null, readOnly: { by: reader === "public" ? "public" : "link" } });
|
|
927
|
+
return sendJson(res, {
|
|
928
|
+
mode: this.ids.mode,
|
|
929
|
+
name: this.ids.who(req),
|
|
930
|
+
...(person ? { person: Invites.info(person) } : {}),
|
|
931
|
+
...(holder ? { taken: taken(holder) } : {}),
|
|
932
|
+
...(ended ? { revoked: { by: ended.revokedBy, at: ended.revoked, name: ended.name } } : {}),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
if (path === "/api/identity" && req.method === "POST")
|
|
936
|
+
return this.identity(req, res);
|
|
937
|
+
// where a reader is: the others see it (CollabSpec decision 4); of a private reader, only that
|
|
938
|
+
// they are here (decision 8)
|
|
939
|
+
if (path === "/api/presence" && req.method === "POST") {
|
|
940
|
+
const r = await this.json(req, 16384);
|
|
941
|
+
if (r.error !== undefined)
|
|
942
|
+
return sendJson(res, { error: r.error }, 400);
|
|
943
|
+
const b = (r.body ?? {});
|
|
944
|
+
// an agent is one entry by its name (server/presence.ts), never private; a page speaks for
|
|
945
|
+
// its own event stream only, by the secret the stream was given (`hello`)
|
|
946
|
+
const name = this.ids.who(req);
|
|
947
|
+
const priv = !agent && b.private === true;
|
|
948
|
+
// agents have no artwork (ExportSpec decision 19): the page draws it
|
|
949
|
+
if (agent && name)
|
|
950
|
+
live.presence.agentBeat(name, withoutArtwork(cleanView(b.view)));
|
|
951
|
+
else if (!live.beat(b.token, priv ? null : name, priv ? null : cleanView(b.view), !priv && b.idle === true, priv, !!this.ids.person(req)))
|
|
952
|
+
return sendJson(res, { error: "This heartbeat is not from an open event stream of this page's: it is sent with the token the stream says hello with.", stream: false }, 403);
|
|
953
|
+
return sendJson(res, { ok: true });
|
|
954
|
+
}
|
|
955
|
+
if (path === "/api/events") {
|
|
956
|
+
const session = url.searchParams.get("session");
|
|
957
|
+
return live.subscribe(res, validSession(session) ? session : null, this.ids.person(req)?.id ?? null, reader ?? null);
|
|
958
|
+
}
|
|
959
|
+
// what happened (CollabSpec decision 5): what the server stored; the review's own history the
|
|
960
|
+
// page reads from the review
|
|
961
|
+
if (path === "/api/activity")
|
|
962
|
+
return sendJson(res, { events: live.timeline.events.slice(-2000) });
|
|
963
|
+
// a layout batch an agent made, undone (AgentSpec decision 4): from a page's Activity, by anyone;
|
|
964
|
+
// by a private reader, without their name (CollabSpec decision 8)
|
|
965
|
+
if (path === "/api/layout/undo" && req.method === "POST") {
|
|
966
|
+
const priv = this.ids.privateOf(req) !== null;
|
|
967
|
+
const me = layoutAuthor({ name: this.ids.who(req), private: priv });
|
|
968
|
+
if (!me && !priv)
|
|
969
|
+
return sendJson(res, { error: "Say who you are first.", who: true }, 401);
|
|
970
|
+
const no = agent ? refusal(this.agents.level, "full") : null;
|
|
971
|
+
if (no)
|
|
972
|
+
return sendJson(res, no.body, no.status);
|
|
973
|
+
const r = await this.json(req, 4096);
|
|
974
|
+
if (r.error !== undefined)
|
|
975
|
+
return sendJson(res, { error: r.error }, 400);
|
|
976
|
+
const a = this.agents.undo(me, agent, r.body?.id);
|
|
977
|
+
return sendJson(res, a.body, a.status);
|
|
978
|
+
}
|
|
979
|
+
if (path === "/api/meta" || path === "/api/edges") {
|
|
980
|
+
const g = live.graph;
|
|
981
|
+
if (!g)
|
|
982
|
+
return sendJson(res, { error: "not ready" }, 503);
|
|
983
|
+
// a page asks for the version it was told about; an older one is gone
|
|
984
|
+
const want = url.searchParams.get("v");
|
|
985
|
+
if (want && Number(want) !== g.version)
|
|
986
|
+
return sendJson(res, { error: "superseded", version: g.version }, 409);
|
|
987
|
+
// a page asks for the edges packed (shared/edgecodec.ts; `pack=2` with the grammar site, a
|
|
988
|
+
// page from before it `pack=1`, which takes grammar for a proof); an agent gets them as they
|
|
989
|
+
// are
|
|
990
|
+
const pack = url.searchParams.get("pack");
|
|
991
|
+
return path === "/api/meta"
|
|
992
|
+
? sendGz(req, res, g.prep.metaGz, "application/json")
|
|
993
|
+
: sendGz(req, res, pack === "1" || pack === "2" ? packedGz(g.prep.edgesGz, pack === "1" ? 1 : 2) : g.prep.edgesGz, "application/octet-stream");
|
|
994
|
+
}
|
|
995
|
+
if (path === "/api/hover" || path === "/api/hover/open" || path === "/api/hover/names" || path === "/api/hover/describe" || path === "/api/hover/print") {
|
|
996
|
+
// what Lean says is at a position of a project file (server/hover.ts). Only the graph's
|
|
997
|
+
// modules (meta.modules) reach Lean: the module names the file the graph was read from, so
|
|
998
|
+
// no path from the client reaches the disk, and no other file in the project's folder — an
|
|
999
|
+
// old copy, a CI script — is ever elaborated or imported
|
|
1000
|
+
if (!live.graph)
|
|
1001
|
+
return sendJson(res, { error: "not ready" }, 503);
|
|
1002
|
+
const mod = url.searchParams.get("module") ?? "";
|
|
1003
|
+
const rel = live.moduleFile(mod);
|
|
1004
|
+
if (rel === undefined)
|
|
1005
|
+
return sendJson(res, { error: `${mod.slice(0, 300)} is not a module of the graph` }, 404);
|
|
1006
|
+
// a read-only reader (CollabSpec decision 11) is answered what Lean already said of this
|
|
1007
|
+
// build, else asks it within bounds, per address and in all
|
|
1008
|
+
const keyOf = reader !== undefined && req.method === "GET" ? `${live.graph.fingerprint}\0${path}\0${url.search}` : null;
|
|
1009
|
+
const kept = keyOf ? this.bounds.answer(keyOf) : undefined;
|
|
1010
|
+
if (kept !== undefined)
|
|
1011
|
+
return sendJson(res, kept);
|
|
1012
|
+
if (reader !== undefined) {
|
|
1013
|
+
const wait = this.bounds.leanWait(req.socket.remoteAddress ?? "");
|
|
1014
|
+
if (wait)
|
|
1015
|
+
return sendJson(res, { error: "Lean is asked too often from here: try again in a moment.", retryAfter: wait }, 429, { "retry-after": String(wait) });
|
|
1016
|
+
}
|
|
1017
|
+
const reply = (body) => {
|
|
1018
|
+
if (keyOf && finalAnswer(body))
|
|
1019
|
+
this.bounds.keep(keyOf, body);
|
|
1020
|
+
return sendJson(res, body);
|
|
1021
|
+
};
|
|
1022
|
+
if (path === "/api/hover/describe") {
|
|
1023
|
+
// a declaration by name, where the module on show is imported: what a name in a hover card
|
|
1024
|
+
// shows. The name is checked to be a name before it is written into Lean source.
|
|
1025
|
+
const name = url.searchParams.get("name") ?? "";
|
|
1026
|
+
if (!isDeclName(name))
|
|
1027
|
+
return sendJson(res, { error: "not a name" }, 400);
|
|
1028
|
+
return reply(await this.hover.describe(mod, name, 6000));
|
|
1029
|
+
}
|
|
1030
|
+
if (path === "/api/hover/print") {
|
|
1031
|
+
// what Lean's #print says of a declaration it generated from a command, which has no text
|
|
1032
|
+
// of its own (the source window, under the command that made it, when asked)
|
|
1033
|
+
const name = url.searchParams.get("name") ?? "";
|
|
1034
|
+
if (!isDeclName(name))
|
|
1035
|
+
return sendJson(res, { error: "not a name" }, 400);
|
|
1036
|
+
return reply(await this.hover.print(mod, name, false, 6000));
|
|
1037
|
+
}
|
|
1038
|
+
if (rel === null)
|
|
1039
|
+
return sendJson(res, { error: `no source for ${mod}` }, 404);
|
|
1040
|
+
const text = readFileSync(join(live.project.root, rel), "utf8");
|
|
1041
|
+
const hash = lakeTextHash(text);
|
|
1042
|
+
if (path === "/api/hover/open") {
|
|
1043
|
+
try {
|
|
1044
|
+
await this.hover.open(rel, text, hash);
|
|
1045
|
+
return sendJson(res, { ok: true });
|
|
1046
|
+
}
|
|
1047
|
+
catch (e) {
|
|
1048
|
+
return sendJson(res, { error: this.hover.failure ?? (e instanceof Error ? e.message : String(e)) });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// the page shows another text than there is now: it reads the source again first
|
|
1052
|
+
const want = url.searchParams.get("hash");
|
|
1053
|
+
if (want && want !== hash)
|
|
1054
|
+
return sendJson(res, { stale: true });
|
|
1055
|
+
if (path === "/api/hover/names") {
|
|
1056
|
+
// the positions of the names in a declaration's source, as JSON [[line, character], …]
|
|
1057
|
+
const r = await this.json(req, 1 << 20);
|
|
1058
|
+
if (r.error !== undefined)
|
|
1059
|
+
return sendJson(res, { error: r.error }, 400);
|
|
1060
|
+
const ps = r.body;
|
|
1061
|
+
const ok = Array.isArray(ps) && ps.length <= 5000 && ps.every((p) => Array.isArray(p) && p.length === 2 && p.every((x) => Number.isInteger(x) && x >= 0));
|
|
1062
|
+
if (!ok)
|
|
1063
|
+
return sendJson(res, { error: "positions" }, 400);
|
|
1064
|
+
return sendJson(res, await this.hover.names(rel, text, hash, ps, 8000));
|
|
1065
|
+
}
|
|
1066
|
+
const line = Number(url.searchParams.get("line")), character = Number(url.searchParams.get("character"));
|
|
1067
|
+
if (!Number.isInteger(line) || !Number.isInteger(character) || line < 0 || character < 0)
|
|
1068
|
+
return sendJson(res, { error: "position" }, 400);
|
|
1069
|
+
return reply(await this.hover.hover(rel, text, hash, line, character, 6000));
|
|
1070
|
+
}
|
|
1071
|
+
if (path === "/api/source") {
|
|
1072
|
+
// only the graph's modules, from the file the graph was read from: no path from the client
|
|
1073
|
+
// ever reaches the disk, and no file outside the graph is served
|
|
1074
|
+
if (!live.graph)
|
|
1075
|
+
return sendJson(res, { error: "not ready" }, 503);
|
|
1076
|
+
const mod = url.searchParams.get("module") ?? "";
|
|
1077
|
+
const rel = live.moduleFile(mod);
|
|
1078
|
+
if (!rel)
|
|
1079
|
+
return sendJson(res, { error: `no source for ${mod.slice(0, 300)}: it is not a module of the graph` }, 404);
|
|
1080
|
+
const text = readFileSync(join(live.project.root, rel), "utf8");
|
|
1081
|
+
const hash = lakeTextHash(text);
|
|
1082
|
+
// the usage pass's contexts of the module (shared/context.ts), when this is the text it
|
|
1083
|
+
// elaborated
|
|
1084
|
+
const contexts = live.contextsOf(mod);
|
|
1085
|
+
// the hash says whether this is the text the graph was read from (meta.modules[].compiled)
|
|
1086
|
+
return sendJson(res, { module: mod, file: rel, text, hash, ...(contexts && contexts.hash === hash ? { contexts } : {}) });
|
|
1087
|
+
}
|
|
1088
|
+
sendJson(res, { error: "not found" }, 404);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** an artwork's font file (assets/webfonts/) */
|
|
1093
|
+
const FONT_PATH = /^\/fonts\/(source-sans-3|source-serif-4|jost)-[a-z-]+\.woff2$/;
|
|
1094
|
+
const TYPES = {
|
|
1095
|
+
".woff2": "font/woff2",
|
|
1096
|
+
".html": "text/html; charset=utf-8",
|
|
1097
|
+
".css": "text/css; charset=utf-8",
|
|
1098
|
+
".js": "text/javascript; charset=utf-8",
|
|
1099
|
+
".map": "application/json",
|
|
1100
|
+
};
|
|
1101
|
+
// Pages are often far from the server (CollabSpec, "Far pages"): every byte counts, and what a
|
|
1102
|
+
// browser has it should not fetch again. The page's files and the graph are sent compressed, with
|
|
1103
|
+
// a tag naming their content; a browser that has that content already is told so (304) and sends
|
|
1104
|
+
// nothing more. Everything else in JSON is compressed when it is large.
|
|
1105
|
+
/** The tag naming a content. */
|
|
1106
|
+
function tagOf(body) {
|
|
1107
|
+
return `"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
|
|
1108
|
+
}
|
|
1109
|
+
/** Whether the request already has the content `etag` names. */
|
|
1110
|
+
function fresh(req, etag) {
|
|
1111
|
+
const have = req.headers["if-none-match"];
|
|
1112
|
+
return !!have && have.split(",").some((t) => t.trim().replace(/^W\//, "") === etag);
|
|
1113
|
+
}
|
|
1114
|
+
function acceptsGzip(req) {
|
|
1115
|
+
return /\bgzip\b/.test(String(req?.headers["accept-encoding"] ?? ""));
|
|
1116
|
+
}
|
|
1117
|
+
/** the page's files as last read: compressed and tagged once per change on disk */
|
|
1118
|
+
const files = new Map();
|
|
1119
|
+
function sendFile(req, res, file) {
|
|
1120
|
+
if (refused.has(res))
|
|
1121
|
+
return;
|
|
1122
|
+
if (!existsSync(file))
|
|
1123
|
+
return sendJson(res, { error: "not found" }, 404);
|
|
1124
|
+
const st = statSync(file);
|
|
1125
|
+
let f = files.get(file);
|
|
1126
|
+
if (!f || f.mtime !== st.mtimeMs || f.size !== st.size) {
|
|
1127
|
+
const body = readFileSync(file);
|
|
1128
|
+
f = { mtime: st.mtimeMs, size: st.size, body, gz: null, etag: tagOf(body) };
|
|
1129
|
+
files.set(file, f);
|
|
1130
|
+
}
|
|
1131
|
+
const head = { "content-type": TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-cache", etag: f.etag, vary: "accept-encoding" };
|
|
1132
|
+
if (fresh(req, f.etag)) {
|
|
1133
|
+
res.writeHead(304, head);
|
|
1134
|
+
return void res.end();
|
|
1135
|
+
}
|
|
1136
|
+
if (!acceptsGzip(req)) {
|
|
1137
|
+
res.writeHead(200, head);
|
|
1138
|
+
return void res.end(f.body);
|
|
1139
|
+
}
|
|
1140
|
+
f.gz ??= gzipSync(f.body, { level: 9 });
|
|
1141
|
+
res.writeHead(200, { ...head, "content-encoding": "gzip" });
|
|
1142
|
+
res.end(f.gz);
|
|
1143
|
+
}
|
|
1144
|
+
/** the most an agent's request body may be (docs/agent-api/requests.md: 1 MB) */
|
|
1145
|
+
const AGENT_BODY = 1 << 20;
|
|
1146
|
+
/** how long the rest of a body refused as too large is read before it is answered all the same,
|
|
1147
|
+
* and the connection cut: long enough for a client to finish sending what it began, not for an
|
|
1148
|
+
* endless body */
|
|
1149
|
+
const TOO_LARGE_GRACE_MS = 2000;
|
|
1150
|
+
class TooLarge extends Error {
|
|
1151
|
+
}
|
|
1152
|
+
/** A request's body, refused (TooLarge) once it passes `limit` bytes: what comes after is not kept. */
|
|
1153
|
+
function readBody(req, limit) {
|
|
1154
|
+
return new Promise((ok, fail) => {
|
|
1155
|
+
const parts = [];
|
|
1156
|
+
let size = 0;
|
|
1157
|
+
if (Number(req.headers["content-length"]) > limit)
|
|
1158
|
+
return fail(new TooLarge());
|
|
1159
|
+
req.on("data", (b) => {
|
|
1160
|
+
if (size > limit)
|
|
1161
|
+
return;
|
|
1162
|
+
size += b.length;
|
|
1163
|
+
if (size > limit) {
|
|
1164
|
+
parts.length = 0;
|
|
1165
|
+
fail(new TooLarge());
|
|
1166
|
+
}
|
|
1167
|
+
else
|
|
1168
|
+
parts.push(b);
|
|
1169
|
+
});
|
|
1170
|
+
req.on("end", () => ok(Buffer.concat(parts).toString("utf8")));
|
|
1171
|
+
req.on("error", fail);
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/** Responses refused as too large and not answered yet (tooLarge): what the handler that asked for
|
|
1175
|
+
* the body answers meanwhile is not sent. */
|
|
1176
|
+
const refused = new WeakSet();
|
|
1177
|
+
/** Answer 413 to a body too large and close the connection. The rest of the body is read and thrown
|
|
1178
|
+
* away first, and the answer goes once it has all come: a connection closed while the client is
|
|
1179
|
+
* still sending is reset on some systems (macOS), and the client never reads the answer. A client
|
|
1180
|
+
* still sending after a short grace is answered all the same, and cut off. */
|
|
1181
|
+
function tooLarge(req, res, body) {
|
|
1182
|
+
refused.add(res);
|
|
1183
|
+
let done = false;
|
|
1184
|
+
const answer = () => {
|
|
1185
|
+
if (done)
|
|
1186
|
+
return;
|
|
1187
|
+
done = true;
|
|
1188
|
+
refused.delete(res);
|
|
1189
|
+
sendJson(res, body, 413, { connection: "close" });
|
|
1190
|
+
};
|
|
1191
|
+
req.on("data", () => { });
|
|
1192
|
+
req.on("end", answer);
|
|
1193
|
+
req.resume();
|
|
1194
|
+
if (req.readableEnded)
|
|
1195
|
+
answer();
|
|
1196
|
+
setTimeout(() => {
|
|
1197
|
+
answer();
|
|
1198
|
+
setTimeout(() => req.socket.destroy(), 500).unref();
|
|
1199
|
+
}, TOO_LARGE_GRACE_MS).unref();
|
|
1200
|
+
}
|
|
1201
|
+
/** Send a JSON answer, once: a handler that answers after something else already did (a body too
|
|
1202
|
+
* large, answered as it was read) is not heard. */
|
|
1203
|
+
function sendJson(res, body, code = 200, headers = {}) {
|
|
1204
|
+
if (res.headersSent || res.writableEnded || refused.has(res))
|
|
1205
|
+
return;
|
|
1206
|
+
const text = JSON.stringify(body);
|
|
1207
|
+
// a small answer is not worth compressing; a status with its build log, a source file, are
|
|
1208
|
+
if (text.length > JSON_GZIP_FROM && acceptsGzip(res.req)) {
|
|
1209
|
+
res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store", "content-encoding": "gzip", vary: "accept-encoding", ...headers });
|
|
1210
|
+
return void res.end(gzipSync(text));
|
|
1211
|
+
}
|
|
1212
|
+
res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store", ...headers });
|
|
1213
|
+
res.end(text);
|
|
1214
|
+
}
|
|
1215
|
+
/** Send a text that is not JSON (the agents' documentation, the MCP server's file), compressed
|
|
1216
|
+
* where the client takes it. */
|
|
1217
|
+
function sendText(req, res, text, type, code = 200) {
|
|
1218
|
+
if (res.headersSent || res.writableEnded || refused.has(res))
|
|
1219
|
+
return;
|
|
1220
|
+
const head = { "content-type": type, "cache-control": "no-store", vary: "accept-encoding" };
|
|
1221
|
+
if (text.length > JSON_GZIP_FROM && acceptsGzip(req)) {
|
|
1222
|
+
res.writeHead(code, { ...head, "content-encoding": "gzip" });
|
|
1223
|
+
return void res.end(gzipSync(text));
|
|
1224
|
+
}
|
|
1225
|
+
res.writeHead(code, head);
|
|
1226
|
+
res.end(text);
|
|
1227
|
+
}
|
|
1228
|
+
/** Send a file to download (a typeset document): as it is, never cached, named for the disk. */
|
|
1229
|
+
function sendDownload(res, f) {
|
|
1230
|
+
if (res.headersSent || res.writableEnded || refused.has(res))
|
|
1231
|
+
return;
|
|
1232
|
+
res.writeHead(200, { "content-type": f.type, "content-length": String(f.body.length), "content-disposition": `attachment; filename="${f.name.replace(/[^A-Za-z0-9._-]/g, "_")}"`, "cache-control": "no-store" });
|
|
1233
|
+
res.end(f.body);
|
|
1234
|
+
}
|
|
1235
|
+
/** The Host a request was sent to, when it looks like one. */
|
|
1236
|
+
function hostOf(req) {
|
|
1237
|
+
return /^[A-Za-z0-9.:[\]-]{1,300}$/.test(req.headers.host ?? "") ? req.headers.host : "127.0.0.1";
|
|
1238
|
+
}
|
|
1239
|
+
/** The server's address as the request's browser reached it: its Origin where it names the host
|
|
1240
|
+
* the request was sent to (a page's POST carries it, with the scheme), else http:// and the Host. */
|
|
1241
|
+
export function reachedAt(req) {
|
|
1242
|
+
const host = hostOf(req);
|
|
1243
|
+
try {
|
|
1244
|
+
const o = new URL(String(req.headers.origin ?? ""));
|
|
1245
|
+
if (o.host === host && (o.protocol === "http:" || o.protocol === "https:"))
|
|
1246
|
+
return o.origin;
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
// no Origin, or not an address: the Host
|
|
1250
|
+
}
|
|
1251
|
+
return `http://${host}`;
|
|
1252
|
+
}
|
|
1253
|
+
/** characters from which a JSON answer is sent compressed */
|
|
1254
|
+
const JSON_GZIP_FROM = 2048;
|
|
1255
|
+
/** the graph's parts as sent, each tagged once (a graph's buffers live as long as it does) */
|
|
1256
|
+
const gzTags = new WeakMap();
|
|
1257
|
+
/** A part of the graph, gzipped already: tagged, so that a page that has it gets 304. */
|
|
1258
|
+
function sendGz(req, res, gz, type) {
|
|
1259
|
+
if (refused.has(res))
|
|
1260
|
+
return;
|
|
1261
|
+
let etag = gzTags.get(gz);
|
|
1262
|
+
if (!etag)
|
|
1263
|
+
gzTags.set(gz, (etag = tagOf(gz)));
|
|
1264
|
+
const head = { "content-type": type, "cache-control": "private, no-cache", etag };
|
|
1265
|
+
if (fresh(req, etag)) {
|
|
1266
|
+
res.writeHead(304, head);
|
|
1267
|
+
return void res.end();
|
|
1268
|
+
}
|
|
1269
|
+
res.writeHead(200, { ...head, "content-encoding": "gzip" });
|
|
1270
|
+
res.end(gz);
|
|
1271
|
+
}
|
|
1272
|
+
/** the edges packed for pages (shared/edgecodec.ts), once per graph and format */
|
|
1273
|
+
const packedEdges = new WeakMap();
|
|
1274
|
+
function packedGz(edgesGz, format) {
|
|
1275
|
+
let byFormat = packedEdges.get(edgesGz);
|
|
1276
|
+
if (!byFormat)
|
|
1277
|
+
packedEdges.set(edgesGz, (byFormat = new Map()));
|
|
1278
|
+
let p = byFormat.get(format);
|
|
1279
|
+
if (!p) {
|
|
1280
|
+
const raw = gunzipSync(edgesGz);
|
|
1281
|
+
const edges = new Int32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4);
|
|
1282
|
+
let n = 0;
|
|
1283
|
+
for (let k = 0; k < edges.length; k += 3)
|
|
1284
|
+
n = Math.max(n, edges[k] + 1, edges[k + 1] + 1);
|
|
1285
|
+
p = gzipSync(packEdges(edges, n, format), { level: 9 });
|
|
1286
|
+
byFormat.set(format, p);
|
|
1287
|
+
}
|
|
1288
|
+
return p;
|
|
1289
|
+
}
|
|
1290
|
+
async function main() {
|
|
1291
|
+
const o = parseArgs(process.argv.slice(2));
|
|
1292
|
+
if (o.cmd === "doctor") {
|
|
1293
|
+
const report = doctor({ path: o.path || null, roots: o.roots, site: o.site, cache: o.cache });
|
|
1294
|
+
console.log(printReport(report, o.path || null));
|
|
1295
|
+
process.exit(report.checks.some((c) => c.level === "fail") ? 1 : 0);
|
|
1296
|
+
}
|
|
1297
|
+
// a live server outlives any one failed task: log it and keep serving
|
|
1298
|
+
process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
|
|
1299
|
+
let project;
|
|
1300
|
+
try {
|
|
1301
|
+
project = openProject(o.path, o.roots, o.graph === null);
|
|
1302
|
+
}
|
|
1303
|
+
catch (e) {
|
|
1304
|
+
if (e instanceof ProjectError) {
|
|
1305
|
+
console.error(`visualproof: ${e.message}`);
|
|
1306
|
+
process.exit(1);
|
|
1307
|
+
}
|
|
1308
|
+
throw e;
|
|
1309
|
+
}
|
|
1310
|
+
const dirs = projectDirs(project.root, o.site, o.cache);
|
|
1311
|
+
openSite(dirs.site);
|
|
1312
|
+
mkdirSync(dirs.cache, { recursive: true });
|
|
1313
|
+
// one server at a time writes a site; an export only reads it
|
|
1314
|
+
if (o.cmd !== "export") {
|
|
1315
|
+
try {
|
|
1316
|
+
const release = lockSite(dirs.site);
|
|
1317
|
+
process.on("exit", release);
|
|
1318
|
+
}
|
|
1319
|
+
catch (e) {
|
|
1320
|
+
if (!(e instanceof SiteBusy))
|
|
1321
|
+
throw e;
|
|
1322
|
+
console.error(`visualproof: ${e.message}`);
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
useBundleDir(bundleDirIn(o.cache ? resolve(o.cache) : cacheHome()));
|
|
1327
|
+
if (o.cmd === "purge-file") {
|
|
1328
|
+
// the owner's alone, from the command line (AttachmentSpec decision 3): the bytes and what was
|
|
1329
|
+
// read of them go; the record and the links stay in the review's logs, which are never rewritten
|
|
1330
|
+
const lib = new Library(dirs.site, dirs.cache);
|
|
1331
|
+
console.log(lib.purge(o.sha) ? `purged ${o.sha} from ${lib.dir}` : `${lib.dir} holds no file ${o.sha}`);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
await ensureBundle();
|
|
1335
|
+
if (o.cmd === "export") {
|
|
1336
|
+
try {
|
|
1337
|
+
const log = (l) => console.log(l);
|
|
1338
|
+
// a snapshot carries the usage pass's findings: run here, as far as the cache does not hold them
|
|
1339
|
+
let ex = o.graph ? undefined : await extract(project, dirs, log);
|
|
1340
|
+
if (ex && o.jobs > 0) {
|
|
1341
|
+
const pass = new UsagePass(project, dirs, o.jobs, log);
|
|
1342
|
+
const plan = pass.plan(ex.graph);
|
|
1343
|
+
if (plan.todo.length)
|
|
1344
|
+
log(`usage pass: elaborating ${plan.todo.length} of ${plan.modules.length} modules, ${o.jobs} at a time`);
|
|
1345
|
+
const results = await pass.run(plan);
|
|
1346
|
+
if (results) {
|
|
1347
|
+
ex = mergeUsage(ex, plan, results, pass.status.failed, pass.status.ms ?? 0);
|
|
1348
|
+
saveMerged(dirs.cache, ex);
|
|
1349
|
+
for (const f of pass.status.failed)
|
|
1350
|
+
log(`usage pass: ${f.module}: ${f.error}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const prep = await prepare(project, dirs, o.graph, log, ex ? { extraction: ex } : {});
|
|
1354
|
+
// the boards as they are kept: a snapshot carries them, frozen
|
|
1355
|
+
const views = new Views(dirs.site, LAYOUT_VERSION, () => { });
|
|
1356
|
+
const bytes = writeSnapshot(project, prep, o.out, o.label, new ReviewStore(project.root, dirs.site).data, views.book);
|
|
1357
|
+
console.log(`wrote ${o.out} (${(bytes / 1e6).toFixed(1)} MB) — opens from disk, no server`);
|
|
1358
|
+
}
|
|
1359
|
+
catch (e) {
|
|
1360
|
+
console.error(`visualproof: ${e instanceof Error ? e.message : String(e)}`);
|
|
1361
|
+
process.exit(1);
|
|
1362
|
+
}
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const review = new ReviewStore(project.root, dirs.site);
|
|
1366
|
+
const live = new Live(project, dirs, o.graph, review, o.editor, o.jobs);
|
|
1367
|
+
live.library.limit = o.libraryLimit;
|
|
1368
|
+
const hover = new LeanHover(project, dirs.cache);
|
|
1369
|
+
const ids = new Identities(o.identityHeader);
|
|
1370
|
+
let key = null;
|
|
1371
|
+
try {
|
|
1372
|
+
key = o.accessKeyFile ? loadKey(o.accessKeyFile) : null;
|
|
1373
|
+
}
|
|
1374
|
+
catch (e) {
|
|
1375
|
+
console.error(`visualproof: --access-key-file: ${e instanceof Error ? e.message : String(e)}`);
|
|
1376
|
+
process.exit(2);
|
|
1377
|
+
}
|
|
1378
|
+
process.on("exit", () => {
|
|
1379
|
+
hover.stop();
|
|
1380
|
+
// the usage pass's workers end with the server
|
|
1381
|
+
live.usage.cancel();
|
|
1382
|
+
// when tokens and personal links were last used is written now, not at the next minute
|
|
1383
|
+
live.tokens.close();
|
|
1384
|
+
live.invites.close();
|
|
1385
|
+
live.readOnly.close();
|
|
1386
|
+
});
|
|
1387
|
+
for (const sig of ["SIGINT", "SIGTERM"])
|
|
1388
|
+
process.on(sig, () => process.exit(0));
|
|
1389
|
+
const app = new App(live, hover, ids, new AgentApi(live, hover, o.agents), key ? new Access(key.key) : null, new Exporter(dirs.cache), o.publicUrl, o.publicRead);
|
|
1390
|
+
const server = createServer((req, res) => app.handle(req, res));
|
|
1391
|
+
server.listen(o.port, o.host, () => {
|
|
1392
|
+
console.log(`VisualProof — ${project.name} (${targetsInWords(project)})`);
|
|
1393
|
+
console.log(` http://${o.host}:${o.port}/`);
|
|
1394
|
+
console.log(` site: ${dirs.site}${review.error ? ` — the review: ${review.error}` : ""}`);
|
|
1395
|
+
console.log(` cache: ${dirs.cache}`);
|
|
1396
|
+
console.log(` names: ${ids.header ? `from the ${ids.header} header` : "as each page gives them"}`);
|
|
1397
|
+
console.log(` editor: ${o.editor ?? "no link unless a reader sets one"}`);
|
|
1398
|
+
console.log(` agents: ${o.agents} — their API is http://${o.host}:${o.port}/api/agent`);
|
|
1399
|
+
console.log(` usage pass: ${o.graph ? "none for a saved graph" : o.jobs > 0 ? `${o.jobs} module(s) at a time` : "off (--jobs 0)"}`);
|
|
1400
|
+
console.log(` access: ${key ? `by its link only, /k/<the key in ${o.accessKeyFile}>${key.made ? " (a new key was made there)" : ""}${o.publicRead ? "; anyone else read-only (--public)" : ""}` : "anyone who reaches the port"}`);
|
|
1401
|
+
});
|
|
1402
|
+
live.start();
|
|
1403
|
+
}
|
|
1404
|
+
// run as the command (`node src/server/main.ts`, the `visualproof` bin), not when a test imports App
|
|
1405
|
+
if (import.meta.main)
|
|
1406
|
+
void main();
|