visualproof 0.0.0-stage → 0.1.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/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 +3235 -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 +741 -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 +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -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 +49 -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 +157 -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 +148 -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 +107 -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 +243 -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 +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -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 +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -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 +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// What the server knows about the codebase it shows, found by reading files only.
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
export class ProjectError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** `defaultTargets`, the package's name and its `[[lean_lib]]` tables from a lakefile.toml, without
|
|
7
|
+
* a TOML parser: the keys read are single-line strings or arrays of strings in every lakefile Lake
|
|
8
|
+
* writes, an array over several lines included. */
|
|
9
|
+
export function readLakefileToml(text) {
|
|
10
|
+
const out = { libs: [] };
|
|
11
|
+
const strings = (v) => [...v.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
12
|
+
// only the part before the first table header is the package itself
|
|
13
|
+
const head = text.split(/^\s*\[/m)[0];
|
|
14
|
+
const name = head.match(/^\s*name\s*=\s*"([^"]+)"/m);
|
|
15
|
+
if (name)
|
|
16
|
+
out.name = name[1];
|
|
17
|
+
const dt = head.match(/^\s*defaultTargets\s*=\s*\[([^\]]*)\]/m);
|
|
18
|
+
if (dt)
|
|
19
|
+
out.defaultTargets = strings(dt[1]);
|
|
20
|
+
for (const table of text.split(/^(?=\s*\[)/m)) {
|
|
21
|
+
if (!/^\s*\[\[\s*lean_lib\s*\]\]/.test(table))
|
|
22
|
+
continue;
|
|
23
|
+
const lib = table.match(/^\s*name\s*=\s*"([^"]+)"/m);
|
|
24
|
+
if (!lib)
|
|
25
|
+
continue;
|
|
26
|
+
const roots = table.match(/^\s*roots\s*=\s*\[([^\]]*)\]/m);
|
|
27
|
+
const globs = table.match(/^\s*globs\s*=\s*\[([^\]]*)\]/m);
|
|
28
|
+
out.libs.push({ name: lib[1], ...(roots ? { roots: strings(roots[1]) } : {}), ...(globs ? { globs: strings(globs[1]) } : {}) });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** The modules a library builds, as Lake decides them: its globs, by default one of each of its
|
|
33
|
+
* roots, by default its name; a glob of submodules matched against `known`, the modules there are.
|
|
34
|
+
* A module named alone is kept even unknown, so that what is missing is said. */
|
|
35
|
+
export function libModules(lib, known) {
|
|
36
|
+
const all = [...known].sort();
|
|
37
|
+
const out = new Set();
|
|
38
|
+
for (const g of lib.globs ?? lib.roots ?? [lib.name]) {
|
|
39
|
+
const glob = /^(.*)\.([+*])$/.exec(g);
|
|
40
|
+
if (!glob) {
|
|
41
|
+
out.add(g);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const [, base, which] = glob;
|
|
45
|
+
if (which === "*" && all.includes(base))
|
|
46
|
+
out.add(base);
|
|
47
|
+
for (const m of all)
|
|
48
|
+
if (m.startsWith(`${base}.`))
|
|
49
|
+
out.add(m);
|
|
50
|
+
}
|
|
51
|
+
return [...out];
|
|
52
|
+
}
|
|
53
|
+
/** The same from a lakefile.lean, read as text, never elaborated (Lake would write into the project
|
|
54
|
+
* and run its code): the package's name, its libraries with the `roots := #[…]` and
|
|
55
|
+
* `globs := #[…]` they give, and those marked `@[default_target]`. What a lakefile computes is not
|
|
56
|
+
* seen; `--root` says it then. */
|
|
57
|
+
export function readLakefileLean(text) {
|
|
58
|
+
const out = { libs: [] };
|
|
59
|
+
const code = stripLeanComments(text);
|
|
60
|
+
// a name: its parts, each plain or in «», joined by dots
|
|
61
|
+
const part = String.raw `(?:«[^»]+»|[A-Za-z_][\w'!?]*)`;
|
|
62
|
+
const ident = String.raw `(${part}(?:\.${part})*)`;
|
|
63
|
+
const unquote = (n) => n.replace(/[«»]/g, "");
|
|
64
|
+
const name = code.match(new RegExp(String.raw `^package\s+${ident}`, "m"));
|
|
65
|
+
if (name)
|
|
66
|
+
out.name = unquote(name[1]);
|
|
67
|
+
const targets = [];
|
|
68
|
+
for (const m of code.matchAll(new RegExp(String.raw `(@\[[^\]]*\]\s*)?lean_lib\s+${ident}`, "g"))) {
|
|
69
|
+
// the library's declaration runs to the next line that starts at the margin
|
|
70
|
+
const rest = code.slice(m.index + m[0].length);
|
|
71
|
+
const block = rest.slice(0, rest.search(/\n(?=\S)/) + 1 || rest.length);
|
|
72
|
+
const lib = { name: unquote(m[2]) };
|
|
73
|
+
const roots = block.match(/\broots\s*:=\s*#\[([^\]]*)\]/);
|
|
74
|
+
if (roots)
|
|
75
|
+
lib.roots = [...roots[1].matchAll(new RegExp("`" + ident, "g"))].map((r) => unquote(r[1]));
|
|
76
|
+
const globs = block.match(/\bglobs\s*:=\s*#\[([^\]]*)\]/);
|
|
77
|
+
if (globs) {
|
|
78
|
+
const kinds = { one: "", submodules: ".+", andSubmodules: ".*" };
|
|
79
|
+
lib.globs = [...globs[1].matchAll(new RegExp(String.raw `(?:Glob)?\.(one|submodules|andSubmodules)\s+\x60` + ident, "g"))].map((g) => unquote(g[2]) + kinds[g[1]]);
|
|
80
|
+
}
|
|
81
|
+
out.libs.push(lib);
|
|
82
|
+
if (m[1] && /\bdefault_target\b/.test(m[1]))
|
|
83
|
+
targets.push(lib.name);
|
|
84
|
+
}
|
|
85
|
+
if (targets.length)
|
|
86
|
+
out.defaultTargets = targets;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** Lean source without its comments: `--` to the end of the line, `/- … -/` nested. Strings are
|
|
90
|
+
* not looked into: a lakefile's strings hold no comment marks that matter here. */
|
|
91
|
+
function stripLeanComments(text) {
|
|
92
|
+
let out = "";
|
|
93
|
+
let depth = 0;
|
|
94
|
+
for (let i = 0; i < text.length; i++) {
|
|
95
|
+
if (text.startsWith("/-", i)) {
|
|
96
|
+
depth++;
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
else if (depth > 0 && text.startsWith("-/", i)) {
|
|
100
|
+
depth--;
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
else if (depth > 0) {
|
|
104
|
+
if (text[i] === "\n")
|
|
105
|
+
out += "\n";
|
|
106
|
+
}
|
|
107
|
+
else if (text.startsWith("--", i)) {
|
|
108
|
+
const nl = text.indexOf("\n", i);
|
|
109
|
+
if (nl < 0)
|
|
110
|
+
break;
|
|
111
|
+
i = nl - 1;
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
out += text[i];
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Every .lean file outside .lake, indexed by each module name it could be: a file
|
|
119
|
+
* `ThirdParty/Vendored/X.lean` can be `ThirdParty.Vendored.X` or `Vendored.X` depending on the
|
|
120
|
+
* library's srcDir. The shortest path wins a tie, which is the usual layout. */
|
|
121
|
+
export function indexSources(root) {
|
|
122
|
+
const files = new Map();
|
|
123
|
+
const walk = (dir) => {
|
|
124
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
125
|
+
if (ent.name.startsWith(".") || ent.name === "node_modules")
|
|
126
|
+
continue;
|
|
127
|
+
const p = join(dir, ent.name);
|
|
128
|
+
if (ent.isDirectory())
|
|
129
|
+
walk(p);
|
|
130
|
+
else if (ent.isFile() && ent.name.endsWith(".lean") && !ent.name.startsWith("lakefile")) {
|
|
131
|
+
const rel = relative(root, p);
|
|
132
|
+
const parts = rel.slice(0, -".lean".length).split(sep);
|
|
133
|
+
for (let i = 0; i < parts.length; i++) {
|
|
134
|
+
const mod = parts.slice(i).join(".");
|
|
135
|
+
const prev = files.get(mod);
|
|
136
|
+
if (prev === undefined || prev.length > rel.length)
|
|
137
|
+
files.set(mod, rel);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(root);
|
|
143
|
+
return files;
|
|
144
|
+
}
|
|
145
|
+
/** The project's modules a library's globs are matched against: those Lake built (an `.olean` in
|
|
146
|
+
* `libDir`) that have a source, and, for a project not built yet, those whose source sits where
|
|
147
|
+
* the module's name says, under the project's folder. */
|
|
148
|
+
function knownModules(libDir, files) {
|
|
149
|
+
const known = new Set();
|
|
150
|
+
for (const [mod, rel] of files)
|
|
151
|
+
if (rel === mod.split(".").join(sep) + ".lean")
|
|
152
|
+
known.add(mod);
|
|
153
|
+
const walk = (dir, prefix) => {
|
|
154
|
+
if (!existsSync(dir))
|
|
155
|
+
return;
|
|
156
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
157
|
+
if (ent.isDirectory())
|
|
158
|
+
walk(join(dir, ent.name), [...prefix, ent.name]);
|
|
159
|
+
else if (ent.name.endsWith(".olean")) {
|
|
160
|
+
const mod = [...prefix, ent.name.slice(0, -".olean".length)].join(".");
|
|
161
|
+
if (files.has(mod))
|
|
162
|
+
known.add(mod);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
walk(libDir, []);
|
|
167
|
+
return known;
|
|
168
|
+
}
|
|
169
|
+
/** What a project's modules to read are, in words: its targets, and how many modules they stand
|
|
170
|
+
* for where that is more. */
|
|
171
|
+
export function targetsInWords(p) {
|
|
172
|
+
const t = p.targets ?? p.roots;
|
|
173
|
+
return t.join(", ") + (p.roots.length > t.length ? `: ${p.roots.length} modules` : "");
|
|
174
|
+
}
|
|
175
|
+
export function openProject(path, rootsOverride, requireBuild = true) {
|
|
176
|
+
const root = resolve(path);
|
|
177
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
178
|
+
throw new ProjectError(`${root} is not a directory`);
|
|
179
|
+
}
|
|
180
|
+
const tcFile = join(root, "lean-toolchain");
|
|
181
|
+
if (!existsSync(tcFile))
|
|
182
|
+
throw new ProjectError(`no lean-toolchain in ${root}: not a Lean project`);
|
|
183
|
+
const toolchain = readFileSync(tcFile, "utf8").trim();
|
|
184
|
+
let name = basename(root);
|
|
185
|
+
let roots = rootsOverride;
|
|
186
|
+
let targets = rootsOverride;
|
|
187
|
+
const toml = join(root, "lakefile.toml");
|
|
188
|
+
const lean = join(root, "lakefile.lean");
|
|
189
|
+
const lf = existsSync(toml) ? readLakefileToml(readFileSync(toml, "utf8")) : existsSync(lean) ? readLakefileLean(readFileSync(lean, "utf8")) : { libs: [] };
|
|
190
|
+
if (lf.name)
|
|
191
|
+
name = lf.name;
|
|
192
|
+
const libDir = join(root, ".lake", "build", "lib", "lean");
|
|
193
|
+
const files = indexSources(root);
|
|
194
|
+
// a target that is a library stands for the modules it builds; any other, for a module: the
|
|
195
|
+
// default targets, or those `--root` names (a library or a module)
|
|
196
|
+
if (roots.length === 0 && lf.defaultTargets)
|
|
197
|
+
roots = targets = lf.defaultTargets;
|
|
198
|
+
if (targets.some((t) => lf.libs.some((l) => l.name === t))) {
|
|
199
|
+
const known = knownModules(libDir, files);
|
|
200
|
+
const out = new Set();
|
|
201
|
+
for (const t of targets) {
|
|
202
|
+
const lib = lf.libs.find((l) => l.name === t);
|
|
203
|
+
const mods = lib ? libModules(lib, known) : [t];
|
|
204
|
+
// a library with nothing built nor written is named, so that it is said to be unbuilt
|
|
205
|
+
for (const m of mods.length ? mods : [t])
|
|
206
|
+
out.add(m);
|
|
207
|
+
}
|
|
208
|
+
roots = [...out];
|
|
209
|
+
}
|
|
210
|
+
if (roots.length === 0) {
|
|
211
|
+
throw new ProjectError("cannot tell which modules to read: pass --root <Module> (the lakefile names no default " +
|
|
212
|
+
"library: no defaultTargets in lakefile.toml, no @[default_target] lean_lib in lakefile.lean)");
|
|
213
|
+
}
|
|
214
|
+
for (const r of requireBuild ? roots : []) {
|
|
215
|
+
const olean = join(libDir, ...r.split(".")) + ".olean";
|
|
216
|
+
if (!existsSync(olean)) {
|
|
217
|
+
throw new ProjectError(`${r} has not been built (${relative(root, olean)} is missing). ` +
|
|
218
|
+
"VisualProof reads what `lake build` produced and never builds itself.");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { root, name, toolchain, roots, targets, libDir, files };
|
|
222
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The prompt Share makes for an AI agent (AgentSpec decision 7): plain English that any agent able
|
|
2
|
+
// to make HTTP requests can act on alone, pasted into it by the person who shared. It says where the
|
|
3
|
+
// server is (as that person's browser reached it), gives the agent's token, who the agent is here,
|
|
4
|
+
// its level and when the token expires, and the few rules that matter before anything is read. What
|
|
5
|
+
// the person wants done they tell the agent themselves, and where they are the companion says (GET
|
|
6
|
+
// /api/agent/companion). Everything else is in GET /api/agent, a short entry the prompt sends the
|
|
7
|
+
// agent to first, and the documentation's topics it names (the MCP server's docs tool): the prompt
|
|
8
|
+
// stays short, and what the API says cannot drift from the prompt.
|
|
9
|
+
//
|
|
10
|
+
// The prompt is made from the token and its record alone, so Share can show it again, the same,
|
|
11
|
+
// while the token is in force.
|
|
12
|
+
//
|
|
13
|
+
// Since decision 8 it also makes the agent one people can talk to, at once: a Claude Code agent
|
|
14
|
+
// installs VisualProof's MCP server, has its user restart the session (MCP servers load when one
|
|
15
|
+
// starts), and then listens by itself — wait_for_message, which returns the moment someone writes;
|
|
16
|
+
// any other agent listens on its inbox over HTTP. Either way its first wait may bring the
|
|
17
|
+
// conversation so far (the owner's decision of 2026-10-11), which it reads and does not answer.
|
|
18
|
+
import { claudeCodeSteps, utc } from "./mcptools.js";
|
|
19
|
+
const MAY = {
|
|
20
|
+
read: "you may read everything and show what you look at, but not give advice or change anything",
|
|
21
|
+
advise: "you may read, give advice on declarations and leave notes that point people at something",
|
|
22
|
+
full: "you may read, give advice, leave notes, and also do whatever a person can do in the page — mark targets and key declarations, trust prefixes, make and change views and pins — each recorded as yours, an agent's",
|
|
23
|
+
};
|
|
24
|
+
/** The prompt, as text. */
|
|
25
|
+
export function agentPrompt(p) {
|
|
26
|
+
const r = p.record;
|
|
27
|
+
const lines = [
|
|
28
|
+
`${r.by} invites you to work with them in VisualProof, a web tool for reviewing a Lean project: it shows the project's declarations, the trusted computing base (TCB) of its theorems, and the review people make of it.`,
|
|
29
|
+
"",
|
|
30
|
+
`Server: ${p.url}`,
|
|
31
|
+
`Your token: ${p.token}`,
|
|
32
|
+
` It is a secret. Send it with every request as the header "Authorization: Bearer <token>". Never print it or show it to anyone, and store it nowhere except where a tool's configuration needs it.`,
|
|
33
|
+
"",
|
|
34
|
+
`You are "${r.name}", an AI agent. Your level is ${r.level}: ${MAY[r.level]}. The token expires ${utc(r.expires)}.`,
|
|
35
|
+
"",
|
|
36
|
+
`Start with GET ${p.url}/api/agent — a short entry: who you are, how to begin, the rules, and the documentation's topics (GET /api/agent/docs/<topic>; with the MCP server below, its docs tool).`,
|
|
37
|
+
"",
|
|
38
|
+
`People on the page can talk to you, and you listen for them whenever you have nothing else to do. If you are Claude Code, set that up now (GET /api/agent/mcp/install says the same, and how other MCP clients do it):`,
|
|
39
|
+
claudeCodeSteps(p.url, p.token),
|
|
40
|
+
`Not Claude Code, nor MCP: listen with GET ${p.url}/api/agent/inbox?wait=<seconds>, which answers the moment someone writes; answer with POST /api/agent/reply, then ask again.`,
|
|
41
|
+
"Either way, the first wait after you join may bring earlier messages; read them as context and do not answer them again.",
|
|
42
|
+
"",
|
|
43
|
+
"Ground rules:",
|
|
44
|
+
r.level === "read"
|
|
45
|
+
? `- You only read here: tell ${r.by} what you find.`
|
|
46
|
+
: "- What you say is advice: shown apart, never counted. Give your reasons; a comment needs text saying what is wrong, unclear or worth checking, and where.",
|
|
47
|
+
r.level === "read"
|
|
48
|
+
? "- Show what you look at with POST /api/agent/view. You never move anyone else's view."
|
|
49
|
+
: "- Show what you look at with POST /api/agent/view, and point people at things with notes (POST /api/agent/note). You never move anyone else's view.",
|
|
50
|
+
`- When you are done, say so: POST /api/agent/view {"leave": true}.`,
|
|
51
|
+
];
|
|
52
|
+
return lines.join("\n") + "\n";
|
|
53
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Read-only links (CollabSpec decision 11, the owner's decision of 2026-10-13): whoever is let in
|
|
2
|
+
// may make one from Share, for anyone they want to show the project to. It lets its holder see
|
|
3
|
+
// what a guest sees and change nothing that is shared; nobody sees them (not listed, not counted).
|
|
4
|
+
// A read-only reader's own Share gives the link they came by, so that revoking one link cuts off
|
|
5
|
+
// everyone it reached.
|
|
6
|
+
//
|
|
7
|
+
// A link is `/r/vpr_<id>_<secret>`, made as a personal link is (server/invites.ts): the id names it
|
|
8
|
+
// in Share's list, the secret is 32 random bytes. Opened, it leaves a cookie and goes on to the
|
|
9
|
+
// page; the cookie's value is the id and a digest of the token, never the token (cookies are not
|
|
10
|
+
// kept per port, and other services answer on this host's name). Unlike a personal link's, the
|
|
11
|
+
// token is kept: a read-only link is made to be passed on, so whoever holds it, and whoever is let
|
|
12
|
+
// in, may see it again, which gives none of them more than they have. It is kept in
|
|
13
|
+
// `<site>/readonly-links.json`, readable by the server's user alone. A link lasts until it is
|
|
14
|
+
// revoked, which cuts its pages off at once; revoked links stay, saying who revoked them.
|
|
15
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { cookieOf } from "./invites.js";
|
|
19
|
+
export const READONLY_PREFIX = "vpr_";
|
|
20
|
+
/** where a read-only link points: `/r/<token>` */
|
|
21
|
+
export const READONLY_PATH = "/r/";
|
|
22
|
+
const FILE = "readonly-links.json";
|
|
23
|
+
const COOKIE = "visualproof_readonly";
|
|
24
|
+
const MAX_AGE = 400 * 24 * 3600;
|
|
25
|
+
const FLUSH_MS = 60_000;
|
|
26
|
+
const ID = /^[0-9a-f]{10}$/;
|
|
27
|
+
const SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
28
|
+
const sha = (s) => createHash("sha256").update(s).digest();
|
|
29
|
+
const cookieValue = (r) => `${r.id}.${sha(`visualproof read-only cookie\0${r.token}`).toString("base64url")}`;
|
|
30
|
+
const same = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
31
|
+
const info = (r) => ({ id: r.id, path: `${READONLY_PATH}${r.token}`, by: r.by, created: r.created, revoked: r.revoked, revokedBy: r.revokedBy, lastUsed: r.lastUsed });
|
|
32
|
+
export class ReadOnlyLinks {
|
|
33
|
+
file;
|
|
34
|
+
records = [];
|
|
35
|
+
cookieName;
|
|
36
|
+
dirty = false;
|
|
37
|
+
timer = null;
|
|
38
|
+
/** told when a link is revoked: its pages' event streams end (server/live.ts) */
|
|
39
|
+
onEnded = () => { };
|
|
40
|
+
/** `siteDir` null keeps the links in memory only (tests). */
|
|
41
|
+
constructor(siteDir) {
|
|
42
|
+
this.file = siteDir ? join(siteDir, FILE) : null;
|
|
43
|
+
let own = null;
|
|
44
|
+
if (this.file && existsSync(this.file)) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(readFileSync(this.file, "utf8"));
|
|
47
|
+
if (typeof data.cookie === "string" && /^[0-9a-f]{12}$/.test(data.cookie))
|
|
48
|
+
own = data.cookie;
|
|
49
|
+
if (Array.isArray(data.links))
|
|
50
|
+
this.records = data.links.filter(isRecord);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// a file that cannot be read lets nobody in by a read-only link; it is kept aside
|
|
54
|
+
renameSync(this.file, `${this.file}.broken-${Date.now()}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.cookieName = `${COOKIE}_${own ?? randomBytes(6).toString("hex")}`;
|
|
58
|
+
if (this.file) {
|
|
59
|
+
this.timer = setInterval(() => this.flush(), FLUSH_MS);
|
|
60
|
+
this.timer.unref?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Make a link, by `by` (null: someone who gave no name). */
|
|
64
|
+
create(by, now = Date.now()) {
|
|
65
|
+
let id;
|
|
66
|
+
do
|
|
67
|
+
id = randomBytes(5).toString("hex");
|
|
68
|
+
while (this.records.some((r) => r.id === id));
|
|
69
|
+
const r = { id, token: `${READONLY_PREFIX}${id}_${randomBytes(32).toString("base64url")}`, by, created: new Date(now).toISOString(), revoked: null, revokedBy: null, lastUsed: null };
|
|
70
|
+
this.records.push(r);
|
|
71
|
+
this.save();
|
|
72
|
+
return info(r);
|
|
73
|
+
}
|
|
74
|
+
/** What a link's token is, compared in constant time whether or not its id is known. */
|
|
75
|
+
check(token, now = Date.now()) {
|
|
76
|
+
if (!token.startsWith(READONLY_PREFIX))
|
|
77
|
+
return null;
|
|
78
|
+
const id = token.slice(READONLY_PREFIX.length, READONLY_PREFIX.length + 10);
|
|
79
|
+
const r = ID.test(id) && token[READONLY_PREFIX.length + 10] === "_" && SECRET.test(token.slice(READONLY_PREFIX.length + 11)) ? this.records.find((x) => x.id === id) : undefined;
|
|
80
|
+
const ok = same(token, r?.token ?? "no such link");
|
|
81
|
+
return r && ok ? this.seen(r, now) : null;
|
|
82
|
+
}
|
|
83
|
+
/** What a request's cookie says: a link in force, one revoked (to be forgotten), or none. */
|
|
84
|
+
ofCookie(header, now = Date.now()) {
|
|
85
|
+
const given = cookieOf(header, this.cookieName);
|
|
86
|
+
if (given === null)
|
|
87
|
+
return null;
|
|
88
|
+
const r = given.indexOf(".") === 10 ? this.records.find((x) => x.id === given.slice(0, 10)) : undefined;
|
|
89
|
+
const ok = same(given, r ? cookieValue(r) : "no such link");
|
|
90
|
+
return r && ok ? this.seen(r, now) : null;
|
|
91
|
+
}
|
|
92
|
+
seen(r, now) {
|
|
93
|
+
if (r.revoked)
|
|
94
|
+
return { ended: "revoked", link: info(r) };
|
|
95
|
+
r.lastUsed = new Date(now).toISOString();
|
|
96
|
+
this.dirty = true;
|
|
97
|
+
return { ok: info(r) };
|
|
98
|
+
}
|
|
99
|
+
/** The cookie that lets a browser in by link `id`, read-only (a `Set-Cookie` value). */
|
|
100
|
+
cookie(id) {
|
|
101
|
+
const r = this.records.find((x) => x.id === id);
|
|
102
|
+
if (!r)
|
|
103
|
+
throw new Error(`no read-only link ${id}`);
|
|
104
|
+
return `${this.cookieName}=${cookieValue(r)}; Path=/; Max-Age=${MAX_AGE}; HttpOnly; SameSite=Lax`;
|
|
105
|
+
}
|
|
106
|
+
/** What makes a browser forget a revoked link's cookie. */
|
|
107
|
+
forget() {
|
|
108
|
+
return `${this.cookieName}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
|
|
109
|
+
}
|
|
110
|
+
/** Every link, those in force first, newest first within each. */
|
|
111
|
+
list() {
|
|
112
|
+
const order = (r) => (r.revoked ? 1 : 0);
|
|
113
|
+
return [...this.records].sort((a, b) => order(a) - order(b) || b.created.localeCompare(a.created)).map(info);
|
|
114
|
+
}
|
|
115
|
+
get(id) {
|
|
116
|
+
const r = this.records.find((x) => x.id === id);
|
|
117
|
+
return r ? info(r) : undefined;
|
|
118
|
+
}
|
|
119
|
+
/** Revoke a link in force: it lets nobody in from now on, and its pages' streams end. */
|
|
120
|
+
revoke(id, by, now = Date.now()) {
|
|
121
|
+
const r = this.records.find((x) => x.id === id);
|
|
122
|
+
if (!r || r.revoked)
|
|
123
|
+
return false;
|
|
124
|
+
r.revoked = new Date(now).toISOString();
|
|
125
|
+
r.revokedBy = by;
|
|
126
|
+
this.save();
|
|
127
|
+
this.onEnded(info(r));
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
flush() {
|
|
131
|
+
if (this.dirty)
|
|
132
|
+
this.save();
|
|
133
|
+
}
|
|
134
|
+
close() {
|
|
135
|
+
if (this.timer)
|
|
136
|
+
clearInterval(this.timer);
|
|
137
|
+
this.timer = null;
|
|
138
|
+
this.flush();
|
|
139
|
+
}
|
|
140
|
+
save() {
|
|
141
|
+
this.dirty = false;
|
|
142
|
+
if (!this.file)
|
|
143
|
+
return;
|
|
144
|
+
const tmp = `${this.file}.tmp`;
|
|
145
|
+
writeFileSync(tmp, JSON.stringify({ cookie: this.cookieName.slice(COOKIE.length + 1), links: this.records }, null, 1) + "\n", { mode: 0o600 });
|
|
146
|
+
renameSync(tmp, this.file);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function isRecord(x) {
|
|
150
|
+
const r = x;
|
|
151
|
+
return !!r && typeof r === "object" && ID.test(r.id) && typeof r.token === "string" && r.token.startsWith(`${READONLY_PREFIX}${r.id}_`) && typeof r.created === "string";
|
|
152
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What a read-only reader may ask (CollabSpec decision 11): what a guest reads, and nothing that
|
|
2
|
+
// changes what is shared. The server refuses the rest (403) whatever the page sends; the page does
|
|
3
|
+
// not offer it. What costs the server is bounded for read-only readers: Lean's answers, by address
|
|
4
|
+
// and in all, each kept per build; the printed document, one typesetting at a time for them all.
|
|
5
|
+
/** Whether a read-only reader may make this request. */
|
|
6
|
+
export function readOnlyAllows(method, path) {
|
|
7
|
+
const read = method === "GET" || method === "HEAD";
|
|
8
|
+
if (read) {
|
|
9
|
+
// the page and its files
|
|
10
|
+
if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/"))
|
|
11
|
+
return true;
|
|
12
|
+
// the graph, its sources and Lean's answers, the review, what happened, the views, the history,
|
|
13
|
+
// the library, the printed document's state and files, who the reader is and the link they
|
|
14
|
+
// came by, and the event stream every change comes by
|
|
15
|
+
if (["/api/status", "/api/meta", "/api/edges", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
|
|
16
|
+
return true;
|
|
17
|
+
// a stream is opened by GET alone: a HEAD would take one of their places for nothing
|
|
18
|
+
if (path === "/api/events")
|
|
19
|
+
return method === "GET";
|
|
20
|
+
if (path === "/api/hover" || path === "/api/hover/describe" || path === "/api/hover/print")
|
|
21
|
+
return true;
|
|
22
|
+
for (const p of ["/api/views", "/api/history", "/api/library", "/api/export"])
|
|
23
|
+
if (path === p || path.startsWith(`${p}/`))
|
|
24
|
+
return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
// a file opened in Lean, the names in a declaration's source: reads, posted for their size
|
|
28
|
+
if (method === "POST" && (path === "/api/hover/open" || path === "/api/hover/names"))
|
|
29
|
+
return true;
|
|
30
|
+
// the printed document of a view as it is shared (bounded below)
|
|
31
|
+
if (method === "POST" && path === "/api/export")
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/** Whether Lean's answer is what it says in the end, to be kept for the build: not a failure, nor
|
|
36
|
+
* "still elaborating" (server/hover.ts), which a later request must ask again. */
|
|
37
|
+
export function finalAnswer(body) {
|
|
38
|
+
const b = body;
|
|
39
|
+
return !!b && typeof b === "object" && !b.error && !b.pending;
|
|
40
|
+
}
|
|
41
|
+
/** At most `per` in `ms` for each key, and `all` in all: a sliding count per key. */
|
|
42
|
+
class Rate {
|
|
43
|
+
hits = new Map();
|
|
44
|
+
every = [];
|
|
45
|
+
per;
|
|
46
|
+
all;
|
|
47
|
+
ms;
|
|
48
|
+
constructor(per, all, ms) {
|
|
49
|
+
this.per = per;
|
|
50
|
+
this.all = all;
|
|
51
|
+
this.ms = ms;
|
|
52
|
+
}
|
|
53
|
+
/** 0 when `key` may go now (and it is counted), else the seconds to wait. */
|
|
54
|
+
take(key, now = Date.now()) {
|
|
55
|
+
const since = now - this.ms;
|
|
56
|
+
const mine = (this.hits.get(key) ?? []).filter((t) => t > since);
|
|
57
|
+
this.every = this.every.filter((t) => t > since);
|
|
58
|
+
const wait = (ts, max) => (ts.length < max ? 0 : Math.max(1, Math.ceil((ts[ts.length - max] + this.ms - now) / 1000)));
|
|
59
|
+
const w = Math.max(wait(mine, this.per), wait(this.every, this.all));
|
|
60
|
+
if (w === 0) {
|
|
61
|
+
mine.push(now);
|
|
62
|
+
this.every.push(now);
|
|
63
|
+
}
|
|
64
|
+
if (mine.length)
|
|
65
|
+
this.hits.set(key, mine);
|
|
66
|
+
else
|
|
67
|
+
this.hits.delete(key);
|
|
68
|
+
// the keys of addresses gone quiet go with them
|
|
69
|
+
if (this.hits.size > 10_000)
|
|
70
|
+
for (const [k, ts] of this.hits)
|
|
71
|
+
if (!ts.some((t) => t > since))
|
|
72
|
+
this.hits.delete(k);
|
|
73
|
+
return w;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Lean's answers to read-only readers, and their printed documents. */
|
|
77
|
+
export class ReadOnlyBounds {
|
|
78
|
+
/** Lean's answers: per address, and for every read-only reader together, a minute's worth */
|
|
79
|
+
lean = new Rate(60, 300, 60_000);
|
|
80
|
+
/** Lean's answers to the same question about the same build, as read-only readers asked them */
|
|
81
|
+
answers = new Map();
|
|
82
|
+
static ANSWERS = 2000;
|
|
83
|
+
/** the read-only readers' document being typeset, by its job's id; `RESERVED` while it starts */
|
|
84
|
+
exporting = null;
|
|
85
|
+
static RESERVED = "\0starting";
|
|
86
|
+
/** whether a job is still running (server/export.ts) */
|
|
87
|
+
running;
|
|
88
|
+
constructor(running = () => false) {
|
|
89
|
+
this.running = running;
|
|
90
|
+
}
|
|
91
|
+
/** 0 when an address may ask Lean now, else the seconds to wait (429). */
|
|
92
|
+
leanWait(address, now = Date.now()) {
|
|
93
|
+
return this.lean.take(address, now);
|
|
94
|
+
}
|
|
95
|
+
/** An answer kept for this build and question, if there is one. */
|
|
96
|
+
answer(key) {
|
|
97
|
+
return this.answers.get(key);
|
|
98
|
+
}
|
|
99
|
+
/** Keep an answer, the oldest going past ANSWERS. */
|
|
100
|
+
keep(key, answer) {
|
|
101
|
+
this.answers.delete(key);
|
|
102
|
+
this.answers.set(key, answer);
|
|
103
|
+
while (this.answers.size > ReadOnlyBounds.ANSWERS)
|
|
104
|
+
this.answers.delete(this.answers.keys().next().value);
|
|
105
|
+
}
|
|
106
|
+
/** Whether a read-only reader may start a document now: no other read-only document is being
|
|
107
|
+
* typeset, nor starting. */
|
|
108
|
+
mayExport() {
|
|
109
|
+
if (this.exporting && this.exporting !== ReadOnlyBounds.RESERVED && !this.running(this.exporting))
|
|
110
|
+
this.exporting = null;
|
|
111
|
+
return this.exporting === null;
|
|
112
|
+
}
|
|
113
|
+
/** Take the place before a document starts (`mayExport` said yes). */
|
|
114
|
+
reserveExport() {
|
|
115
|
+
this.exporting = ReadOnlyBounds.RESERVED;
|
|
116
|
+
}
|
|
117
|
+
/** The document started (the export's answer), or failed to: the place is its job's while it
|
|
118
|
+
* runs, and free otherwise. */
|
|
119
|
+
exportStarted(answer) {
|
|
120
|
+
const job = answer?.job;
|
|
121
|
+
this.exporting = job?.state === "running" && typeof job.id === "string" ? job.id : null;
|
|
122
|
+
}
|
|
123
|
+
}
|