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.
Files changed (146) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  9. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  10. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  11. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  22. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  23. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  24. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  25. package/dist/extractor/Extract.lean +1180 -0
  26. package/dist/extractor/Usage.lean +900 -0
  27. package/dist/mcp/visualproof-mcp.mjs +476 -0
  28. package/dist/server/access.js +95 -0
  29. package/dist/server/agent.js +3270 -0
  30. package/dist/server/agentdocs.js +35 -0
  31. package/dist/server/agentlibrary.js +202 -0
  32. package/dist/server/agentnotes.js +701 -0
  33. package/dist/server/agentviews.js +703 -0
  34. package/dist/server/askfollow.js +286 -0
  35. package/dist/server/boardspots.js +159 -0
  36. package/dist/server/dirs.js +175 -0
  37. package/dist/server/doctor.js +186 -0
  38. package/dist/server/events.js +198 -0
  39. package/dist/server/export.js +420 -0
  40. package/dist/server/extract.js +204 -0
  41. package/dist/server/fetchurl.js +321 -0
  42. package/dist/server/figures.js +158 -0
  43. package/dist/server/history.js +780 -0
  44. package/dist/server/historyapi.js +304 -0
  45. package/dist/server/hover.js +595 -0
  46. package/dist/server/identity.js +114 -0
  47. package/dist/server/invites.js +241 -0
  48. package/dist/server/layoutthread.js +51 -0
  49. package/dist/server/library.js +600 -0
  50. package/dist/server/libraryroute.js +211 -0
  51. package/dist/server/live.js +1101 -0
  52. package/dist/server/lostfound.js +199 -0
  53. package/dist/server/main.js +1406 -0
  54. package/dist/server/mcptools.js +496 -0
  55. package/dist/server/pastdoc.js +73 -0
  56. package/dist/server/pipeline.js +159 -0
  57. package/dist/server/poppler.js +251 -0
  58. package/dist/server/presence.js +252 -0
  59. package/dist/server/project.js +222 -0
  60. package/dist/server/prompt.js +53 -0
  61. package/dist/server/readonly.js +152 -0
  62. package/dist/server/readonlygate.js +123 -0
  63. package/dist/server/renames.js +268 -0
  64. package/dist/server/review.js +334 -0
  65. package/dist/server/reviewapi.js +188 -0
  66. package/dist/server/snapshot.js +50 -0
  67. package/dist/server/sourcestore.js +473 -0
  68. package/dist/server/status.js +360 -0
  69. package/dist/server/talk.js +476 -0
  70. package/dist/server/tokens.js +221 -0
  71. package/dist/server/usage.js +501 -0
  72. package/dist/server/versions.js +82 -0
  73. package/dist/server/view.js +164 -0
  74. package/dist/server/views.js +1167 -0
  75. package/dist/shared/askfollow.js +46 -0
  76. package/dist/shared/board.js +332 -0
  77. package/dist/shared/build.js +2 -0
  78. package/dist/shared/collab.js +241 -0
  79. package/dist/shared/comment.js +285 -0
  80. package/dist/shared/context.js +157 -0
  81. package/dist/shared/doc/authors.js +98 -0
  82. package/dist/shared/doc/build.js +358 -0
  83. package/dist/shared/doc/latex.js +455 -0
  84. package/dist/shared/doc/markdown.js +159 -0
  85. package/dist/shared/doc/model.js +37 -0
  86. package/dist/shared/doc/order.js +124 -0
  87. package/dist/shared/doc/outline.js +45 -0
  88. package/dist/shared/doc/proof.js +272 -0
  89. package/dist/shared/doc/sections.js +93 -0
  90. package/dist/shared/doc/settings.js +75 -0
  91. package/dist/shared/edgecodec.js +102 -0
  92. package/dist/shared/editor.js +64 -0
  93. package/dist/shared/excerpt.js +87 -0
  94. package/dist/shared/graph.js +16 -0
  95. package/dist/shared/items.js +92 -0
  96. package/dist/shared/keys.js +203 -0
  97. package/dist/shared/layout.js +926 -0
  98. package/dist/shared/lexer.js +130 -0
  99. package/dist/shared/library.js +711 -0
  100. package/dist/shared/lostfound.js +150 -0
  101. package/dist/shared/moves.js +244 -0
  102. package/dist/shared/notecard.js +295 -0
  103. package/dist/shared/noteplace.js +505 -0
  104. package/dist/shared/palette.js +46 -0
  105. package/dist/shared/pastgraph.js +103 -0
  106. package/dist/shared/places.js +80 -0
  107. package/dist/shared/projectnotes.js +244 -0
  108. package/dist/shared/query.js +818 -0
  109. package/dist/shared/review.js +642 -0
  110. package/dist/shared/reviewqueue.js +333 -0
  111. package/dist/shared/ring.js +366 -0
  112. package/dist/shared/scope.js +255 -0
  113. package/dist/shared/scopeedit.js +81 -0
  114. package/dist/shared/search.js +191 -0
  115. package/dist/shared/separate.js +178 -0
  116. package/dist/shared/sharesnap.js +281 -0
  117. package/dist/shared/sticky.js +501 -0
  118. package/dist/shared/timeline.js +73 -0
  119. package/dist/shared/titles.js +300 -0
  120. package/dist/shared/viewgraph.js +157 -0
  121. package/dist/shared/views.js +859 -0
  122. package/dist/web/bundle/app.js +52671 -0
  123. package/dist/web/bundle/edges.js +407 -0
  124. package/dist/web/bundle/layout.js +1010 -0
  125. package/dist/web/bundle/pdfworker.js +48322 -0
  126. package/dist/web/index.html +257 -0
  127. package/dist/web/src/motion.js +430 -0
  128. package/dist/web/src/past.js +224 -0
  129. package/dist/web/src/scene.js +804 -0
  130. package/dist/web/style.css +2548 -0
  131. package/docs/agent-api/README.md +91 -0
  132. package/docs/agent-api/advice.md +108 -0
  133. package/docs/agent-api/export.md +120 -0
  134. package/docs/agent-api/following.md +140 -0
  135. package/docs/agent-api/history.md +142 -0
  136. package/docs/agent-api/library.md +65 -0
  137. package/docs/agent-api/mcp.md +104 -0
  138. package/docs/agent-api/notes.md +93 -0
  139. package/docs/agent-api/reading.md +166 -0
  140. package/docs/agent-api/requests.md +94 -0
  141. package/docs/agent-api/talking.md +100 -0
  142. package/docs/agent-api/tcb.md +135 -0
  143. package/docs/agent-api/views.md +125 -0
  144. package/docs/agent-api/words.md +56 -0
  145. package/package.json +39 -4
  146. 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();