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,114 @@
1
+ // Who records something (CollabSpec decision 3). By default the name the page gives, as it gives
2
+ // it: any name, changed at will, nothing checked (relaxed on 2026-10-07 — names used to be claimed
3
+ // with a secret). The page sends it with every request as `X-VisualProof-Name`, percent-encoded so
4
+ // that any script travels in a header. Behind a proxy that signs people in, the name is the one the
5
+ // proxy puts in its own header instead, and the page cannot change it.
6
+ //
7
+ // Someone invited by a personal link (CollabSpec decision 9, server/invites.ts) is who the link's
8
+ // cookie says, whatever the page sends: the link's id is who they are, and the name they go by is
9
+ // the link's, changed through the server. While their link is in force, nobody coming by the key's
10
+ // link (a guest) can go by that name: such a request is taken as one without a name, so that
11
+ // whatever needs one is refused and the page asks for another (`taken` says why).
12
+ //
13
+ // An agent says it is one (AgentSpec decision 1) with `X-VisualProof-Kind: agent` beside its name.
14
+ // Nothing checks that either: the kind is what makes its opinions advice, so an agent that does not
15
+ // say it is one is taken for a person, as a person who says so is taken for an agent.
16
+ //
17
+ // A private reader (CollabSpec decision 8) says so with every request, `X-VisualProof-Private`
18
+ // carrying what the page calls itself (its event stream's session). What they arrange is recorded
19
+ // without their name (layoutAuthor, below); their review keeps it.
20
+ import { validName, validSession } from "../shared/collab.js";
21
+ export const NAME_HEADER = "x-visualproof-name";
22
+ export const KIND_HEADER = "x-visualproof-kind";
23
+ export const PRIVATE_HEADER = "x-visualproof-private";
24
+ export class Identities {
25
+ /** the header an authenticating proxy names the reader in, or null: the page's name is taken */
26
+ header;
27
+ /** the personal links (server/invites.ts), which say who their holders are; none behind a proxy
28
+ * that signs people in, whose names are the proxy's */
29
+ invites = null;
30
+ /** what each request's cookie said, read once per request */
31
+ seen = new WeakMap();
32
+ constructor(header = null) {
33
+ this.header = header ? header.toLowerCase() : null;
34
+ }
35
+ get mode() {
36
+ return this.header ? "header" : "declared";
37
+ }
38
+ /** What the personal link's cookie a request carries says: a link in force, one revoked, none. */
39
+ link(req) {
40
+ if (this.header || !this.invites)
41
+ return null;
42
+ if (!this.seen.has(req))
43
+ this.seen.set(req, this.invites.ofCookie(req.headers.cookie));
44
+ return this.seen.get(req);
45
+ }
46
+ /** The holder of a personal link in force a request comes from, or null. */
47
+ person(req) {
48
+ const l = this.link(req);
49
+ return l && "ok" in l ? l.ok : null;
50
+ }
51
+ /** The name a request is made under, or null when it gives none — or gives one a holder of a
52
+ * personal link goes by, and is not theirs. */
53
+ who(req) {
54
+ const p = this.person(req);
55
+ if (p)
56
+ return p.name;
57
+ const s = this.said(req);
58
+ return s && !this.taken(req, s) ? s : null;
59
+ }
60
+ /** The holder of a personal link in force who goes by the name a guest's request gives, which is
61
+ * then not theirs to take; null for any other request. An agent's names are its own: it is
62
+ * marked as one wherever it is named. */
63
+ taken(req, name = this.said(req)) {
64
+ if (!name || !this.invites || this.header || this.person(req) || this.kind(req) === "agent")
65
+ return null;
66
+ return this.invites.holder(name) ?? null;
67
+ }
68
+ /** The name a request's headers give, checked to be a name. */
69
+ said(req) {
70
+ const raw = req.headers[this.header ?? NAME_HEADER];
71
+ let s = (Array.isArray(raw) ? raw[0] : raw)?.trim();
72
+ if (!s)
73
+ return null;
74
+ if (!this.header) {
75
+ try {
76
+ s = decodeURIComponent(s);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ return validName(s) ? s : null;
83
+ }
84
+ /** Whether a request comes from an agent: it says so. A personal link's holder is a person. */
85
+ kind(req) {
86
+ if (this.person(req))
87
+ return "person";
88
+ const raw = req.headers[KIND_HEADER];
89
+ return (Array.isArray(raw) ? raw[0] : raw)?.trim().toLowerCase() === "agent" ? "agent" : "person";
90
+ }
91
+ /** Whether a request comes from a private page (CollabSpec decision 8): the session the page
92
+ * calls itself by, "" where it gives none, or null for any other request. An agent is never
93
+ * private, whatever it sends: its token or its name is who it is, and it is always listed. Nothing
94
+ * checks a page that says it is private either: it only gives up its name on what it arranges. */
95
+ privateOf(req) {
96
+ if (this.kind(req) === "agent")
97
+ return null;
98
+ const raw = req.headers[PRIVATE_HEADER];
99
+ const s = (Array.isArray(raw) ? raw[0] : raw)?.trim();
100
+ if (s === undefined)
101
+ return null;
102
+ return validSession(s) ? s : "";
103
+ }
104
+ }
105
+ /** The name a change to the layout is recorded under (CollabSpec decision 8): the caller's, or none
106
+ * for a private reader, who shows as "someone" — on the boards, in Activity, among pin authors and
107
+ * in the agents' answers. This is where a private reader's anonymity lives: whatever arranges a
108
+ * board — pins, folds, re-layouts, scopes and clusterings, views made, renamed and deleted, notes
109
+ * left on a board — takes its author from here, through server/views.ts (`handle`, whose `me` is
110
+ * this) and main.ts (an undo). The review is not layout: opinions, targets, key marks and trust
111
+ * take the caller's own name, and need one. */
112
+ export function layoutAuthor(caller) {
113
+ return caller.private ? null : caller.name;
114
+ }
@@ -0,0 +1,241 @@
1
+ // Personal links (CollabSpec decision 9, the owner's decision of 2026-10-09): someone on the server
2
+ // invites a person by name from Share, and the server makes a link for that person alone. The link
3
+ // lets its holder in, as the key's link does (server/access.ts), and says who they are: the
4
+ // holder's identity is the link's id, and the name is only what they are called — they rename
5
+ // themselves from the menu under it, and what they did stays theirs, shown under the new name.
6
+ // Their review goes to one log named by the id (shared/review.ts, logFileName), so a rename never
7
+ // starts a new file. Nobody else can take the name they go by while their link is in force.
8
+ //
9
+ // A link is `/i/vpp_<id>_<secret>`, the token made as an agent's is (server/tokens.ts): the prefix
10
+ // makes one recognisable wherever it is pasted, the id names it in lists and in the review's logs
11
+ // (it is no secret), the secret is 32 random bytes. Opened, it leaves a cookie in the browser and
12
+ // goes on to the page, so that the token leaves the address; the cookie's value is made from the
13
+ // token, never the token itself (cookies are not kept per port, and other services answer on this
14
+ // host's name), and its name is this server's own, so two servers on one host keep theirs apart.
15
+ //
16
+ // A link lasts until it is revoked. An agent's token lasts days, since it hands a program a
17
+ // credential for a piece of work; a person's link is who they are in the review, and its id names
18
+ // their log: if it ran out, the same person would come back with a new link as someone else, with a
19
+ // log of their own. The key's link never runs out either, and a personal link reaches no further.
20
+ // Share lists every link in force with when it was last used, so that one nobody uses any more is
21
+ // seen and revoked, which cuts it off at once — its pages' event streams too.
22
+ //
23
+ // The token is shown once, to whoever made it, for them to send: the server keeps only its digest,
24
+ // in `<site>/person-invites.json` (readable by its owner alone, as the access key and the agents'
25
+ // tokens are). Showing it again would let anyone on the server be that person. Revoked links are
26
+ // kept, with every name their holders went by, so that what they did keeps its name; they never let
27
+ // anyone in again. When a link was last used is written at most once a minute, as an agent token's.
28
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
29
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { MAX_NAME, sameName, validName } from "../shared/collab.js";
32
+ import { validPersonId } from "../shared/review.js";
33
+ export const INVITE_PREFIX = "vpp_";
34
+ /** where a personal link points: `/i/<token>` */
35
+ export const INVITE_PATH = "/i/";
36
+ const FILE = "person-invites.json";
37
+ /** what the cookie's name starts with; the rest is this server's own (`cookie` in its file) */
38
+ const COOKIE = "visualproof_person";
39
+ /** how long a browser stays let in: the longest browsers keep a cookie (400 days), renewed at each
40
+ * visit to the page */
41
+ const MAX_AGE = 400 * 24 * 3600;
42
+ const FLUSH_MS = 60_000;
43
+ const SECRET = /^[A-Za-z0-9_-]{43}$/;
44
+ const sha = (s) => createHash("sha256").update(s).digest();
45
+ const digestOf = (token) => sha(`visualproof person link\0${token}`).toString("base64url");
46
+ /** the cookie's value for a link: its id, and a digest of what is kept of its token */
47
+ const cookieValue = (r) => `${r.id}.${sha(`visualproof person cookie\0${r.digest}`).toString("base64url")}`;
48
+ /** Whether `a` and `b` are the same, in constant time whatever they are. */
49
+ function same(a, b) {
50
+ return timingSafeEqual(sha(a), sha(b));
51
+ }
52
+ export class Invites {
53
+ file;
54
+ records = [];
55
+ /** the cookie's name: `visualproof_person_` and this server's own 12 hex digits */
56
+ cookieName;
57
+ dirty = false;
58
+ timer = null;
59
+ /** told when a link is revoked: its pages' event streams end (server/live.ts) */
60
+ onEnded = () => { };
61
+ /** told when a link was made, renamed or revoked: every page is told the names (`people`) */
62
+ onChanged = () => { };
63
+ /** `siteDir` null keeps the links in memory only (tests). */
64
+ constructor(siteDir) {
65
+ this.file = siteDir ? join(siteDir, FILE) : null;
66
+ let own = null;
67
+ if (this.file && existsSync(this.file)) {
68
+ try {
69
+ const data = JSON.parse(readFileSync(this.file, "utf8"));
70
+ if (typeof data.cookie === "string" && /^[0-9a-f]{12}$/.test(data.cookie))
71
+ own = data.cookie;
72
+ if (Array.isArray(data.invites))
73
+ this.records = data.invites.filter(isRecord);
74
+ }
75
+ catch {
76
+ // a file that cannot be read lets nobody in by a link; it is kept aside, not overwritten
77
+ renameSync(this.file, `${this.file}.broken-${Date.now()}`);
78
+ }
79
+ }
80
+ // the cookie's name is kept with the first link: before there is one, no browser has the cookie
81
+ this.cookieName = `${COOKIE}_${own ?? randomBytes(6).toString("hex")}`;
82
+ if (!own && this.records.length)
83
+ this.save();
84
+ if (this.file) {
85
+ this.timer = setInterval(() => this.flush(), FLUSH_MS);
86
+ this.timer.unref?.();
87
+ }
88
+ }
89
+ /** Make a link for a person, under the name `name` (checked by the caller), made by `by`: the
90
+ * token, for the link, and its record; or why not — a name someone with a link in force goes by
91
+ * is theirs. */
92
+ create(o) {
93
+ const now = new Date(o.now ?? Date.now()).toISOString();
94
+ const name = o.name.trim();
95
+ const holder = this.holder(name);
96
+ if (holder)
97
+ return taken(holder);
98
+ let id;
99
+ do
100
+ id = randomBytes(5).toString("hex");
101
+ while (this.records.some((r) => r.id === id));
102
+ const token = `${INVITE_PREFIX}${id}_${randomBytes(32).toString("base64url")}`;
103
+ const record = { id, digest: digestOf(token), name, names: [{ name, at: now }], by: o.by, created: now, revoked: null, revokedBy: null, lastUsed: null };
104
+ this.records.push(record);
105
+ this.save();
106
+ this.onChanged();
107
+ return { token, record };
108
+ }
109
+ /** What a link's token is. Compared in constant time, by digests of one length whatever was sent,
110
+ * whether or not its id is known. */
111
+ check(token, now = Date.now()) {
112
+ if (!token.startsWith(INVITE_PREFIX))
113
+ return null;
114
+ const id = token.slice(INVITE_PREFIX.length, INVITE_PREFIX.length + 10);
115
+ const r = validPersonId(id) && token[INVITE_PREFIX.length + 10] === "_" && SECRET.test(token.slice(INVITE_PREFIX.length + 11)) ? this.get(id) : undefined;
116
+ const ok = same(digestOf(token), r?.digest ?? "no such link");
117
+ return r && ok ? this.seen(r, now) : null;
118
+ }
119
+ /** What the cookie a request carries says (its `Cookie` header): a link in force, one revoked
120
+ * (whose cookie the browser should forget), or none. */
121
+ ofCookie(header, now = Date.now()) {
122
+ const given = cookieOf(header, this.cookieName);
123
+ if (given === null)
124
+ return null;
125
+ const r = given.indexOf(".") === 10 ? this.get(given.slice(0, 10)) : undefined;
126
+ const ok = same(given, r ? cookieValue(r) : "no such link");
127
+ return r && ok ? this.seen(r, now) : null;
128
+ }
129
+ seen(r, now) {
130
+ if (r.revoked)
131
+ return { ended: "revoked", record: r };
132
+ r.lastUsed = new Date(now).toISOString();
133
+ this.dirty = true;
134
+ return { ok: r };
135
+ }
136
+ /** The cookie that lets a browser in as the holder of `r` (a `Set-Cookie` value). */
137
+ cookie(r) {
138
+ return `${this.cookieName}=${cookieValue(r)}; Path=/; Max-Age=${MAX_AGE}; HttpOnly; SameSite=Lax`;
139
+ }
140
+ /** What makes a browser forget a revoked link's cookie. */
141
+ forget() {
142
+ return `${this.cookieName}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
143
+ }
144
+ /** The links in force, oldest first. */
145
+ inForce() {
146
+ return this.records.filter((r) => !r.revoked);
147
+ }
148
+ get(id) {
149
+ return this.records.find((r) => r.id === id);
150
+ }
151
+ /** The holder of a link in force who goes by `name` (sameName), but `except`; undefined when
152
+ * nobody does. */
153
+ holder(name, except = null) {
154
+ return this.inForce().find((r) => r.id !== except && sameName(r.name, name));
155
+ }
156
+ /** The holder of a link in force goes by `name` from now on (checked by the caller); every name
157
+ * they had is kept. The record, or why not: the name is another holder's. */
158
+ rename(id, name, now = Date.now()) {
159
+ const r = this.get(id);
160
+ if (!r || r.revoked)
161
+ return "This personal link was revoked.";
162
+ const n = name.trim();
163
+ const other = this.holder(n, id);
164
+ if (other)
165
+ return taken(other);
166
+ if (r.name === n)
167
+ return r;
168
+ r.name = n;
169
+ r.names.push({ name: n, at: new Date(now).toISOString() });
170
+ this.save();
171
+ this.onChanged();
172
+ return r;
173
+ }
174
+ /** Revoke a link in force: it lets nobody in from now on, and its pages' event streams end.
175
+ * False when there is no such link in force. */
176
+ revoke(id, by, now = Date.now()) {
177
+ const r = this.get(id);
178
+ if (!r || r.revoked)
179
+ return false;
180
+ r.revoked = new Date(now).toISOString();
181
+ r.revokedBy = by;
182
+ this.save();
183
+ this.onEnded(r);
184
+ this.onChanged();
185
+ return true;
186
+ }
187
+ /** Every holder of a link, revoked ones too, as every page is told them (`Invited`). */
188
+ people() {
189
+ return this.records.map((r) => ({ id: r.id, name: r.name, was: [...new Set(r.names.map((n) => n.name).filter((n) => n !== r.name))], ...(r.revoked ? { revoked: true } : {}) }));
190
+ }
191
+ /** Write when links were last used, if that changed since the last write. */
192
+ flush() {
193
+ if (this.dirty)
194
+ this.save();
195
+ }
196
+ /** Stop writing on a timer (tests, the server's end), writing what is left. */
197
+ close() {
198
+ if (this.timer)
199
+ clearInterval(this.timer);
200
+ this.timer = null;
201
+ this.flush();
202
+ }
203
+ /** A link as Share shows it: no digest. */
204
+ static info(r) {
205
+ const { digest: _, ...rest } = r;
206
+ return { ...rest, names: rest.names.map((n) => ({ ...n })) };
207
+ }
208
+ save() {
209
+ this.dirty = false;
210
+ if (!this.file)
211
+ return;
212
+ const tmp = `${this.file}.tmp`;
213
+ writeFileSync(tmp, JSON.stringify({ cookie: this.cookieName.slice(COOKIE.length + 1), invites: this.records }, null, 1) + "\n", { mode: 0o600 });
214
+ renameSync(tmp, this.file);
215
+ }
216
+ }
217
+ /** Why a name is not to be had: someone with a personal link goes by it. */
218
+ export function taken(r) {
219
+ return `“${r.name}” is the name of someone invited with a personal link: choose another.`;
220
+ }
221
+ /** Why a name a person is to be invited under, or to take, will not do; null when it will. */
222
+ export function invalidPersonName(s) {
223
+ if (typeof s !== "string" || !validName(s.trim()))
224
+ return `A name is 1 to ${MAX_NAME} characters, without line breaks.`;
225
+ return null;
226
+ }
227
+ /** A cookie's value as the `Cookie` header carries it, or null. */
228
+ export function cookieOf(header, name) {
229
+ for (const part of (header ?? "").split(";")) {
230
+ const eq = part.indexOf("=");
231
+ if (eq > 0 && part.slice(0, eq).trim() === name)
232
+ return part.slice(eq + 1).trim();
233
+ }
234
+ return null;
235
+ }
236
+ /** A record as this server keeps it; anything else is dropped when the file is read. */
237
+ function isRecord(x) {
238
+ const r = x;
239
+ return (!!r && typeof r === "object" && validPersonId(r.id) && typeof r.digest === "string" && typeof r.name === "string" && validName(r.name) &&
240
+ Array.isArray(r.names) && r.names.every((n) => n && typeof n.name === "string" && typeof n.at === "string") && typeof r.by === "string" && typeof r.created === "string");
241
+ }
@@ -0,0 +1,51 @@
1
+ // The graph's layouts, off the server's thread: computeLayout then stabilize, for each clustering in
2
+ // a worker of its own, both at once. Laid out on the server's thread, Sphere-Packing-Lean's graph held
3
+ // every request for seconds (2.5 s a clustering, more against a memory far off); a worker holds
4
+ // none. A worker is given what a layout reads of each declaration, the edges and the memory, as
5
+ // the page's own (web/src/layoutworker.ts), and gives the layout back. This module is its own
6
+ // worker, so that a checkout (TypeScript) and the package (JavaScript) find it alike.
7
+ import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
8
+ import { computeLayout, stabilize } from "../shared/layout.js";
9
+ /** A clustering's layout, here: computeLayout, then stabilize against the memory. */
10
+ export function layOutHere(mode, nodes, edges, memory) {
11
+ const all = nodes;
12
+ return stabilize(computeLayout(mode, all, edges), all, memory);
13
+ }
14
+ /** A clustering's layout, in a worker. Should no worker start, or one fail, it is laid out here:
15
+ * slower to answer others meanwhile, never wrong. */
16
+ export function layOutOffThread(mode, nodes, edges, memory) {
17
+ const ask = { vpLayout: true, mode, nodes: nodes.map(({ name, module, ext }) => (ext ? { name, module, ext } : { name, module })), edges, memory };
18
+ return new Promise((ok) => {
19
+ let done = false;
20
+ const here = () => {
21
+ if (done)
22
+ return;
23
+ done = true;
24
+ ok(layOutHere(mode, nodes, edges, memory));
25
+ };
26
+ let worker;
27
+ try {
28
+ worker = new Worker(new URL(import.meta.url), { workerData: ask });
29
+ }
30
+ catch {
31
+ return here();
32
+ }
33
+ worker.once("message", (layout) => {
34
+ if (done)
35
+ return;
36
+ done = true;
37
+ ok(layout);
38
+ void worker.terminate();
39
+ });
40
+ worker.once("error", here);
41
+ worker.once("exit", (code) => {
42
+ if (code !== 0)
43
+ here();
44
+ });
45
+ });
46
+ }
47
+ // in a worker: the one layout asked, and back
48
+ if (!isMainThread && workerData?.vpLayout) {
49
+ const a = workerData;
50
+ parentPort.postMessage(layOutHere(a.mode, a.nodes, a.edges, a.memory));
51
+ }