@appsoftwareltd/etherpk-mcp 0.8.0 → 0.8.2
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/README.md +14 -8
- package/dist/main.js +1641 -232
- package/dist/main.js.map +1 -1
- package/package.json +7 -7
package/dist/main.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
3
3
|
import { availableParallelism, homedir, hostname, tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { access, chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import { x25519 } from "@noble/curves/ed25519.js";
|
|
9
9
|
import "@noble/hashes/argon2.js";
|
|
@@ -29,9 +29,12 @@ import { languages } from "@codemirror/language-data";
|
|
|
29
29
|
import MarkdownIt from "markdown-it";
|
|
30
30
|
import katex from "katex";
|
|
31
31
|
import Mustache from "mustache";
|
|
32
|
+
import { lookup } from "node:dns";
|
|
33
|
+
import { request } from "node:https";
|
|
34
|
+
import { BlockList, isIP } from "node:net";
|
|
32
35
|
var package_default = {
|
|
33
36
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
34
|
-
version: "0.8.
|
|
37
|
+
version: "0.8.2",
|
|
35
38
|
license: "Elastic-2.0",
|
|
36
39
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
37
40
|
type: "module",
|
|
@@ -52,20 +55,20 @@ var package_default = {
|
|
|
52
55
|
"@huggingface/tokenizers": "0.2.0",
|
|
53
56
|
"@lezer/highlight": "^1.2.3",
|
|
54
57
|
"@lezer/markdown": "^1.6.4",
|
|
55
|
-
"@modelcontextprotocol/sdk": "
|
|
56
|
-
"@noble/curves": "
|
|
57
|
-
"@noble/hashes": "
|
|
58
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
59
|
+
"@noble/curves": "2.2.0",
|
|
60
|
+
"@noble/hashes": "2.2.0",
|
|
58
61
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
59
62
|
"fake-indexeddb": "^6.2.5",
|
|
60
63
|
"katex": "^0.17.0",
|
|
61
|
-
"lib0": "
|
|
64
|
+
"lib0": "0.2.117",
|
|
62
65
|
"markdown-it": "15.0.2",
|
|
63
66
|
"mermaid": "^11.16.1",
|
|
64
67
|
"mustache": "4.2.0",
|
|
65
68
|
"playwright-core": "1.59.1",
|
|
66
|
-
"y-protocols": "
|
|
69
|
+
"y-protocols": "1.0.7",
|
|
67
70
|
"yaml": "^2.9.0",
|
|
68
|
-
"yjs": "
|
|
71
|
+
"yjs": "13.6.31",
|
|
69
72
|
"zod": "^4.3.6"
|
|
70
73
|
},
|
|
71
74
|
devDependencies: {
|
|
@@ -632,7 +635,9 @@ function createSyncApi(deps) {
|
|
|
632
635
|
});
|
|
633
636
|
if (!res.ok) {
|
|
634
637
|
const body = await res.json().catch(() => null);
|
|
635
|
-
|
|
638
|
+
const nested = typeof body?.error === "object" && body.error !== null ? body.error : null;
|
|
639
|
+
const code = nested?.code ?? body?.code;
|
|
640
|
+
throw new SyncApiError(nested?.message ?? body?.code ?? `HTTP ${res.status}`, res.status, code, body?.retryable === true);
|
|
636
641
|
}
|
|
637
642
|
return await res.json();
|
|
638
643
|
}
|
|
@@ -671,8 +676,14 @@ function createSyncApi(deps) {
|
|
|
671
676
|
method: "PUT",
|
|
672
677
|
body: JSON.stringify({ publicKey })
|
|
673
678
|
}),
|
|
674
|
-
getIdentityByEmail: (email) => call(
|
|
675
|
-
|
|
679
|
+
getIdentityByEmail: (graphId, email) => call("/api/v1/sync/identity/lookup", {
|
|
680
|
+
method: "POST",
|
|
681
|
+
body: JSON.stringify({
|
|
682
|
+
graphId,
|
|
683
|
+
email
|
|
684
|
+
})
|
|
685
|
+
}).catch((e) => {
|
|
686
|
+
if (e instanceof SyncApiError && e.status === 404) return null;
|
|
676
687
|
throw e;
|
|
677
688
|
}),
|
|
678
689
|
createInvite: (graphId, inviteeEmail, sealedKeyring) => call("/api/v1/sync/invites", {
|
|
@@ -685,6 +696,7 @@ function createSyncApi(deps) {
|
|
|
685
696
|
}),
|
|
686
697
|
listInvites: () => call("/api/v1/sync/invites").then((r) => r.invites),
|
|
687
698
|
acceptInvite: (id) => call(`/api/v1/sync/invites/${id}/accept`, { method: "POST" }).then((r) => r.graphId),
|
|
699
|
+
withdrawInvite: (id) => call(`/api/v1/sync/invites/${id}`, { method: "DELETE" }),
|
|
688
700
|
graphMembers: (graphId) => call(`/api/v1/sync/graphs/${graphId}/members`).then((r) => r.members),
|
|
689
701
|
leaveGraph: (graphId) => call(`/api/v1/sync/graphs/${graphId}/leave`, { method: "POST" }),
|
|
690
702
|
deleteGraph: (graphId) => call(`/api/v1/sync/graphs/${graphId}`, { method: "DELETE" }),
|
|
@@ -782,6 +794,36 @@ function createSyncTokenSource(mint, opts) {
|
|
|
782
794
|
};
|
|
783
795
|
}
|
|
784
796
|
//#endregion
|
|
797
|
+
//#region src/connection-errors.ts
|
|
798
|
+
/** Socket and TLS error codes Node's fetch reports as the cause of "fetch failed". */
|
|
799
|
+
var TLS_CODES = new Set([
|
|
800
|
+
"ERR_SSL_WRONG_VERSION_NUMBER",
|
|
801
|
+
"EPROTO",
|
|
802
|
+
"CERT_HAS_EXPIRED",
|
|
803
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
804
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
805
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
806
|
+
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
807
|
+
]);
|
|
808
|
+
/**
|
|
809
|
+
* Why connecting to a Sync Server failed, in terms a person can act on, or null for a failure
|
|
810
|
+
* this does not recognise. The first mistakes a login makes - the EtherPK app's address in place
|
|
811
|
+
* of the server's, a wrong or revoked token, https against a plain-HTTP server, a mistyped host -
|
|
812
|
+
* otherwise read as "HTTP 404", "Unauthorized" or "fetch failed".
|
|
813
|
+
*/
|
|
814
|
+
function describeConnectionFailure(error, syncServer) {
|
|
815
|
+
if (error instanceof SyncApiError) {
|
|
816
|
+
if (error.status === 404 && error.message === "HTTP 404") return `${syncServer} is not a Sync Server (it may be the EtherPK app). Use the Sync Server address shown in EtherPK under a synced graph's Settings > Agents.`;
|
|
817
|
+
if (error.status === 401) return `${syncServer} did not accept the access token: it may be revoked, expired or mistyped. Create one at ${syncServer}/account/tokens and run login again.`;
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
if (error instanceof TypeError && error.message === "fetch failed") {
|
|
821
|
+
const code = error.cause?.code;
|
|
822
|
+
return `Could not connect to ${syncServer}: ${code === "ENOTFOUND" ? "the host was not found. Check the address." : code === "ECONNREFUSED" ? "the connection was refused. Check the address and that the server is running." : typeof code === "string" && TLS_CODES.has(code) ? "the secure connection failed. Check whether the server uses https or http." : `it did not answer (${typeof code === "string" ? code : "no reason given"}).`}`;
|
|
823
|
+
}
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
785
827
|
//#region src/account.ts
|
|
786
828
|
/**
|
|
787
829
|
* The account side of the [[Headless Client]]: the same REST bridge and vault the browser uses,
|
|
@@ -803,7 +845,13 @@ function createHeadlessAccount(config) {
|
|
|
803
845
|
/** Resolve the PAT to its principal; a graph-scoped or revoked token fails here, in words. */
|
|
804
846
|
async function connectAccount(config) {
|
|
805
847
|
const account = createHeadlessAccount(config);
|
|
806
|
-
|
|
848
|
+
let me;
|
|
849
|
+
try {
|
|
850
|
+
me = await account.api.me();
|
|
851
|
+
} catch (error) {
|
|
852
|
+
const described = describeConnectionFailure(error, config.syncServer);
|
|
853
|
+
throw described ? new Error(described, { cause: error }) : error;
|
|
854
|
+
}
|
|
807
855
|
return {
|
|
808
856
|
...account,
|
|
809
857
|
principal: {
|
|
@@ -2572,7 +2620,7 @@ function createSchema(db) {
|
|
|
2572
2620
|
db.exec(SCHEMA$1);
|
|
2573
2621
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
|
|
2574
2622
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
|
|
2575
|
-
db.exec(`PRAGMA user_version =
|
|
2623
|
+
db.exec(`PRAGMA user_version = 13`);
|
|
2576
2624
|
}
|
|
2577
2625
|
function activeIndexGeneration(db) {
|
|
2578
2626
|
return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
|
|
@@ -2593,7 +2641,7 @@ function advanceIndexRevision(db) {
|
|
|
2593
2641
|
*/
|
|
2594
2642
|
function isUsableIndex(db) {
|
|
2595
2643
|
try {
|
|
2596
|
-
return db.all("PRAGMA user_version")[0]?.user_version ===
|
|
2644
|
+
return db.all("PRAGMA user_version")[0]?.user_version === 13;
|
|
2597
2645
|
} catch {
|
|
2598
2646
|
return false;
|
|
2599
2647
|
}
|
|
@@ -2644,8 +2692,12 @@ function hashText(text) {
|
|
|
2644
2692
|
function isTextSearchable(block) {
|
|
2645
2693
|
return !containsCipherFence(block.text);
|
|
2646
2694
|
}
|
|
2647
|
-
/**
|
|
2695
|
+
/**
|
|
2696
|
+
* Write one document's derived rows under an already-established page id. Returns the text of
|
|
2697
|
+
* every row it put into the text index, which `ingestOne` compares with what it replaced.
|
|
2698
|
+
*/
|
|
2648
2699
|
function insertDerived(db, pageId, doc) {
|
|
2700
|
+
const searchable = [];
|
|
2649
2701
|
for (const alias of doc.aliases) db.run("INSERT INTO aliases (page_id, alias_key, display) VALUES (?,?,?)", [
|
|
2650
2702
|
pageId,
|
|
2651
2703
|
conceptKey$1(alias),
|
|
@@ -2683,7 +2735,10 @@ function insertDerived(db, pageId, doc) {
|
|
|
2683
2735
|
b.label,
|
|
2684
2736
|
b.text
|
|
2685
2737
|
]);
|
|
2686
|
-
if (isTextSearchable(b) && b.localId < 1048576)
|
|
2738
|
+
if (isTextSearchable(b) && b.localId < 1048576) {
|
|
2739
|
+
db.run("INSERT INTO block_fts (rowid, text) VALUES (?,?)", [pageId * BLOCK_FTS_STRIDE + b.localId, b.text]);
|
|
2740
|
+
searchable.push(b.text);
|
|
2741
|
+
}
|
|
2687
2742
|
}
|
|
2688
2743
|
for (const l of links) db.run("INSERT INTO links (page_id, concept, concept_key, line, line_text, match_start, match_end, block_local_id) VALUES (?,?,?,?,?,?,?,?)", [
|
|
2689
2744
|
pageId,
|
|
@@ -2721,6 +2776,7 @@ function insertDerived(db, pageId, doc) {
|
|
|
2721
2776
|
hashText(passage.text),
|
|
2722
2777
|
passage.text
|
|
2723
2778
|
]);
|
|
2779
|
+
return searchable;
|
|
2724
2780
|
}
|
|
2725
2781
|
/**
|
|
2726
2782
|
* Write one document's [[Task Concept]] rows (ADR 0051).
|
|
@@ -2765,6 +2821,37 @@ function inTransaction(db, work) {
|
|
|
2765
2821
|
throw err;
|
|
2766
2822
|
}
|
|
2767
2823
|
}
|
|
2824
|
+
/**
|
|
2825
|
+
* Merge the text index into one segment, which is what removes the words of deleted rows from
|
|
2826
|
+
* the database file (ADR 0097).
|
|
2827
|
+
*
|
|
2828
|
+
* FTS5 deletes by writing a tombstone: the deleted row's terms stay in the segment that holds
|
|
2829
|
+
* them until a merge folds segment and tombstone together, and in a graph nobody is editing that
|
|
2830
|
+
* can be never. `PRAGMA secure_delete` (index-db-sqlite.ts) zeroes the pages a merge frees, but a
|
|
2831
|
+
* segment still in use is not freed. Without a merge, a document protected with content would
|
|
2832
|
+
* leave its words readable with `strings` in the browser's OPFS pool and the Headless Client's
|
|
2833
|
+
* index file, which is the disk-level reader protection exists to stop.
|
|
2834
|
+
*
|
|
2835
|
+
* FTS5's own `secure-delete` option would remove the terms on every delete instead. Measured on a
|
|
2836
|
+
* 2,400-document index it made an edit's re-index 3.5x slower and a generation swap 12.5 s rather
|
|
2837
|
+
* than 0.6 s, for every graph. `optimize` costs about 70 ms there and runs only when protected
|
|
2838
|
+
* text may have been left behind: see {@link ingestOne} and {@link purgeIfProtected}.
|
|
2839
|
+
*
|
|
2840
|
+
* "Protected" here is the index's own flag, `containsCipherFence` anywhere in the text, so it
|
|
2841
|
+
* also covers a page holding a fence beside plaintext (text typed after a protected fence, or a
|
|
2842
|
+
* quoted example), whose plaintext is indexed until the page is protected whole.
|
|
2843
|
+
*/
|
|
2844
|
+
function purgeDeletedText(db) {
|
|
2845
|
+
db.run("INSERT INTO block_fts(block_fts) VALUES('optimize')");
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* After a rebuild: purge when the index now holds any protected document. A rebuild replaces
|
|
2849
|
+
* every row, so the old segments may hold the plaintext of a document that was protected since
|
|
2850
|
+
* the last build, on this device or another. A graph with no protected document pays nothing.
|
|
2851
|
+
*/
|
|
2852
|
+
function purgeIfProtected(db, generation) {
|
|
2853
|
+
if (db.all("SELECT 1 AS n FROM pages WHERE generation = ? AND protected = 1 LIMIT 1", [generation]).length > 0) purgeDeletedText(db);
|
|
2854
|
+
}
|
|
2768
2855
|
/** SQLite has no boolean: the `protected` column as stored. */
|
|
2769
2856
|
function protectedFlag(doc) {
|
|
2770
2857
|
return containsCipherFence(doc.text) ? 1 : 0;
|
|
@@ -2819,6 +2906,7 @@ function commitIndexRebuild(db, generation) {
|
|
|
2819
2906
|
const revision = advanceIndexRevision(db);
|
|
2820
2907
|
const obsolete = db.all("SELECT DISTINCT generation FROM pages WHERE generation <> ?", [generation]).map((row) => row.generation);
|
|
2821
2908
|
for (const oldGeneration of obsolete) deleteGeneration(db, oldGeneration);
|
|
2909
|
+
purgeIfProtected(db, generation);
|
|
2822
2910
|
return revision;
|
|
2823
2911
|
});
|
|
2824
2912
|
}
|
|
@@ -2854,6 +2942,7 @@ function ingestOne(db, doc) {
|
|
|
2854
2942
|
const generation = activeIndexGeneration(db);
|
|
2855
2943
|
const found = db.all("SELECT id FROM pages WHERE generation = ? AND concept_key = ?", [generation, key]);
|
|
2856
2944
|
let pageId;
|
|
2945
|
+
const leaving = found.length > 0 && protectedFlag(doc) === 1 ? db.all("SELECT text FROM block_fts WHERE rowid >= ? AND rowid < ?", blockFtsRange(found[0].id)).map((row) => row.text) : [];
|
|
2857
2946
|
if (found.length > 0) {
|
|
2858
2947
|
pageId = found[0].id;
|
|
2859
2948
|
db.run("DELETE FROM aliases WHERE page_id = ?", [pageId]);
|
|
@@ -2884,7 +2973,8 @@ function ingestOne(db, doc) {
|
|
|
2884
2973
|
protectedFlag(doc)
|
|
2885
2974
|
]);
|
|
2886
2975
|
}
|
|
2887
|
-
insertDerived(db, pageId, doc);
|
|
2976
|
+
const written = new Set(insertDerived(db, pageId, doc));
|
|
2977
|
+
if (leaving.some((text) => !written.has(text))) purgeDeletedText(db);
|
|
2888
2978
|
});
|
|
2889
2979
|
}
|
|
2890
2980
|
/** Every concept key that resolves to a real document (canonical name or alias). */
|
|
@@ -3215,7 +3305,8 @@ function backlinksFor(db, concept) {
|
|
|
3215
3305
|
const hits = db.all(`SELECT l.page_id, p.concept AS sourceConcept, p.kind AS sourceKind, l.line, l.line_text,
|
|
3216
3306
|
l.match_start, l.match_end, l.block_local_id, l.in_title
|
|
3217
3307
|
FROM links l JOIN pages p ON p.id = l.page_id
|
|
3218
|
-
WHERE p.generation=? AND l.concept_key IN (${placeholders})
|
|
3308
|
+
WHERE p.generation=? AND l.concept_key IN (${placeholders})
|
|
3309
|
+
ORDER BY l.page_id, l.line, l.match_start`, [generation, ...names]);
|
|
3219
3310
|
const blocksByPage = /* @__PURE__ */ new Map();
|
|
3220
3311
|
for (const pid of new Set(hits.map((h) => h.page_id))) {
|
|
3221
3312
|
const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, done, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
|
|
@@ -3235,7 +3326,11 @@ function backlinksFor(db, concept) {
|
|
|
3235
3326
|
blocksByPage.set(pid, blocks);
|
|
3236
3327
|
}
|
|
3237
3328
|
const groups = /* @__PURE__ */ new Map();
|
|
3329
|
+
const places = /* @__PURE__ */ new Set();
|
|
3238
3330
|
for (const h of hits) {
|
|
3331
|
+
const place = h.in_title ? `${h.page_id}:title` : h.block_local_id != null ? `${h.page_id}:block:${h.block_local_id}` : `${h.page_id}:line:${h.line}`;
|
|
3332
|
+
if (places.has(place)) continue;
|
|
3333
|
+
places.add(place);
|
|
3239
3334
|
let group = groups.get(h.sourceConcept);
|
|
3240
3335
|
if (!group) {
|
|
3241
3336
|
group = {
|
|
@@ -3915,7 +4010,7 @@ function cacheRoot(env) {
|
|
|
3915
4010
|
* recovered from a joined path (see there).
|
|
3916
4011
|
*/
|
|
3917
4012
|
var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
|
|
3918
|
-
var INDEX_FILE_NAME = `index.
|
|
4013
|
+
var INDEX_FILE_NAME = `index.v13.sqlite`;
|
|
3919
4014
|
var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
|
|
3920
4015
|
function cacheFile(dir) {
|
|
3921
4016
|
return join(dir, CACHE_FILE_NAME);
|
|
@@ -3926,7 +4021,7 @@ function indexFile(dir) {
|
|
|
3926
4021
|
function vectorsFile(dir) {
|
|
3927
4022
|
return join(dir, VECTORS_FILE_NAME);
|
|
3928
4023
|
}
|
|
3929
|
-
function request(req) {
|
|
4024
|
+
function request$1(req) {
|
|
3930
4025
|
return new Promise((resolve, reject) => {
|
|
3931
4026
|
req.onsuccess = () => resolve(req.result);
|
|
3932
4027
|
req.onerror = () => reject(req.error);
|
|
@@ -3941,7 +4036,7 @@ function done(tx) {
|
|
|
3941
4036
|
}
|
|
3942
4037
|
/** Open the cache database the sync engine already created; never creates or upgrades it. */
|
|
3943
4038
|
async function openCacheDb() {
|
|
3944
|
-
const db = await request(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
|
|
4039
|
+
const db = await request$1(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
|
|
3945
4040
|
for (const store of CACHE_STORES) if (!db.objectStoreNames.contains(store)) {
|
|
3946
4041
|
db.close();
|
|
3947
4042
|
throw new Error(`Local Cache database has no "${store}" store; open the graph cache before restoring it.`);
|
|
@@ -3979,7 +4074,7 @@ async function captureLocalCache(graphId) {
|
|
|
3979
4074
|
try {
|
|
3980
4075
|
const tx = db.transaction([...CACHE_STORES], "readonly");
|
|
3981
4076
|
const rows = {};
|
|
3982
|
-
for (const store of CACHE_STORES) rows[store] = (await request(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
|
|
4077
|
+
for (const store of CACHE_STORES) rows[store] = (await request$1(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
|
|
3983
4078
|
await done(tx);
|
|
3984
4079
|
return {
|
|
3985
4080
|
version: CACHE_DB_VERSION,
|
|
@@ -4241,6 +4336,65 @@ async function removeCacheRoot(env) {
|
|
|
4241
4336
|
force: true
|
|
4242
4337
|
});
|
|
4243
4338
|
}
|
|
4339
|
+
var GRAPH_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4340
|
+
/**
|
|
4341
|
+
* The account a synced graph's cache was written for. Cache directories are keyed by server host
|
|
4342
|
+
* and graph id, not by account, and two agents with their own `ETHERPK_MCP_CONFIG` can sign in as
|
|
4343
|
+
* different accounts on one server and share the cache root. The stamp is what lets one of them
|
|
4344
|
+
* tidy only its own caches.
|
|
4345
|
+
*/
|
|
4346
|
+
var OWNER_FILE = "owner.json";
|
|
4347
|
+
/** Record which account's graph this cache holds. Written when a synced graph is opened. */
|
|
4348
|
+
async function stampGraphCacheOwner(dir, principalId) {
|
|
4349
|
+
await mkdir(dir, {
|
|
4350
|
+
recursive: true,
|
|
4351
|
+
mode: 448
|
|
4352
|
+
});
|
|
4353
|
+
await writeFile(join(dir, OWNER_FILE), `${JSON.stringify({ principalId })}\n`, { mode: 384 });
|
|
4354
|
+
}
|
|
4355
|
+
/** The account a cache directory was stamped for, or null (none, or unreadable). */
|
|
4356
|
+
async function graphCacheOwner(dir) {
|
|
4357
|
+
try {
|
|
4358
|
+
const { principalId } = JSON.parse(await readFile(join(dir, OWNER_FILE), "utf8"));
|
|
4359
|
+
return typeof principalId === "string" ? principalId : null;
|
|
4360
|
+
} catch {
|
|
4361
|
+
return null;
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
/**
|
|
4365
|
+
* Remove this account's cached graphs of one server that the server no longer lists for it: a
|
|
4366
|
+
* graph deleted, left or taken away since this machine last served it. Its cache is the graph's
|
|
4367
|
+
* plaintext, and without this it would stay on disk until `logout`. `listed` must be the
|
|
4368
|
+
* server's own answer: a failed listing must not reach here, or every cached graph would go.
|
|
4369
|
+
*
|
|
4370
|
+
* Only directories named as a graph id and stamped for `principalId` are touched. Another
|
|
4371
|
+
* account's cache under the same root (its unacknowledged edits included) is not this account's
|
|
4372
|
+
* to drop, and a cache written before the stamp existed is left for `logout`. Returns the ids
|
|
4373
|
+
* removed.
|
|
4374
|
+
*/
|
|
4375
|
+
async function removeUnlistedGraphCaches(env, serverBaseUrl, principalId, listed) {
|
|
4376
|
+
const keep = new Set([...listed].map((id) => id.toLowerCase()));
|
|
4377
|
+
const hostDir = dirname(graphCacheDir(env, serverBaseUrl, "x"));
|
|
4378
|
+
let entries;
|
|
4379
|
+
try {
|
|
4380
|
+
entries = await readdir(hostDir, { withFileTypes: true });
|
|
4381
|
+
} catch (error) {
|
|
4382
|
+
if (error.code === "ENOENT") return [];
|
|
4383
|
+
throw error;
|
|
4384
|
+
}
|
|
4385
|
+
const removed = [];
|
|
4386
|
+
for (const entry of entries) {
|
|
4387
|
+
if (!entry.isDirectory() || !GRAPH_ID.test(entry.name) || keep.has(entry.name.toLowerCase())) continue;
|
|
4388
|
+
const dir = join(hostDir, entry.name);
|
|
4389
|
+
if (await graphCacheOwner(dir) !== principalId) continue;
|
|
4390
|
+
await rm(dir, {
|
|
4391
|
+
recursive: true,
|
|
4392
|
+
force: true
|
|
4393
|
+
});
|
|
4394
|
+
removed.push(entry.name);
|
|
4395
|
+
}
|
|
4396
|
+
return removed;
|
|
4397
|
+
}
|
|
4244
4398
|
/** Remove one server's persisted graphs (logout of that server while others stay). */
|
|
4245
4399
|
async function removeServerCache(env, serverBaseUrl) {
|
|
4246
4400
|
await rm(dirname(graphCacheDir(env, serverBaseUrl, "x")), {
|
|
@@ -4730,6 +4884,20 @@ var entitlementLimitsSchema = z.strictObject({
|
|
|
4730
4884
|
assetBytes: z.int().nonnegative(),
|
|
4731
4885
|
assetChunks: z.int().nonnegative()
|
|
4732
4886
|
});
|
|
4887
|
+
/**
|
|
4888
|
+
* Set, and only ever `true`, while the Billing Account's subscription has a payment outstanding
|
|
4889
|
+
* (Stripe `past_due` or `unpaid`). It explains a `grace` or `read_only` status as a failed
|
|
4890
|
+
* payment rather than an ended plan, so the Client can say "fix your payment" instead of "your
|
|
4891
|
+
* subscription has ended". Issuers omit it otherwise, which keeps every other statement readable
|
|
4892
|
+
* by a Sync Server whose strict schema predates the field.
|
|
4893
|
+
*/
|
|
4894
|
+
var paymentOverdueSchema = z.literal(true).optional();
|
|
4895
|
+
/**
|
|
4896
|
+
* When the Billing Account's trial ends, set only while the trial runs, so the Client and the Sync
|
|
4897
|
+
* portal can say "Trial, ends 7 Oct" as the Billing page does. Omitted otherwise, as
|
|
4898
|
+
* `paymentOverdue` is.
|
|
4899
|
+
*/
|
|
4900
|
+
var trialEndsAtSchema = z.iso.datetime({ offset: true }).optional();
|
|
4733
4901
|
var serviceEntitlementSchema = z.strictObject({
|
|
4734
4902
|
eventId: z.uuid(),
|
|
4735
4903
|
issuer: httpsUrl,
|
|
@@ -4746,6 +4914,8 @@ var serviceEntitlementSchema = z.strictObject({
|
|
|
4746
4914
|
]),
|
|
4747
4915
|
plan: z.string().min(1).max(64),
|
|
4748
4916
|
limits: entitlementLimitsSchema,
|
|
4917
|
+
paymentOverdue: paymentOverdueSchema,
|
|
4918
|
+
trialEndsAt: trialEndsAtSchema,
|
|
4749
4919
|
effectiveAt: z.iso.datetime({ offset: true }),
|
|
4750
4920
|
expiresAt: z.iso.datetime({ offset: true })
|
|
4751
4921
|
}).refine(({ effectiveAt, expiresAt }) => Date.parse(expiresAt) > Date.parse(effectiveAt), {
|
|
@@ -4771,14 +4941,18 @@ z.strictObject({
|
|
|
4771
4941
|
id: z.uuid(),
|
|
4772
4942
|
email: z.email().nullable(),
|
|
4773
4943
|
name: z.string().min(1).nullable(),
|
|
4774
|
-
image: httpsUrl.nullable()
|
|
4944
|
+
image: httpsUrl.nullable(),
|
|
4945
|
+
emailVerified: z.boolean().optional()
|
|
4775
4946
|
}),
|
|
4776
4947
|
authentication: syncAuthenticationSchema,
|
|
4948
|
+
invitesNeedVerifiedEmail: z.boolean().optional(),
|
|
4777
4949
|
clientUrl: httpsUrl.optional(),
|
|
4778
4950
|
entitlement: z.strictObject({
|
|
4779
4951
|
plan: z.string().min(1).max(64),
|
|
4780
4952
|
status: serviceEntitlementSchema.shape.status,
|
|
4781
4953
|
limits: entitlementLimitsSchema,
|
|
4954
|
+
paymentOverdue: paymentOverdueSchema,
|
|
4955
|
+
trialEndsAt: trialEndsAtSchema,
|
|
4782
4956
|
usage: z.strictObject({
|
|
4783
4957
|
ownedGraphs: z.int().nonnegative(),
|
|
4784
4958
|
ownedStorageBytes: z.int().nonnegative()
|
|
@@ -4800,6 +4974,19 @@ var quotaErrorResponseSchema = z.strictObject({
|
|
|
4800
4974
|
ownerPrincipalId: z.uuid().optional(),
|
|
4801
4975
|
retryable: z.literal(true)
|
|
4802
4976
|
});
|
|
4977
|
+
z.strictObject({
|
|
4978
|
+
eventId: z.uuid(),
|
|
4979
|
+
issuer: httpsUrl,
|
|
4980
|
+
audience: z.literal("urn:etherpk:sync-identity"),
|
|
4981
|
+
subject: z.string().min(1).max(256),
|
|
4982
|
+
revision: z.int().positive(),
|
|
4983
|
+
disabled: z.boolean(),
|
|
4984
|
+
deleted: z.boolean(),
|
|
4985
|
+
credentialsRevokedAt: z.iso.datetime({ offset: true }).nullable(),
|
|
4986
|
+
email: z.email().optional(),
|
|
4987
|
+
emailVerified: z.boolean().optional(),
|
|
4988
|
+
issuedAt: z.iso.datetime({ offset: true })
|
|
4989
|
+
});
|
|
4803
4990
|
var SYNC_PROTOCOL_LIMITS = Object.freeze({
|
|
4804
4991
|
maxMessageBytes: 12 * 1024 * 1024,
|
|
4805
4992
|
maxEnvelopeBytes: 8 * 1024 * 1024,
|
|
@@ -5012,6 +5199,7 @@ function serverMessageSchema(limits) {
|
|
|
5012
5199
|
code: z.enum(SYNC_ERROR_CODES),
|
|
5013
5200
|
message: z.string().min(1).max(256),
|
|
5014
5201
|
docId: uuid.optional(),
|
|
5202
|
+
outboxId: uuid.optional(),
|
|
5015
5203
|
currentGeneration: generation.optional(),
|
|
5016
5204
|
quotaCode: quotaErrorCodeSchema.optional(),
|
|
5017
5205
|
retryable: z.boolean().optional()
|
|
@@ -5166,6 +5354,9 @@ var ASSET_CHUNK_PLAINTEXT_BYTES = 4 * 1024 * 1024;
|
|
|
5166
5354
|
function assetChunkCount(size) {
|
|
5167
5355
|
return Math.max(1, Math.ceil(size / ASSET_CHUNK_PLAINTEXT_BYTES));
|
|
5168
5356
|
}
|
|
5357
|
+
var DAY = 24 * (60 * 6e4);
|
|
5358
|
+
365 * DAY, 30 * DAY, 7 * DAY;
|
|
5359
|
+
Number.MAX_SAFE_INTEGER;
|
|
5169
5360
|
//#endregion
|
|
5170
5361
|
//#region ../client/src/lib/sync/state-vector.ts
|
|
5171
5362
|
/**
|
|
@@ -5207,6 +5398,10 @@ var REMOTE = Symbol("etherpk-remote");
|
|
|
5207
5398
|
var CACHE_SEED = Symbol("etherpk-cache-seed");
|
|
5208
5399
|
/** Structural lifecycle changes which must never be mistaken for a user's edit. */
|
|
5209
5400
|
var SUPPRESSED = Symbol("etherpk-suppressed");
|
|
5401
|
+
/** Health that says the bytes are not this document's, so neither text nor emptiness is real. */
|
|
5402
|
+
function contentBlocked(health) {
|
|
5403
|
+
return health === "key-unavailable" || health === "ciphertext-corrupt";
|
|
5404
|
+
}
|
|
5210
5405
|
function newSyncCompletion() {
|
|
5211
5406
|
let resolvePromise;
|
|
5212
5407
|
let rejectPromise;
|
|
@@ -5293,6 +5488,10 @@ function createDocSync(deps) {
|
|
|
5293
5488
|
let pendingSnapshot;
|
|
5294
5489
|
/** A read-back failed: the next idle uploads a fresh snapshot whatever the tail size. */
|
|
5295
5490
|
let snapshotRetryWanted = false;
|
|
5491
|
+
/** Tell the graph the queue changed; see `DocSyncDeps.onQueueChange`. */
|
|
5492
|
+
function queueChanged() {
|
|
5493
|
+
if (!destroyed) deps.onQueueChange?.();
|
|
5494
|
+
}
|
|
5296
5495
|
function requestCatchup(afterSeq) {
|
|
5297
5496
|
if (!catchupActive) {
|
|
5298
5497
|
if (catchupCompletion.settled) catchupCompletion = newSyncCompletion();
|
|
@@ -5379,6 +5578,7 @@ function createDocSync(deps) {
|
|
|
5379
5578
|
}
|
|
5380
5579
|
async function transmit(operation) {
|
|
5381
5580
|
await persistTask(() => persist.markAttempt(operation.outboxId, now()));
|
|
5581
|
+
if (durableQueue[0]?.outboxId !== operation.outboxId) return;
|
|
5382
5582
|
if (operation.kind === "delete") {
|
|
5383
5583
|
send({
|
|
5384
5584
|
type: "delete",
|
|
@@ -5439,6 +5639,7 @@ function createDocSync(deps) {
|
|
|
5439
5639
|
lastAttemptAt: null
|
|
5440
5640
|
};
|
|
5441
5641
|
durableQueue.push(durable);
|
|
5642
|
+
queueChanged();
|
|
5442
5643
|
if (operation.kind === "resurrect") lifecycleState = "resurrecting";
|
|
5443
5644
|
await transmit(durable);
|
|
5444
5645
|
}
|
|
@@ -5692,6 +5893,7 @@ function createDocSync(deps) {
|
|
|
5692
5893
|
baseline.destroy();
|
|
5693
5894
|
} else lastSyncedSnapshot = Y.snapshot(doc);
|
|
5694
5895
|
if (lifecycleState === "seeding") lifecycleState = "active";
|
|
5896
|
+
queueChanged();
|
|
5695
5897
|
},
|
|
5696
5898
|
async receive(message) {
|
|
5697
5899
|
switch (message.type) {
|
|
@@ -5727,6 +5929,7 @@ function createDocSync(deps) {
|
|
|
5727
5929
|
scheduleAutomaticCompaction();
|
|
5728
5930
|
}
|
|
5729
5931
|
durableQueue.shift();
|
|
5932
|
+
queueChanged();
|
|
5730
5933
|
send({
|
|
5731
5934
|
type: "ack_confirm",
|
|
5732
5935
|
outboxId: message.outboxId
|
|
@@ -5757,6 +5960,7 @@ function createDocSync(deps) {
|
|
|
5757
5960
|
}
|
|
5758
5961
|
await persist.purge();
|
|
5759
5962
|
durableQueue = [];
|
|
5963
|
+
queueChanged();
|
|
5760
5964
|
boundarySnapshots.clear();
|
|
5761
5965
|
recoveredDirtyTokens.clear();
|
|
5762
5966
|
activeDirtyToken = void 0;
|
|
@@ -5822,6 +6026,7 @@ function createDocSync(deps) {
|
|
|
5822
6026
|
seeding = false;
|
|
5823
6027
|
}
|
|
5824
6028
|
for (const operation of durableQueue.splice(0)) await persist.discard(operation.outboxId);
|
|
6029
|
+
queueChanged();
|
|
5825
6030
|
boundarySnapshots.clear();
|
|
5826
6031
|
lastSyncedSnapshot = Y.snapshot(doc);
|
|
5827
6032
|
const operation = {
|
|
@@ -5847,6 +6052,7 @@ function createDocSync(deps) {
|
|
|
5847
6052
|
attemptCount: 0,
|
|
5848
6053
|
lastAttemptAt: null
|
|
5849
6054
|
});
|
|
6055
|
+
queueChanged();
|
|
5850
6056
|
await transmit(durableQueue[0]);
|
|
5851
6057
|
},
|
|
5852
6058
|
lifecycle: () => lifecycleState,
|
|
@@ -5854,6 +6060,7 @@ function createDocSync(deps) {
|
|
|
5854
6060
|
async staleGeneration(currentGeneration) {
|
|
5855
6061
|
syncHealth = "generation-stale";
|
|
5856
6062
|
for (const operation of durableQueue.splice(0)) await persist.discard(operation.outboxId);
|
|
6063
|
+
queueChanged();
|
|
5857
6064
|
boundarySnapshots.clear();
|
|
5858
6065
|
documentGeneration = currentGeneration;
|
|
5859
6066
|
lastSeq = 0;
|
|
@@ -5874,6 +6081,12 @@ function createDocSync(deps) {
|
|
|
5874
6081
|
await sendAwareness([doc.clientID]);
|
|
5875
6082
|
},
|
|
5876
6083
|
isIdle: () => durableQueue.length === 0 && !debounceTimer && !drainPromise,
|
|
6084
|
+
unsentOperations: () => durableQueue.length,
|
|
6085
|
+
retryUnsent() {
|
|
6086
|
+
if (destroyed) return;
|
|
6087
|
+
if (durableQueue[0]) detached(transmit(durableQueue[0]));
|
|
6088
|
+
else detached(requestDrain());
|
|
6089
|
+
},
|
|
5877
6090
|
destroy() {
|
|
5878
6091
|
destroyed = true;
|
|
5879
6092
|
boundarySnapshots.clear();
|
|
@@ -6063,6 +6276,29 @@ function createPresenceSession(identity, options = {}) {
|
|
|
6063
6276
|
}
|
|
6064
6277
|
};
|
|
6065
6278
|
}
|
|
6279
|
+
var RECONNECT_CAP_MS = 3e4;
|
|
6280
|
+
/** The wait before reconnect attempt `attempt` (1 for the first retry). */
|
|
6281
|
+
function reconnectDelayMs(attempt, random = Math.random) {
|
|
6282
|
+
const exponent = Math.min(Math.max(0, attempt - 1), 16);
|
|
6283
|
+
const ceiling = Math.min(RECONNECT_CAP_MS, 500 * 2 ** exponent);
|
|
6284
|
+
return Math.floor(random() * ceiling);
|
|
6285
|
+
}
|
|
6286
|
+
/**
|
|
6287
|
+
* How long a graph waits before sending a write the Sync Server refused on a quota again: a
|
|
6288
|
+
* lapsed plan, a storage allowance, or a plan the server could not confirm. The refusal is an
|
|
6289
|
+
* answer, not an outage, so the schedule is slower than a reconnect's: the ceiling doubles from
|
|
6290
|
+
* ten seconds to two minutes. Half of it is fixed and half drawn below it, so a refused client
|
|
6291
|
+
* is never back within milliseconds and a crowd refused together spreads out. `round` counts
|
|
6292
|
+
* the retries already made for this refusal, from 1. The workspace also retries at once when the
|
|
6293
|
+
* person comes back to the tab, which is when a restarted plan usually shows.
|
|
6294
|
+
*/
|
|
6295
|
+
var REFUSAL_RETRY_BASE_MS = 1e4;
|
|
6296
|
+
var REFUSAL_RETRY_CAP_MS = 12e4;
|
|
6297
|
+
function refusalRetryDelayMs(round, random = Math.random) {
|
|
6298
|
+
const exponent = Math.min(Math.max(0, round - 1), 16);
|
|
6299
|
+
const ceiling = Math.min(REFUSAL_RETRY_CAP_MS, REFUSAL_RETRY_BASE_MS * 2 ** exponent);
|
|
6300
|
+
return Math.floor(ceiling / 2 + random() * (ceiling / 2));
|
|
6301
|
+
}
|
|
6066
6302
|
//#endregion
|
|
6067
6303
|
//#region ../client/src/lib/document/quick-notes.ts
|
|
6068
6304
|
/** A hard ceiling, so a corrupt or hostile list cannot swamp the View or the root doc. */
|
|
@@ -6475,16 +6711,65 @@ function graphThemeFromFiles(id, name, files, origin, now = /* @__PURE__ */ new
|
|
|
6475
6711
|
*
|
|
6476
6712
|
* The transport is injected (a `connect(url)` factory) so this unit-tests without real sockets.
|
|
6477
6713
|
*/
|
|
6478
|
-
/**
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6714
|
+
/** The HTTP status a token source's refusal carries (SyncApiError, ManagedTokenError), if any. */
|
|
6715
|
+
function refusalStatus(error) {
|
|
6716
|
+
const status = typeof error === "object" && error !== null ? error.status : void 0;
|
|
6717
|
+
return typeof status === "number" ? status : void 0;
|
|
6718
|
+
}
|
|
6719
|
+
/** How long activity changes are gathered before listeners hear them: an import acks thousands. */
|
|
6720
|
+
var ACTIVITY_COALESCE_MS = 50;
|
|
6482
6721
|
/** Internal signal: a watermark request is safe to repeat on the next socket generation. */
|
|
6483
6722
|
var WatermarkConnectionInterruptedError = class extends Error {};
|
|
6723
|
+
/**
|
|
6724
|
+
* The relay did not answer in time: the socket reads open, but nothing reaches the server, as on a
|
|
6725
|
+
* connection that dropped without closing. Named so a caller can say "can't reach the Sync Server"
|
|
6726
|
+
* rather than repeat the protocol's words.
|
|
6727
|
+
*/
|
|
6728
|
+
var RelayUnansweredError = class extends Error {};
|
|
6484
6729
|
function createGraphSync(deps) {
|
|
6485
6730
|
const engines = /* @__PURE__ */ new Map();
|
|
6486
6731
|
const retained = new Map([[deps.rootDocId, 1]]);
|
|
6487
6732
|
const retiring = /* @__PURE__ */ new Set();
|
|
6733
|
+
/**
|
|
6734
|
+
* Batch walks holding an engine: one count per `seedDocsFromCache` or `readyDocs` not yet
|
|
6735
|
+
* matched by a `retireDocs`. Walks overlap - a publish's read, the Local Mirror's pass and
|
|
6736
|
+
* the index's catch-up can all be over the same document - so a count decides retirement.
|
|
6737
|
+
* With a plain flag, the first walk to finish would retire the engine while another still
|
|
6738
|
+
* waited on its catch-up; the catch-up's completion would destroy it, the waiter's
|
|
6739
|
+
* `caughtUpDoc` would resolve, and the waiter would read the empty engine created in its
|
|
6740
|
+
* place (a cold `etherpk-mcp publish` would read every page as empty and "settled").
|
|
6741
|
+
*/
|
|
6742
|
+
const batchHolds = /* @__PURE__ */ new Map();
|
|
6743
|
+
function holdForBatch(docIds) {
|
|
6744
|
+
for (const docId of docIds) {
|
|
6745
|
+
if (docId === deps.rootDocId) continue;
|
|
6746
|
+
batchHolds.set(docId, (batchHolds.get(docId) ?? 0) + 1);
|
|
6747
|
+
}
|
|
6748
|
+
}
|
|
6749
|
+
/**
|
|
6750
|
+
* Wait for a held batch's cache reads. The holds stay taken whether or not they succeed: the
|
|
6751
|
+
* caller releases the batch it asked for, once, in its own `finally`. Releasing here as well
|
|
6752
|
+
* would release a failed batch twice, and the second release would take the hold of another
|
|
6753
|
+
* walk over the same document.
|
|
6754
|
+
*/
|
|
6755
|
+
async function awaitHeld(docIds) {
|
|
6756
|
+
await Promise.all(docIds.map((docId) => readied.get(docId) ?? Promise.resolve()));
|
|
6757
|
+
}
|
|
6758
|
+
/** One hold per document back; an engine no walk holds or retains is retired once idle. */
|
|
6759
|
+
function releaseBatch(docIds) {
|
|
6760
|
+
for (const docId of docIds) {
|
|
6761
|
+
if (docId === deps.rootDocId) continue;
|
|
6762
|
+
const holds = (batchHolds.get(docId) ?? 0) - 1;
|
|
6763
|
+
if (holds > 0) {
|
|
6764
|
+
batchHolds.set(docId, holds);
|
|
6765
|
+
continue;
|
|
6766
|
+
}
|
|
6767
|
+
batchHolds.delete(docId);
|
|
6768
|
+
if (retained.has(docId)) continue;
|
|
6769
|
+
retiring.add(docId);
|
|
6770
|
+
retireEngineIfIdle(docId);
|
|
6771
|
+
}
|
|
6772
|
+
}
|
|
6488
6773
|
const presenceSession = deps.presence ? createPresenceSession({
|
|
6489
6774
|
name: deps.presence.name,
|
|
6490
6775
|
color: deps.presence.color,
|
|
@@ -6496,6 +6781,42 @@ function createGraphSync(deps) {
|
|
|
6496
6781
|
let socket;
|
|
6497
6782
|
let open = false;
|
|
6498
6783
|
let disposed = false;
|
|
6784
|
+
/** Set once access has ended for good; nothing reconnects after it. */
|
|
6785
|
+
let lost = null;
|
|
6786
|
+
/** See `SyncActivity.connection`. */
|
|
6787
|
+
let connection = "connecting";
|
|
6788
|
+
/** Documents whose engines hold unacknowledged outbox operations, kept by `onQueueChange`. */
|
|
6789
|
+
const unsentDocs = /* @__PURE__ */ new Set();
|
|
6790
|
+
/**
|
|
6791
|
+
* Write refusals. The relay answers a refused append, delete or resurrect with `quota_denied`
|
|
6792
|
+
* naming the document and operation, and acknowledges nothing: the operation stays at the
|
|
6793
|
+
* head of that document's outbox, and without a retry the document would stay stalled until
|
|
6794
|
+
* a reload even after the allowance came back. `refusedDocs` are the documents to send
|
|
6795
|
+
* again; empty when a server too old to name them refused.
|
|
6796
|
+
*/
|
|
6797
|
+
let refusal = null;
|
|
6798
|
+
const refusedDocs = /* @__PURE__ */ new Set();
|
|
6799
|
+
/** Bumped by every refusal, so `awaitAcked` can tell one of its own writes from an older one. */
|
|
6800
|
+
let refusalCount = 0;
|
|
6801
|
+
let refusalRounds = 0;
|
|
6802
|
+
let refusalTimer;
|
|
6803
|
+
const refusalDelay = deps.refusalRetryDelayMs ?? ((round) => refusalRetryDelayMs(round));
|
|
6804
|
+
const activityListeners = /* @__PURE__ */ new Set();
|
|
6805
|
+
let activityTimer;
|
|
6806
|
+
const snapshotActivity = () => ({
|
|
6807
|
+
connection,
|
|
6808
|
+
unsentDocuments: unsentDocs.size,
|
|
6809
|
+
refusal
|
|
6810
|
+
});
|
|
6811
|
+
function activityChanged() {
|
|
6812
|
+
if (disposed || activityTimer || activityListeners.size === 0) return;
|
|
6813
|
+
activityTimer = setTimeout(() => {
|
|
6814
|
+
activityTimer = void 0;
|
|
6815
|
+
if (disposed) return;
|
|
6816
|
+
const current = snapshotActivity();
|
|
6817
|
+
for (const listener of activityListeners) listener(current);
|
|
6818
|
+
}, ACTIVITY_COALESCE_MS);
|
|
6819
|
+
}
|
|
6499
6820
|
/**
|
|
6500
6821
|
* Set when the Sync Server turned out to speak another protocol version. The session then
|
|
6501
6822
|
* stops for good: every reconnect would meet the same server, and nothing either side
|
|
@@ -6506,6 +6827,10 @@ function createGraphSync(deps) {
|
|
|
6506
6827
|
if (!disposed) deps.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
6507
6828
|
};
|
|
6508
6829
|
const outboundSnapshots = [];
|
|
6830
|
+
/** Documents whose operation this socket has sent and the relay has not answered, to its outbox id. */
|
|
6831
|
+
const operationsInFlight = /* @__PURE__ */ new Map();
|
|
6832
|
+
/** Each document's operation waiting for room in {@link OPERATION_WINDOW}, oldest first. */
|
|
6833
|
+
const operationsWaiting = /* @__PURE__ */ new Map();
|
|
6509
6834
|
const foregroundCatchups = [];
|
|
6510
6835
|
const backgroundCatchups = [];
|
|
6511
6836
|
let catchupInFlight;
|
|
@@ -6523,6 +6848,7 @@ function createGraphSync(deps) {
|
|
|
6523
6848
|
function waitForCurrentConnection() {
|
|
6524
6849
|
if (open && socket) return Promise.resolve();
|
|
6525
6850
|
if (disposed) return Promise.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
|
|
6851
|
+
if (lost) return Promise.reject(/* @__PURE__ */ new Error("access to this graph has ended"));
|
|
6526
6852
|
if (protocolMismatch) return Promise.reject(protocolMismatch);
|
|
6527
6853
|
return new Promise((resolve, reject) => {
|
|
6528
6854
|
connectionWaiters.add({
|
|
@@ -6541,7 +6867,7 @@ function createGraphSync(deps) {
|
|
|
6541
6867
|
const requestId = crypto.randomUUID();
|
|
6542
6868
|
const timer = setTimeout(() => {
|
|
6543
6869
|
watermarkRequests.delete(requestId);
|
|
6544
|
-
reject(
|
|
6870
|
+
reject(new RelayUnansweredError("the sync relay did not answer a watermark check"));
|
|
6545
6871
|
}, 15e3);
|
|
6546
6872
|
watermarkRequests.set(requestId, {
|
|
6547
6873
|
resolve,
|
|
@@ -6555,7 +6881,7 @@ function createGraphSync(deps) {
|
|
|
6555
6881
|
}));
|
|
6556
6882
|
});
|
|
6557
6883
|
} catch (error) {
|
|
6558
|
-
if (error instanceof WatermarkConnectionInterruptedError && !disposed) continue;
|
|
6884
|
+
if (error instanceof WatermarkConnectionInterruptedError && !disposed && !lost) continue;
|
|
6559
6885
|
throw error;
|
|
6560
6886
|
}
|
|
6561
6887
|
}
|
|
@@ -6617,6 +6943,37 @@ function createGraphSync(deps) {
|
|
|
6617
6943
|
pumpCatchups();
|
|
6618
6944
|
retireEngineIfIdle(completedDocId);
|
|
6619
6945
|
}
|
|
6946
|
+
/**
|
|
6947
|
+
* Send a document's operation within the window, or hold it until an answer makes room. A
|
|
6948
|
+
* document's newer operation replaces one still waiting (the engine sends only its head).
|
|
6949
|
+
*/
|
|
6950
|
+
function sendOperation(docId, message) {
|
|
6951
|
+
ackRoute.set(message.outboxId, docId);
|
|
6952
|
+
if (!open || !socket) return;
|
|
6953
|
+
if (!operationsInFlight.has(docId) && operationsInFlight.size >= 128) {
|
|
6954
|
+
operationsWaiting.set(docId, message);
|
|
6955
|
+
return;
|
|
6956
|
+
}
|
|
6957
|
+
operationsWaiting.delete(docId);
|
|
6958
|
+
operationsInFlight.set(docId, message.outboxId);
|
|
6959
|
+
sendNow(message);
|
|
6960
|
+
}
|
|
6961
|
+
/**
|
|
6962
|
+
* The relay answered `docId`'s operation, by acking or refusing it: its place in the window
|
|
6963
|
+
* goes to the longest-waiting one. `outboxId`, when the answer names one, must be the
|
|
6964
|
+
* operation in flight; an answer to an older one frees nothing.
|
|
6965
|
+
*/
|
|
6966
|
+
function operationAnswered(docId, outboxId) {
|
|
6967
|
+
const inFlight = operationsInFlight.get(docId);
|
|
6968
|
+
if (inFlight === void 0 || outboxId !== void 0 && inFlight !== outboxId) return;
|
|
6969
|
+
operationsInFlight.delete(docId);
|
|
6970
|
+
for (const [waitingDocId, waiting] of operationsWaiting) {
|
|
6971
|
+
if (!open || !socket || operationsInFlight.size >= 128) return;
|
|
6972
|
+
operationsWaiting.delete(waitingDocId);
|
|
6973
|
+
operationsInFlight.set(waitingDocId, waiting.outboxId);
|
|
6974
|
+
sendNow(waiting);
|
|
6975
|
+
}
|
|
6976
|
+
}
|
|
6620
6977
|
function rawSend(message) {
|
|
6621
6978
|
if (message.type === "catchup") {
|
|
6622
6979
|
queueCatchup(message);
|
|
@@ -6652,7 +7009,7 @@ function createGraphSync(deps) {
|
|
|
6652
7009
|
presenceDetach.delete(docId);
|
|
6653
7010
|
}
|
|
6654
7011
|
function retireEngineIfIdle(docId) {
|
|
6655
|
-
if (docId === deps.rootDocId || retained.has(docId) || !retiring.has(docId)) return;
|
|
7012
|
+
if (docId === deps.rootDocId || retained.has(docId) || batchHolds.has(docId) || !retiring.has(docId)) return;
|
|
6656
7013
|
if (catchupInFlight?.docId === docId || foregroundCatchups.some((request) => request.docId === docId) || backgroundCatchups.some((request) => request.docId === docId)) return;
|
|
6657
7014
|
const candidate = engines.get(docId);
|
|
6658
7015
|
if (!candidate || !candidate.isIdle()) return;
|
|
@@ -6660,6 +7017,7 @@ function createGraphSync(deps) {
|
|
|
6660
7017
|
detachPresence(docId);
|
|
6661
7018
|
candidate.destroy();
|
|
6662
7019
|
engines.delete(docId);
|
|
7020
|
+
unsentDocs.delete(docId);
|
|
6663
7021
|
readied.delete(docId);
|
|
6664
7022
|
syncEnabled.delete(docId);
|
|
6665
7023
|
performanceRecorder.mark("sync.engine.count", {
|
|
@@ -6667,6 +7025,69 @@ function createGraphSync(deps) {
|
|
|
6667
7025
|
subscriptions: retained.size
|
|
6668
7026
|
});
|
|
6669
7027
|
}
|
|
7028
|
+
function noteQueue(docId) {
|
|
7029
|
+
const e = engines.get(docId);
|
|
7030
|
+
if (e && e.unsentOperations() > 0) unsentDocs.add(docId);
|
|
7031
|
+
else unsentDocs.delete(docId);
|
|
7032
|
+
activityChanged();
|
|
7033
|
+
}
|
|
7034
|
+
/**
|
|
7035
|
+
* The relay refused a write on a quota. A refused snapshot is not a stalled write - the relay
|
|
7036
|
+
* still holds the document's log, and the engine uploads another at a later idle - so only a
|
|
7037
|
+
* refused outbox operation (named by its outbox id), or a refusal a server too old to name
|
|
7038
|
+
* anything sent, counts.
|
|
7039
|
+
*/
|
|
7040
|
+
function noteRefusal(message) {
|
|
7041
|
+
if (message.docId && !message.outboxId) return;
|
|
7042
|
+
if (message.docId) refusedDocs.add(message.docId);
|
|
7043
|
+
refusal = message.quotaCode ? { quotaCode: message.quotaCode } : {};
|
|
7044
|
+
refusalCount += 1;
|
|
7045
|
+
scheduleRefusalRetry();
|
|
7046
|
+
activityChanged();
|
|
7047
|
+
}
|
|
7048
|
+
function scheduleRefusalRetry() {
|
|
7049
|
+
if (refusalTimer || disposed || lost) return;
|
|
7050
|
+
refusalTimer = setTimeout(() => {
|
|
7051
|
+
refusalTimer = void 0;
|
|
7052
|
+
refusalRounds += 1;
|
|
7053
|
+
resendRefused();
|
|
7054
|
+
}, refusalDelay(refusalRounds + 1));
|
|
7055
|
+
}
|
|
7056
|
+
/** Send each refused document's head operation again; the relay applies an outbox id once. */
|
|
7057
|
+
function resendRefused() {
|
|
7058
|
+
if (disposed || lost || !open) return;
|
|
7059
|
+
const docIds = refusedDocs.size > 0 ? [...refusedDocs] : [...unsentDocs];
|
|
7060
|
+
for (const docId of docIds) {
|
|
7061
|
+
const e = engines.get(docId);
|
|
7062
|
+
if (!e || e.unsentOperations() === 0) {
|
|
7063
|
+
refusedDocs.delete(docId);
|
|
7064
|
+
continue;
|
|
7065
|
+
}
|
|
7066
|
+
e.retryUnsent();
|
|
7067
|
+
}
|
|
7068
|
+
if (refusedDocs.size === 0 && unsentDocs.size === 0) clearRefusal();
|
|
7069
|
+
}
|
|
7070
|
+
/** Whether every document with unsent operations is one the server refused. */
|
|
7071
|
+
function everyUnsentRefused() {
|
|
7072
|
+
if (unsentDocs.size === 0) return false;
|
|
7073
|
+
if (refusedDocs.size === 0) return true;
|
|
7074
|
+
for (const docId of unsentDocs) if (!refusedDocs.has(docId)) return false;
|
|
7075
|
+
return true;
|
|
7076
|
+
}
|
|
7077
|
+
/** An ack: the server accepted a write, so that document is refused no longer. */
|
|
7078
|
+
function writeAccepted(docId) {
|
|
7079
|
+
if (!refusal) return;
|
|
7080
|
+
refusedDocs.delete(docId);
|
|
7081
|
+
if (refusedDocs.size === 0) clearRefusal();
|
|
7082
|
+
}
|
|
7083
|
+
function clearRefusal() {
|
|
7084
|
+
refusal = null;
|
|
7085
|
+
refusedDocs.clear();
|
|
7086
|
+
refusalRounds = 0;
|
|
7087
|
+
clearTimeout(refusalTimer);
|
|
7088
|
+
refusalTimer = void 0;
|
|
7089
|
+
activityChanged();
|
|
7090
|
+
}
|
|
6670
7091
|
function engine(docId, synchronize = true) {
|
|
6671
7092
|
let e = engines.get(docId);
|
|
6672
7093
|
if (!e) {
|
|
@@ -6675,7 +7096,10 @@ function createGraphSync(deps) {
|
|
|
6675
7096
|
graphId: deps.graphId,
|
|
6676
7097
|
keyring: deps.keyring,
|
|
6677
7098
|
send: (message) => {
|
|
6678
|
-
if (message.type === "append" || message.type === "delete" || message.type === "resurrect")
|
|
7099
|
+
if (message.type === "append" || message.type === "delete" || message.type === "resurrect") {
|
|
7100
|
+
sendOperation(docId, message);
|
|
7101
|
+
return;
|
|
7102
|
+
}
|
|
6679
7103
|
rawSend(message);
|
|
6680
7104
|
},
|
|
6681
7105
|
persist: deps.cache.docCache(docId, docId === deps.rootDocId ? "root" : "document"),
|
|
@@ -6683,6 +7107,7 @@ function createGraphSync(deps) {
|
|
|
6683
7107
|
collectRowWhen: docId === deps.rootDocId ? void 0 : deps.collectRowWhen,
|
|
6684
7108
|
onError: reportError,
|
|
6685
7109
|
onIdle: () => queueMicrotask(() => retireEngineIfIdle(docId)),
|
|
7110
|
+
onQueueChange: () => noteQueue(docId),
|
|
6686
7111
|
catchupPriority: () => {
|
|
6687
7112
|
return retained.has(docId) ? "foreground" : "background";
|
|
6688
7113
|
}
|
|
@@ -6779,6 +7204,11 @@ function createGraphSync(deps) {
|
|
|
6779
7204
|
function stopForProtocolMismatch(serverVersion) {
|
|
6780
7205
|
if (protocolMismatch) return;
|
|
6781
7206
|
protocolMismatch = new SyncProtocolMismatchError(serverVersion);
|
|
7207
|
+
connection = "ended";
|
|
7208
|
+
clearTimeout(reconnectTimer);
|
|
7209
|
+
clearTimeout(refusalTimer);
|
|
7210
|
+
refusalTimer = void 0;
|
|
7211
|
+
activityChanged();
|
|
6782
7212
|
reportError(protocolMismatch);
|
|
6783
7213
|
for (const waiter of connectionWaiters) waiter.reject(protocolMismatch);
|
|
6784
7214
|
connectionWaiters.clear();
|
|
@@ -6798,8 +7228,18 @@ function createGraphSync(deps) {
|
|
|
6798
7228
|
continuation: message.hasMore ? 1 : 0
|
|
6799
7229
|
});
|
|
6800
7230
|
if (message.type === "error") {
|
|
7231
|
+
if (message.code === "membership_revoked") {
|
|
7232
|
+
endAccess({ kind: "membership" });
|
|
7233
|
+
return;
|
|
7234
|
+
}
|
|
7235
|
+
if (message.code === "quota_denied") {
|
|
7236
|
+
if (message.docId) operationAnswered(message.docId, message.outboxId);
|
|
7237
|
+
noteRefusal(message);
|
|
7238
|
+
return;
|
|
7239
|
+
}
|
|
6801
7240
|
if (message.code === "stale_generation" && message.docId && message.currentGeneration !== void 0) {
|
|
6802
7241
|
if (catchupInFlight?.docId === message.docId) catchupInFlight = void 0;
|
|
7242
|
+
operationAnswered(message.docId);
|
|
6803
7243
|
engine(message.docId).staleGeneration(message.currentGeneration);
|
|
6804
7244
|
pumpCatchups();
|
|
6805
7245
|
}
|
|
@@ -6821,7 +7261,13 @@ function createGraphSync(deps) {
|
|
|
6821
7261
|
}
|
|
6822
7262
|
if (message.type === "ack") {
|
|
6823
7263
|
const docId = ackRoute.get(message.outboxId);
|
|
6824
|
-
if (docId)
|
|
7264
|
+
if (docId) {
|
|
7265
|
+
operationAnswered(docId, message.outboxId);
|
|
7266
|
+
engine(docId).receive(message).then(() => {
|
|
7267
|
+
ackRoute.delete(message.outboxId);
|
|
7268
|
+
writeAccepted(docId);
|
|
7269
|
+
}).catch(report);
|
|
7270
|
+
}
|
|
6825
7271
|
return;
|
|
6826
7272
|
}
|
|
6827
7273
|
if (message.type === "catchup_batch") {
|
|
@@ -6831,20 +7277,78 @@ function createGraphSync(deps) {
|
|
|
6831
7277
|
engine(message.docId).receive(message).catch(report);
|
|
6832
7278
|
}
|
|
6833
7279
|
/**
|
|
7280
|
+
* Forget everything that belonged to one socket generation. Durable IndexedDB rows, not these
|
|
7281
|
+
* entries, determine outstanding work and replay on the next open, which resubscribes every
|
|
7282
|
+
* retained document.
|
|
7283
|
+
*/
|
|
7284
|
+
function forgetSocketGeneration() {
|
|
7285
|
+
subscribed.clear();
|
|
7286
|
+
ackRoute.clear();
|
|
7287
|
+
operationsInFlight.clear();
|
|
7288
|
+
operationsWaiting.clear();
|
|
7289
|
+
catchupInFlight = void 0;
|
|
7290
|
+
foregroundCatchups.length = 0;
|
|
7291
|
+
backgroundCatchups.length = 0;
|
|
7292
|
+
for (const pending of watermarkRequests.values()) {
|
|
7293
|
+
clearTimeout(pending.timer);
|
|
7294
|
+
pending.reject(new WatermarkConnectionInterruptedError());
|
|
7295
|
+
}
|
|
7296
|
+
watermarkRequests.clear();
|
|
7297
|
+
}
|
|
7298
|
+
let attempts = 0;
|
|
7299
|
+
let openedAt = 0;
|
|
7300
|
+
let forceToken = false;
|
|
7301
|
+
let credentialRefusals = 0;
|
|
7302
|
+
let reconnectTimer;
|
|
7303
|
+
const retryDelay = deps.retryDelayMs ?? ((attempt) => reconnectDelayMs(attempt));
|
|
7304
|
+
function scheduleConnect(delayMs) {
|
|
7305
|
+
if (disposed || lost || protocolMismatch) return;
|
|
7306
|
+
clearTimeout(reconnectTimer);
|
|
7307
|
+
reconnectTimer = setTimeout(connect, delayMs);
|
|
7308
|
+
}
|
|
7309
|
+
function backOff() {
|
|
7310
|
+
attempts += 1;
|
|
7311
|
+
scheduleConnect(retryDelay(attempts));
|
|
7312
|
+
}
|
|
7313
|
+
/** Stop for good: close the socket, never reconnect, and tell the owner once. */
|
|
7314
|
+
function endAccess(loss) {
|
|
7315
|
+
if (lost || disposed) return;
|
|
7316
|
+
lost = loss;
|
|
7317
|
+
connection = "ended";
|
|
7318
|
+
clearTimeout(reconnectTimer);
|
|
7319
|
+
clearTimeout(refusalTimer);
|
|
7320
|
+
refusalTimer = void 0;
|
|
7321
|
+
activityChanged();
|
|
7322
|
+
const current = socket;
|
|
7323
|
+
socket = void 0;
|
|
7324
|
+
open = false;
|
|
7325
|
+
forgetSocketGeneration();
|
|
7326
|
+
current?.close();
|
|
7327
|
+
for (const waiter of connectionWaiters) waiter.reject(/* @__PURE__ */ new Error("access to this graph has ended"));
|
|
7328
|
+
connectionWaiters.clear();
|
|
7329
|
+
deps.onAccessLost?.(loss);
|
|
7330
|
+
}
|
|
7331
|
+
/**
|
|
6834
7332
|
* A token is fetched for EVERY connect, never captured: the source re-mints when the
|
|
6835
|
-
* held token nears expiry, so a socket that drops an hour in still reconnects.
|
|
7333
|
+
* held token nears expiry, so a socket that drops an hour in still reconnects.
|
|
6836
7334
|
* Encrypted appends issued while the token is in flight remain in IndexedDB and replay
|
|
6837
7335
|
* on open. Only rebuildable snapshot uploads use a volatile queue.
|
|
6838
7336
|
*/
|
|
6839
7337
|
function connect() {
|
|
6840
|
-
if (disposed || protocolMismatch) return;
|
|
6841
|
-
|
|
6842
|
-
|
|
7338
|
+
if (disposed || lost || protocolMismatch) return;
|
|
7339
|
+
const force = forceToken;
|
|
7340
|
+
forceToken = false;
|
|
7341
|
+
deps.token(force ? { force: true } : void 0).then((token) => {
|
|
7342
|
+
if (disposed || lost) return;
|
|
6843
7343
|
const s = deps.connect(`${deps.relayUrl}?token=${encodeURIComponent(token)}`);
|
|
6844
7344
|
socket = s;
|
|
6845
7345
|
s.onOpen(() => {
|
|
6846
|
-
if (disposed || socket !== s) return;
|
|
7346
|
+
if (disposed || lost || socket !== s) return;
|
|
6847
7347
|
open = true;
|
|
7348
|
+
connection = "open";
|
|
7349
|
+
activityChanged();
|
|
7350
|
+
openedAt = Date.now();
|
|
7351
|
+
credentialRefusals = 0;
|
|
6848
7352
|
resolveFirstOpen?.();
|
|
6849
7353
|
for (const waiter of connectionWaiters) waiter.resolve();
|
|
6850
7354
|
connectionWaiters.clear();
|
|
@@ -6865,24 +7369,45 @@ function createGraphSync(deps) {
|
|
|
6865
7369
|
}
|
|
6866
7370
|
});
|
|
6867
7371
|
s.onMessage(handleMessage);
|
|
6868
|
-
s.onClose(() => {
|
|
7372
|
+
s.onClose((event) => {
|
|
6869
7373
|
if (socket !== s) return;
|
|
7374
|
+
const upFor = open ? Date.now() - openedAt : 0;
|
|
6870
7375
|
open = false;
|
|
6871
7376
|
socket = void 0;
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
7377
|
+
forgetSocketGeneration();
|
|
7378
|
+
if (disposed || lost || protocolMismatch) return;
|
|
7379
|
+
connection = "reconnecting";
|
|
7380
|
+
activityChanged();
|
|
7381
|
+
if (event?.code === 4403) {
|
|
7382
|
+
endAccess({ kind: "membership" });
|
|
7383
|
+
return;
|
|
7384
|
+
}
|
|
7385
|
+
if (upFor >= 1e4) attempts = 0;
|
|
7386
|
+
if (event?.code === 4401) {
|
|
7387
|
+
forceToken = true;
|
|
7388
|
+
credentialRefusals += 1;
|
|
7389
|
+
if (credentialRefusals === 1) {
|
|
7390
|
+
scheduleConnect(0);
|
|
7391
|
+
return;
|
|
7392
|
+
}
|
|
6880
7393
|
}
|
|
6881
|
-
|
|
6882
|
-
if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
|
|
7394
|
+
backOff();
|
|
6883
7395
|
});
|
|
6884
|
-
}, () => {
|
|
6885
|
-
if (
|
|
7396
|
+
}, (error) => {
|
|
7397
|
+
if (disposed || lost) return;
|
|
7398
|
+
const status = refusalStatus(error);
|
|
7399
|
+
if (status === 401) {
|
|
7400
|
+
endAccess({
|
|
7401
|
+
kind: "credentials",
|
|
7402
|
+
cause: error
|
|
7403
|
+
});
|
|
7404
|
+
return;
|
|
7405
|
+
}
|
|
7406
|
+
if (status === 403) {
|
|
7407
|
+
endAccess({ kind: "membership" });
|
|
7408
|
+
return;
|
|
7409
|
+
}
|
|
7410
|
+
backOff();
|
|
6886
7411
|
});
|
|
6887
7412
|
}
|
|
6888
7413
|
connect();
|
|
@@ -7061,21 +7586,17 @@ function createGraphSync(deps) {
|
|
|
7061
7586
|
await readied.get(docId);
|
|
7062
7587
|
},
|
|
7063
7588
|
async seedDocsFromCache(docIds) {
|
|
7589
|
+
holdForBatch(docIds);
|
|
7064
7590
|
const batch = docIds.map((docId) => engine(docId, false));
|
|
7065
|
-
await
|
|
7591
|
+
await awaitHeld(docIds);
|
|
7066
7592
|
return batch.map((entry) => entry.doc);
|
|
7067
7593
|
},
|
|
7068
7594
|
async readyDocs(docIds) {
|
|
7595
|
+
holdForBatch(docIds);
|
|
7069
7596
|
for (const docId of docIds) engine(docId);
|
|
7070
|
-
await
|
|
7071
|
-
},
|
|
7072
|
-
retireDocs(docIds) {
|
|
7073
|
-
for (const docId of docIds) {
|
|
7074
|
-
if (docId === deps.rootDocId || retained.has(docId)) continue;
|
|
7075
|
-
retiring.add(docId);
|
|
7076
|
-
retireEngineIfIdle(docId);
|
|
7077
|
-
}
|
|
7597
|
+
await awaitHeld(docIds);
|
|
7078
7598
|
},
|
|
7599
|
+
retireDocs: releaseBatch,
|
|
7079
7600
|
firstCatchupPageDoc: (docId) => engine(docId).firstCatchupPage(),
|
|
7080
7601
|
caughtUpDoc: (docId) => engine(docId).caughtUp(),
|
|
7081
7602
|
async docsNeedingCatchup(docIds) {
|
|
@@ -7102,12 +7623,24 @@ function createGraphSync(deps) {
|
|
|
7102
7623
|
};
|
|
7103
7624
|
},
|
|
7104
7625
|
isConnected: () => open,
|
|
7626
|
+
activity: snapshotActivity,
|
|
7627
|
+
onActivity(listener) {
|
|
7628
|
+
activityListeners.add(listener);
|
|
7629
|
+
return () => activityListeners.delete(listener);
|
|
7630
|
+
},
|
|
7631
|
+
retryRefused() {
|
|
7632
|
+
if (!refusal) return;
|
|
7633
|
+
clearTimeout(refusalTimer);
|
|
7634
|
+
refusalTimer = void 0;
|
|
7635
|
+
resendRefused();
|
|
7636
|
+
},
|
|
7105
7637
|
async flushAll({ onProgress } = {}) {
|
|
7106
7638
|
const all = [...engines.values()];
|
|
7107
7639
|
let flushed = 0;
|
|
7108
7640
|
await Promise.all(all.map((e) => e.flush().then(() => onProgress?.(++flushed, all.length))));
|
|
7109
7641
|
},
|
|
7110
7642
|
async awaitAcked({ onProgress, signal, stallMs = 3e4 } = {}) {
|
|
7643
|
+
const refusalsBefore = refusalCount;
|
|
7111
7644
|
const initial = await deps.cache.countPending();
|
|
7112
7645
|
if (initial === 0) return {
|
|
7113
7646
|
settled: true,
|
|
@@ -7135,13 +7668,19 @@ function createGraphSync(deps) {
|
|
|
7135
7668
|
settled: true,
|
|
7136
7669
|
outstanding: 0
|
|
7137
7670
|
});
|
|
7671
|
+
if (refusal && (refusalCount > refusalsBefore || everyUnsentRefused())) return finish({
|
|
7672
|
+
settled: false,
|
|
7673
|
+
outstanding: current,
|
|
7674
|
+
refused: refusal
|
|
7675
|
+
});
|
|
7138
7676
|
if (signal?.aborted) return finish({
|
|
7139
7677
|
settled: false,
|
|
7140
7678
|
outstanding: current
|
|
7141
7679
|
});
|
|
7142
7680
|
if (Date.now() - lastProgressAt >= stallMs) return finish({
|
|
7143
7681
|
settled: false,
|
|
7144
|
-
outstanding: current
|
|
7682
|
+
outstanding: current,
|
|
7683
|
+
...refusal ? { refused: refusal } : {}
|
|
7145
7684
|
});
|
|
7146
7685
|
timer = setTimeout(poll, POLL_MS);
|
|
7147
7686
|
} catch (error) {
|
|
@@ -7159,14 +7698,21 @@ function createGraphSync(deps) {
|
|
|
7159
7698
|
activeEngines: engines.size,
|
|
7160
7699
|
retainedDocuments: retained.size
|
|
7161
7700
|
}),
|
|
7701
|
+
endAccess,
|
|
7702
|
+
accessLoss: () => lost,
|
|
7162
7703
|
dispose() {
|
|
7163
7704
|
disposed = true;
|
|
7705
|
+
clearTimeout(reconnectTimer);
|
|
7706
|
+
clearTimeout(refusalTimer);
|
|
7707
|
+
clearTimeout(activityTimer);
|
|
7708
|
+
activityListeners.clear();
|
|
7164
7709
|
socket?.close();
|
|
7165
7710
|
for (const detach of presenceDetach.values()) detach();
|
|
7166
7711
|
presenceDetach.clear();
|
|
7167
7712
|
for (const e of engines.values()) e.destroy();
|
|
7168
7713
|
engines.clear();
|
|
7169
7714
|
retiring.clear();
|
|
7715
|
+
batchHolds.clear();
|
|
7170
7716
|
syncEnabled.clear();
|
|
7171
7717
|
foregroundCatchups.length = 0;
|
|
7172
7718
|
backgroundCatchups.length = 0;
|
|
@@ -7646,7 +8192,10 @@ function nodeTransport(url) {
|
|
|
7646
8192
|
close: () => ws.close(),
|
|
7647
8193
|
onOpen: (cb) => ws.addEventListener("open", () => cb()),
|
|
7648
8194
|
onMessage: (cb) => ws.addEventListener("message", (e) => cb(String(e.data))),
|
|
7649
|
-
onClose: (cb) => ws.addEventListener("close", () => cb(
|
|
8195
|
+
onClose: (cb) => ws.addEventListener("close", (event) => cb({
|
|
8196
|
+
code: event.code,
|
|
8197
|
+
reason: event.reason
|
|
8198
|
+
}))
|
|
7650
8199
|
};
|
|
7651
8200
|
}
|
|
7652
8201
|
//#endregion
|
|
@@ -9003,6 +9552,32 @@ function assetHashFromName(fileName) {
|
|
|
9003
9552
|
ext: (match[3] ?? "").toLowerCase()
|
|
9004
9553
|
};
|
|
9005
9554
|
}
|
|
9555
|
+
/**
|
|
9556
|
+
* Whether a decoded asset name is one file inside `assets/`, and so safe to join onto a folder.
|
|
9557
|
+
*
|
|
9558
|
+
* An asset reference comes from a document, and a document can come from a collaborator, an
|
|
9559
|
+
* import or an agent, so the name it decodes to is untrusted: `../assets/..%2Fpages%2FSecret.md`
|
|
9560
|
+
* decodes to a path that leaves `assets/`. Every place that turns a reference into a file path
|
|
9561
|
+
* (the asset stores, the publisher's bundle) checks this first, before any directory adapter
|
|
9562
|
+
* or site writer sees the name.
|
|
9563
|
+
*/
|
|
9564
|
+
function isSafeAssetName(name) {
|
|
9565
|
+
if (!isSingleFileName(name)) return false;
|
|
9566
|
+
for (const char of name) {
|
|
9567
|
+
const code = char.codePointAt(0) ?? 0;
|
|
9568
|
+
if (code < 32 || code === 127) return false;
|
|
9569
|
+
}
|
|
9570
|
+
return true;
|
|
9571
|
+
}
|
|
9572
|
+
/**
|
|
9573
|
+
* Whether `name` is one entry of a directory rather than a path: not empty, not `.` or `..`, and
|
|
9574
|
+
* free of separators and NUL. What a directory adapter requires of every name it is handed, which
|
|
9575
|
+
* is looser than {@link isSafeAssetName}: a file already on disk may carry other odd characters.
|
|
9576
|
+
*/
|
|
9577
|
+
function isSingleFileName(name) {
|
|
9578
|
+
if (name === "" || name === "." || name === "..") return false;
|
|
9579
|
+
return !name.includes("/") && !name.includes("\\") && !name.includes("\0");
|
|
9580
|
+
}
|
|
9006
9581
|
//#endregion
|
|
9007
9582
|
//#region ../client/src/lib/storage/fs/asset-store.ts
|
|
9008
9583
|
/**
|
|
@@ -9036,6 +9611,20 @@ var IMAGE_EXTS = new Set([
|
|
|
9036
9611
|
]);
|
|
9037
9612
|
/** A doc-relative asset reference: optional `../` hops, then `assets/<name>`. */
|
|
9038
9613
|
var ASSET_REF$1 = /^(?:\.\.\/)*assets\/(.+)$/;
|
|
9614
|
+
/**
|
|
9615
|
+
* An asset could not be fetched just now: the connection failed, or the server answered with a
|
|
9616
|
+
* status that means "try again". It says nothing about whether the file exists, so something
|
|
9617
|
+
* showing the file asks again rather than calling it missing. `status` is the server's answer,
|
|
9618
|
+
* absent when the connection failed before there was one.
|
|
9619
|
+
*/
|
|
9620
|
+
var AssetUnavailableError = class extends Error {
|
|
9621
|
+
name = "AssetUnavailableError";
|
|
9622
|
+
status;
|
|
9623
|
+
constructor(message, options = {}) {
|
|
9624
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
9625
|
+
this.status = options.status;
|
|
9626
|
+
}
|
|
9627
|
+
};
|
|
9039
9628
|
/** Split a file name into its stem and lower-cased extension (`''` ext when there is none). */
|
|
9040
9629
|
function splitNameExt(fileName) {
|
|
9041
9630
|
const dot = fileName.lastIndexOf(".");
|
|
@@ -9103,11 +9692,22 @@ var MIME_TYPES = {
|
|
|
9103
9692
|
function mimeTypeForExt(ext) {
|
|
9104
9693
|
return MIME_TYPES[ext.replace(/^\./, "").toLowerCase()] ?? "";
|
|
9105
9694
|
}
|
|
9106
|
-
/**
|
|
9695
|
+
/**
|
|
9696
|
+
* The on-disk name referenced by a doc-relative asset ref, or `null` if `ref` is not an asset
|
|
9697
|
+
* reference. A reference whose name decodes to anything but one file inside `assets/`, or does not
|
|
9698
|
+
* decode at all, is not one: it can never name a file on disk (see `isSafeAssetName`).
|
|
9699
|
+
*/
|
|
9107
9700
|
function assetNameFromRef(ref) {
|
|
9108
9701
|
const clean = ref.split(/[?#]/)[0];
|
|
9109
9702
|
const match = ASSET_REF$1.exec(clean);
|
|
9110
|
-
|
|
9703
|
+
if (!match) return null;
|
|
9704
|
+
let name;
|
|
9705
|
+
try {
|
|
9706
|
+
name = decodeURIComponent(match[1]);
|
|
9707
|
+
} catch {
|
|
9708
|
+
return null;
|
|
9709
|
+
}
|
|
9710
|
+
return isSafeAssetName(name) ? name : null;
|
|
9111
9711
|
}
|
|
9112
9712
|
/**
|
|
9113
9713
|
* The markdown to embed a saved asset: an inline image, or a plain link that downloads on click.
|
|
@@ -9464,6 +10064,25 @@ async function deleteGraphTheme(adapter, id) {
|
|
|
9464
10064
|
* Pure: no store, no editor. The YAML rules (what counts as a block, how it parses) come from
|
|
9465
10065
|
* `storage/fs`, shared with the scan and the editor's analysis, so there is one rule.
|
|
9466
10066
|
*/
|
|
10067
|
+
/** What the block claims. An unterminated block is not a block, as everywhere else. */
|
|
10068
|
+
function frontmatterIdentity(text) {
|
|
10069
|
+
const span = frontmatterSpan(text);
|
|
10070
|
+
const data = span ? parseBlock$2(span.body) : {};
|
|
10071
|
+
if (data === null) return {
|
|
10072
|
+
title: null,
|
|
10073
|
+
aliases: [],
|
|
10074
|
+
hasAliasesKey: false,
|
|
10075
|
+
hasBlock: true,
|
|
10076
|
+
readable: false
|
|
10077
|
+
};
|
|
10078
|
+
return {
|
|
10079
|
+
title: titleOf(data),
|
|
10080
|
+
aliases: aliasesOf({ data }),
|
|
10081
|
+
hasAliasesKey: "aliases" in data,
|
|
10082
|
+
hasBlock: span !== null,
|
|
10083
|
+
readable: true
|
|
10084
|
+
};
|
|
10085
|
+
}
|
|
9467
10086
|
function titleOf(data) {
|
|
9468
10087
|
const title = data.title;
|
|
9469
10088
|
return typeof title === "string" && title.trim() !== "" ? title : null;
|
|
@@ -9532,6 +10151,19 @@ function withFrontmatterIdentity(text, patch, options = {}) {
|
|
|
9532
10151
|
if (wantAliases.length > 0 && !("aliases" in data)) next.aliases = wantAliases;
|
|
9533
10152
|
return `---\n${serialise(next)}---\n${text.slice(span.end)}`;
|
|
9534
10153
|
}
|
|
10154
|
+
/**
|
|
10155
|
+
* `after` - a writer's rewrite of `before` - with the document's aliases carried into the block
|
|
10156
|
+
* the rewrite added. A writer that gives a document its first block, as Publish does when it adds
|
|
10157
|
+
* `public:`, must not make the document claim fewer aliases than it has: a block with no
|
|
10158
|
+
* `aliases:` line claims none, and the next edit to it would clear them (ADR 0061). Only a Server
|
|
10159
|
+
* Backend has aliases without a block; on a Filesystem Backend they are read from the saved file,
|
|
10160
|
+
* so `aliases` is empty there provided the caller wrote pending edits before reading them
|
|
10161
|
+
* (`rewriteFrontmatter` flushes first). A block `before` already had is left as the writer left it.
|
|
10162
|
+
*/
|
|
10163
|
+
function withAliasesInAddedBlock(before, after, aliases) {
|
|
10164
|
+
if (aliases.length === 0 || frontmatterSpan(before) !== null || frontmatterSpan(after) === null) return after;
|
|
10165
|
+
return withFrontmatterIdentity(after, { aliases });
|
|
10166
|
+
}
|
|
9535
10167
|
/** The block's YAML as a plain object, or null when it is malformed or not an object. */
|
|
9536
10168
|
function parseBlock$2(yaml) {
|
|
9537
10169
|
try {
|
|
@@ -9979,6 +10611,31 @@ async function refuseProtectedMerges(plan, isProtected) {
|
|
|
9979
10611
|
return plan;
|
|
9980
10612
|
}
|
|
9981
10613
|
/**
|
|
10614
|
+
* Refuse a plan whose steps would rewrite a [[Frontmatter]] block that does not parse. A
|
|
10615
|
+
* Filesystem Backend rebuilds each renamed document's block, and a merge survivor's, from its
|
|
10616
|
+
* parsed data, which is empty for such a block, so every key it holds would be lost from the file:
|
|
10617
|
+
* the only copy. `textOf` gives a document's current text (an open buffer, else its file), or
|
|
10618
|
+
* null for a name with no document behind it.
|
|
10619
|
+
*/
|
|
10620
|
+
async function refuseUnreadableBlocks(plan, textOf) {
|
|
10621
|
+
if (plan.refusal) return plan;
|
|
10622
|
+
for (const step of renameSteps(plan)) for (const concept of step.merges ? [step.from, step.into] : [step.from]) {
|
|
10623
|
+
const text = await textOf(concept);
|
|
10624
|
+
if (text === null || frontmatterIdentity(text).readable) continue;
|
|
10625
|
+
return {
|
|
10626
|
+
...plan,
|
|
10627
|
+
refusal: unreadableBlockRefusal(plan.direct.from, concept)
|
|
10628
|
+
};
|
|
10629
|
+
}
|
|
10630
|
+
return plan;
|
|
10631
|
+
}
|
|
10632
|
+
/** Why `renamed` cannot be renamed: the block of `unreadable` (itself, or a page the rename rewrites) does not parse. */
|
|
10633
|
+
function unreadableBlockRefusal(renamed, unreadable) {
|
|
10634
|
+
const fix = "Fix the block (a property written twice, or a line left half typed) and rename again.";
|
|
10635
|
+
if (conceptKey(renamed) === conceptKey(unreadable)) return `“${renamed}” cannot be renamed while its frontmatter is not valid YAML: renaming rewrites the block, and what it holds would be lost. ${fix}`;
|
|
10636
|
+
return `“${renamed}” cannot be renamed while the frontmatter of “${unreadable}”, which the rename rewrites, is not valid YAML: what it holds would be lost. ${fix}`;
|
|
10637
|
+
}
|
|
10638
|
+
/**
|
|
9982
10639
|
* A step merges when a DIFFERENT document already answers to the target name - by title or by
|
|
9983
10640
|
* alias - and the source has a document to join to it. Renaming onto yourself (a pure
|
|
9984
10641
|
* re-casing, or onto one of your own aliases) is neither. A source with no document landing
|
|
@@ -10303,6 +10960,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10303
10960
|
await settled(openDoc);
|
|
10304
10961
|
}
|
|
10305
10962
|
const { text } = await adapter.read(entry.subdir, entry.fileName);
|
|
10963
|
+
if (!frontmatterIdentity(text).readable) throw new Error(unreadableBlockRefusal(step.from, entry.concept));
|
|
10306
10964
|
const fm = parseFrontmatter(text);
|
|
10307
10965
|
let aliases = aliasesOf(fm);
|
|
10308
10966
|
let body = fm.body;
|
|
@@ -10313,7 +10971,9 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10313
10971
|
const targetEntry = step.merges ? registry.get(conceptKey(step.into)) : void 0;
|
|
10314
10972
|
const survivor = targetEntry && targetEntry.key !== entry.key ? targetEntry : void 0;
|
|
10315
10973
|
if (survivor) {
|
|
10316
|
-
const
|
|
10974
|
+
const existing = await adapter.read(survivor.subdir, survivor.fileName);
|
|
10975
|
+
if (!frontmatterIdentity(existing.text).readable) throw new Error(unreadableBlockRefusal(step.from, survivor.concept));
|
|
10976
|
+
const existingFm = parseFrontmatter(existing.text);
|
|
10317
10977
|
const merged = mergeDocuments({
|
|
10318
10978
|
body: existingFm.body,
|
|
10319
10979
|
aliases: aliasesOf(existingFm)
|
|
@@ -10422,6 +11082,29 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10422
11082
|
if (doc.dirty) runSave(doc);
|
|
10423
11083
|
await settled(doc);
|
|
10424
11084
|
}
|
|
11085
|
+
/**
|
|
11086
|
+
* The registry entry `target` names: a document by its own name, else by one of its aliases
|
|
11087
|
+
* (ADR 0061), as a bookmark or a Recents entry can carry a renamed page's old name. A page's own
|
|
11088
|
+
* name outranks another page's alias of the same name, as the index resolves it.
|
|
11089
|
+
*/
|
|
11090
|
+
function entryNamed(target) {
|
|
11091
|
+
const key = conceptKey(target);
|
|
11092
|
+
const own = registry.get(key);
|
|
11093
|
+
if (own) return own;
|
|
11094
|
+
for (const entry of registry.values()) if (entry.aliases.some((alias) => conceptKey(alias) === key)) return entry;
|
|
11095
|
+
}
|
|
11096
|
+
/** The open document `target` names, opening it if it is not; throws when nothing has that name. */
|
|
11097
|
+
function openDocNamed(target) {
|
|
11098
|
+
const existing = open.get(conceptKey(target));
|
|
11099
|
+
if (existing) return existing;
|
|
11100
|
+
const entry = entryNamed(target);
|
|
11101
|
+
if (!entry) throw new DocumentNotFoundError(target);
|
|
11102
|
+
const opened = open.get(entry.key);
|
|
11103
|
+
if (opened) return opened;
|
|
11104
|
+
const doc = makeOpenDoc(entry.key === conceptKey(target) ? target : entry.concept, entry);
|
|
11105
|
+
open.set(entry.key, doc);
|
|
11106
|
+
return doc;
|
|
11107
|
+
}
|
|
10425
11108
|
function makeOpenDoc(target, entry) {
|
|
10426
11109
|
const doc = {
|
|
10427
11110
|
key: entry.key,
|
|
@@ -10525,17 +11208,10 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10525
11208
|
}
|
|
10526
11209
|
return {
|
|
10527
11210
|
open(target) {
|
|
10528
|
-
|
|
10529
|
-
const existing = open.get(key);
|
|
10530
|
-
if (existing) return existing.handle;
|
|
10531
|
-
const entry = registry.get(key);
|
|
10532
|
-
if (!entry) throw new DocumentNotFoundError(target);
|
|
10533
|
-
const doc = makeOpenDoc(target, entry);
|
|
10534
|
-
open.set(key, doc);
|
|
10535
|
-
return doc.handle;
|
|
11211
|
+
return openDocNamed(target).handle;
|
|
10536
11212
|
},
|
|
10537
11213
|
async whenReady(target) {
|
|
10538
|
-
await (
|
|
11214
|
+
await openDocNamed(target).ready;
|
|
10539
11215
|
},
|
|
10540
11216
|
async scan() {
|
|
10541
11217
|
await adapter.ensureSkeleton();
|
|
@@ -10684,7 +11360,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10684
11360
|
return concept;
|
|
10685
11361
|
},
|
|
10686
11362
|
async planRename(from, to, referencingDocuments) {
|
|
10687
|
-
return refuseProtectedMerges(planRename$1({
|
|
11363
|
+
return refuseUnreadableBlocks(await refuseProtectedMerges(planRename$1({
|
|
10688
11364
|
from,
|
|
10689
11365
|
to,
|
|
10690
11366
|
kind: registry.get(conceptKey(from))?.kind ?? null,
|
|
@@ -10698,6 +11374,12 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10698
11374
|
const other = registry.get(conceptKey(concept));
|
|
10699
11375
|
if (!other) return false;
|
|
10700
11376
|
return documentProtection(open.get(other.key)?.buffer ?? (await adapter.read(other.subdir, other.fileName)).text).kind === "document";
|
|
11377
|
+
}), async (concept) => {
|
|
11378
|
+
const other = registry.get(conceptKey(concept));
|
|
11379
|
+
if (!other) return null;
|
|
11380
|
+
const openDoc = open.get(other.key);
|
|
11381
|
+
if (openDoc) await settled(openDoc);
|
|
11382
|
+
return (await adapter.read(other.subdir, other.fileName)).text;
|
|
10701
11383
|
});
|
|
10702
11384
|
},
|
|
10703
11385
|
async renamePage(from, to, options) {
|
|
@@ -10760,6 +11442,11 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10760
11442
|
if (!doc) return;
|
|
10761
11443
|
await saveNow(doc);
|
|
10762
11444
|
},
|
|
11445
|
+
async flushAll() {
|
|
11446
|
+
await Promise.all([...open.values()].map(saveNow));
|
|
11447
|
+
const unwritten = [...open.values()].filter((doc) => doc.dirty).map((doc) => doc.target);
|
|
11448
|
+
if (unwritten.length > 0) throw new Error(`Edits to ${unwritten.join(", ")} could not be written to the folder yet, so it does not hold them. Try again once they are saved.`);
|
|
11449
|
+
},
|
|
10763
11450
|
async dispose() {
|
|
10764
11451
|
await Promise.all([...open.values()].map(saveNow));
|
|
10765
11452
|
open.clear();
|
|
@@ -10960,10 +11647,6 @@ async function reachedWithin(promise, timeoutMs) {
|
|
|
10960
11647
|
if (timer) clearTimeout(timer);
|
|
10961
11648
|
}
|
|
10962
11649
|
}
|
|
10963
|
-
/** Health that says the bytes are not this document's, so neither text nor emptiness is real. */
|
|
10964
|
-
function contentBlocked(health) {
|
|
10965
|
-
return health === "key-unavailable" || health === "ciphertext-corrupt";
|
|
10966
|
-
}
|
|
10967
11650
|
function createServerDocumentStore(graph, options) {
|
|
10968
11651
|
const registry = graph.registry();
|
|
10969
11652
|
/** Engines created between yields while snapshotting — small enough to keep frames free. */
|
|
@@ -10974,6 +11657,9 @@ function createServerDocumentStore(graph, options) {
|
|
|
10974
11657
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
10975
11658
|
const docsChangedListeners = /* @__PURE__ */ new Set();
|
|
10976
11659
|
const removedListeners = /* @__PURE__ */ new Set();
|
|
11660
|
+
const renamedListeners = /* @__PURE__ */ new Set();
|
|
11661
|
+
/** docId to the name it had when first seen renamed from elsewhere, and its latest name. */
|
|
11662
|
+
const renamesFromElsewhere = /* @__PURE__ */ new Map();
|
|
10977
11663
|
const resurrectionClaims = /* @__PURE__ */ new Map();
|
|
10978
11664
|
let disposed = false;
|
|
10979
11665
|
/** Whether the encrypted registry has been read to its terminal relay page this session. */
|
|
@@ -10993,9 +11679,14 @@ function createServerDocumentStore(graph, options) {
|
|
|
10993
11679
|
*/
|
|
10994
11680
|
async function materialise(docIds, timeoutMs) {
|
|
10995
11681
|
const unique = [...new Set(docIds)];
|
|
10996
|
-
const
|
|
11682
|
+
const held = [];
|
|
11683
|
+
const release = () => graph.retireDocs(held);
|
|
10997
11684
|
try {
|
|
10998
|
-
for (let start = 0; start < unique.length; start += ENGINE_BATCH)
|
|
11685
|
+
for (let start = 0; start < unique.length; start += ENGINE_BATCH) {
|
|
11686
|
+
const batch = unique.slice(start, start + ENGINE_BATCH);
|
|
11687
|
+
held.push(...batch);
|
|
11688
|
+
await graph.readyDocs(batch);
|
|
11689
|
+
}
|
|
10999
11690
|
const connected = graph.isConnected();
|
|
11000
11691
|
const behind = new Set(connected ? await graph.docsNeedingCatchup(unique) : []);
|
|
11001
11692
|
const unconfirmed = [];
|
|
@@ -11052,11 +11743,18 @@ function createServerDocumentStore(graph, options) {
|
|
|
11052
11743
|
});
|
|
11053
11744
|
return out;
|
|
11054
11745
|
}
|
|
11746
|
+
/**
|
|
11747
|
+
* Every name a document answers to, to its docId. A page's own name outranks another page's
|
|
11748
|
+
* alias of the same name, whatever order the registry lists them in, as the index resolves it.
|
|
11749
|
+
*/
|
|
11055
11750
|
function currentDocIds() {
|
|
11056
11751
|
const out = /* @__PURE__ */ new Map();
|
|
11752
|
+
registry.forEach((entry, docId) => out.set(conceptKey(conceptOf(entry)), docId));
|
|
11057
11753
|
registry.forEach((entry, docId) => {
|
|
11058
|
-
|
|
11059
|
-
|
|
11754
|
+
for (const alias of entry.aliases ?? []) {
|
|
11755
|
+
const key = conceptKey(alias);
|
|
11756
|
+
if (!out.has(key)) out.set(key, docId);
|
|
11757
|
+
}
|
|
11060
11758
|
});
|
|
11061
11759
|
return out;
|
|
11062
11760
|
}
|
|
@@ -11065,25 +11763,43 @@ function createServerDocumentStore(graph, options) {
|
|
|
11065
11763
|
const nextRegistry = currentRegistryIdentities();
|
|
11066
11764
|
const changedDocIds = [...new Set([...knownRegistry.keys(), ...nextRegistry.keys()])].filter((docId) => !sameIdentity(knownRegistry.get(docId), nextRegistry.get(docId)));
|
|
11067
11765
|
if (changedDocIds.length === 0) return;
|
|
11766
|
+
const restored = event.transaction.origin === CACHE_SEED;
|
|
11767
|
+
const fromElsewhere = !restored && !event.transaction.local;
|
|
11068
11768
|
const additions = [];
|
|
11769
|
+
const renames = [];
|
|
11069
11770
|
let additionsOnly = true;
|
|
11070
11771
|
for (const docId of changedDocIds) {
|
|
11071
11772
|
const before = knownRegistry.get(docId);
|
|
11072
11773
|
const after = nextRegistry.get(docId);
|
|
11774
|
+
if (fromElsewhere && before && after && before.concept !== after.concept) renames.push({
|
|
11775
|
+
docId,
|
|
11776
|
+
from: before.concept,
|
|
11777
|
+
to: after.concept
|
|
11778
|
+
});
|
|
11073
11779
|
if (before || !after) {
|
|
11074
11780
|
additionsOnly = false;
|
|
11075
11781
|
continue;
|
|
11076
11782
|
}
|
|
11077
11783
|
additions.push(after.concept);
|
|
11078
11784
|
}
|
|
11785
|
+
const renamedAway = new Set(renames.map((rename) => conceptKey(rename.from)));
|
|
11079
11786
|
const next = currentConcepts();
|
|
11080
|
-
for (const [key, concept] of known) if (!next.has(key)) removedListeners.forEach((l) => l(concept));
|
|
11787
|
+
for (const [key, concept] of known) if (!next.has(key) && !renamedAway.has(key)) removedListeners.forEach((l) => l(concept));
|
|
11081
11788
|
known = next;
|
|
11082
11789
|
docIdsByConcept = currentDocIds();
|
|
11083
11790
|
knownRegistry = nextRegistry;
|
|
11084
11791
|
docsChangedListeners.forEach((l) => l());
|
|
11085
|
-
if (additionsOnly) for (const concept of additions) changeListeners.forEach((listener) => listener({ concept }));
|
|
11792
|
+
if (!restored) if (additionsOnly) for (const concept of additions) changeListeners.forEach((listener) => listener({ concept }));
|
|
11086
11793
|
else changeListeners.forEach((listener) => listener());
|
|
11794
|
+
for (const { docId, from, to } of renames) {
|
|
11795
|
+
const first = renamesFromElsewhere.get(docId)?.from ?? from;
|
|
11796
|
+
if (first === to) renamesFromElsewhere.delete(docId);
|
|
11797
|
+
else renamesFromElsewhere.set(docId, {
|
|
11798
|
+
from: first,
|
|
11799
|
+
to
|
|
11800
|
+
});
|
|
11801
|
+
renamedListeners.forEach((listener) => listener(from, to));
|
|
11802
|
+
}
|
|
11087
11803
|
for (const [docId, change] of event.changes.keys) {
|
|
11088
11804
|
if (change.action !== "delete") continue;
|
|
11089
11805
|
const claimed = resurrectionClaims.get(docId);
|
|
@@ -11361,6 +12077,18 @@ function createServerDocumentStore(graph, options) {
|
|
|
11361
12077
|
}
|
|
11362
12078
|
return out;
|
|
11363
12079
|
},
|
|
12080
|
+
async docsBehind(docIds, timeoutMs = COLD_CONTENT_TIMEOUT_MS) {
|
|
12081
|
+
if (disposed || !graph.isConnected()) return null;
|
|
12082
|
+
if (docIds.length === 0) return [];
|
|
12083
|
+
let timer;
|
|
12084
|
+
try {
|
|
12085
|
+
return await Promise.race([graph.docsNeedingCatchup(docIds).catch(() => null), new Promise((resolve) => {
|
|
12086
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
12087
|
+
})]);
|
|
12088
|
+
} finally {
|
|
12089
|
+
if (timer) clearTimeout(timer);
|
|
12090
|
+
}
|
|
12091
|
+
},
|
|
11364
12092
|
async confirmRegistry(timeoutMs = 5e3) {
|
|
11365
12093
|
if (registryConfirmed) return true;
|
|
11366
12094
|
if (!graph.isConnected()) return false;
|
|
@@ -11375,6 +12103,13 @@ function createServerDocumentStore(graph, options) {
|
|
|
11375
12103
|
removedListeners.add(listener);
|
|
11376
12104
|
return () => removedListeners.delete(listener);
|
|
11377
12105
|
},
|
|
12106
|
+
onDocumentRenamed(listener) {
|
|
12107
|
+
renamedListeners.add(listener);
|
|
12108
|
+
return () => renamedListeners.delete(listener);
|
|
12109
|
+
},
|
|
12110
|
+
renamesObserved() {
|
|
12111
|
+
return [...renamesFromElsewhere.values()].map((rename) => ({ ...rename }));
|
|
12112
|
+
},
|
|
11378
12113
|
onChange(listener) {
|
|
11379
12114
|
changeListeners.add(listener);
|
|
11380
12115
|
return () => changeListeners.delete(listener);
|
|
@@ -11488,11 +12223,16 @@ function createServerDocumentStore(graph, options) {
|
|
|
11488
12223
|
acknowledge: checkpoint.acknowledge
|
|
11489
12224
|
};
|
|
11490
12225
|
},
|
|
11491
|
-
snapshotDocument(concept) {
|
|
12226
|
+
async snapshotDocument(concept) {
|
|
11492
12227
|
const docId = docIdFor(concept);
|
|
11493
|
-
|
|
11494
|
-
|
|
11495
|
-
|
|
12228
|
+
if (!docId || !registry.has(docId)) return null;
|
|
12229
|
+
try {
|
|
12230
|
+
await graph.readyDocs([docId]);
|
|
12231
|
+
const entry = registry.get(docId);
|
|
12232
|
+
return entry ? indexSnapshotFor(entry, graph.docSync(docId).doc.getText("content").toString()) : null;
|
|
12233
|
+
} finally {
|
|
12234
|
+
graph.retireDocs([docId]);
|
|
12235
|
+
}
|
|
11496
12236
|
},
|
|
11497
12237
|
async compactDocument(target) {
|
|
11498
12238
|
const docId = docIdFor(target);
|
|
@@ -11759,7 +12499,7 @@ async function withRetry(task, options = {}) {
|
|
|
11759
12499
|
} catch (error) {
|
|
11760
12500
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
11761
12501
|
if (attempt >= attempts || !shouldRetry(error)) throw error;
|
|
11762
|
-
const full = base * 2 ** (attempt - 1);
|
|
12502
|
+
const full = Math.min(base * 2 ** (attempt - 1), options.maxDelayMs ?? Number.POSITIVE_INFINITY);
|
|
11763
12503
|
const wait = Math.round(full / 2 + random() * (full / 2));
|
|
11764
12504
|
options.onRetry?.(error, attempt, wait);
|
|
11765
12505
|
await sleep(wait, options.signal);
|
|
@@ -11849,11 +12589,25 @@ var HttpFailure = class extends Error {
|
|
|
11849
12589
|
*/
|
|
11850
12590
|
function isTransient(error) {
|
|
11851
12591
|
if (!(error instanceof HttpFailure)) return true;
|
|
11852
|
-
return error.status
|
|
12592
|
+
return isTransientStatus(error.status);
|
|
12593
|
+
}
|
|
12594
|
+
/** "Not your fault, try again": a 401 only once a fresh token has been presented. */
|
|
12595
|
+
function isTransientStatus(status) {
|
|
12596
|
+
return status === 401 || status === 408 || status === 429 || status >= 500;
|
|
11853
12597
|
}
|
|
11854
12598
|
function kebabStem(name) {
|
|
11855
12599
|
return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "asset";
|
|
11856
12600
|
}
|
|
12601
|
+
/**
|
|
12602
|
+
* The MIME type a downloaded asset is given: its extension's, from the same short list the
|
|
12603
|
+
* Filesystem Backend uses, or `application/octet-stream`. Never the type recorded in the asset's
|
|
12604
|
+
* metadata, which is whatever the uploader's client sent: a collaborator could type an asset
|
|
12605
|
+
* `text/html`, and a blob opened in its own tab would then be a same-origin HTML document. The
|
|
12606
|
+
* extension is the reference's, the one the viewers are chosen by.
|
|
12607
|
+
*/
|
|
12608
|
+
function assetTypeFor(ref) {
|
|
12609
|
+
return mimeTypeForExt(extOf(ref)) || "application/octet-stream";
|
|
12610
|
+
}
|
|
11857
12611
|
function extOf(name) {
|
|
11858
12612
|
const m = /\.([^.]+)$/.exec(name);
|
|
11859
12613
|
return m ? m[1].toLowerCase() : "bin";
|
|
@@ -11910,14 +12664,33 @@ function createServerAssetStore(deps) {
|
|
|
11910
12664
|
}, retryOptions);
|
|
11911
12665
|
}
|
|
11912
12666
|
/**
|
|
11913
|
-
*
|
|
11914
|
-
*
|
|
12667
|
+
* A read request whose connection failure is a file that cannot be fetched just now, not a
|
|
12668
|
+
* missing one. A cancellation stays a cancellation.
|
|
11915
12669
|
*/
|
|
11916
|
-
async function
|
|
12670
|
+
async function readRequest(url, init) {
|
|
12671
|
+
try {
|
|
12672
|
+
return await f(url, init);
|
|
12673
|
+
} catch (error) {
|
|
12674
|
+
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
12675
|
+
throw new AssetUnavailableError("The file could not be fetched: the connection failed.", { cause: error });
|
|
12676
|
+
}
|
|
12677
|
+
}
|
|
12678
|
+
/**
|
|
12679
|
+
* Fetch, decrypt and reassemble one asset; both read paths go through here. `null` when the
|
|
12680
|
+
* server has no such asset or refuses it. A failure that says nothing about the asset rejects
|
|
12681
|
+
* with `AssetUnavailableError`: the connection, or a status that means "try again" (a 401
|
|
12682
|
+
* after one fresh token). A file that will not decrypt or parse rejects with that error.
|
|
12683
|
+
*/
|
|
12684
|
+
async function fetchAssetBytes(ref) {
|
|
11917
12685
|
const assetId = assetIdFromRef(ref);
|
|
11918
12686
|
if (!assetId) return null;
|
|
11919
|
-
const
|
|
11920
|
-
|
|
12687
|
+
const url = `${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`;
|
|
12688
|
+
let res = await readRequest(url, { headers: { "x-sync-token": await deps.syncToken() } });
|
|
12689
|
+
if (res.status === 401) res = await readRequest(url, { headers: { "x-sync-token": await deps.syncToken({ force: true }) } });
|
|
12690
|
+
if (!res.ok) {
|
|
12691
|
+
if (isTransientStatus(res.status)) throw new AssetUnavailableError(`The sync server could not hand over the file just now (HTTP ${res.status}).`, { status: res.status });
|
|
12692
|
+
return null;
|
|
12693
|
+
}
|
|
11921
12694
|
const body = await res.json();
|
|
11922
12695
|
const metaPlain = await openSymmetric({
|
|
11923
12696
|
keyForEpoch: (id) => keyForEpoch(deps.keyring, id),
|
|
@@ -11928,8 +12701,11 @@ function createServerAssetStore(deps) {
|
|
|
11928
12701
|
const perAssetKey = fromBase64Url(metadata.perAssetKey);
|
|
11929
12702
|
const parts = [];
|
|
11930
12703
|
for (let n = 0; n < body.downloadUrls.length; n++) {
|
|
11931
|
-
const chunkRes = await
|
|
11932
|
-
if (!chunkRes.ok)
|
|
12704
|
+
const chunkRes = await readRequest(body.downloadUrls[n]);
|
|
12705
|
+
if (!chunkRes.ok) {
|
|
12706
|
+
if (chunkRes.status !== 401 && isTransientStatus(chunkRes.status)) throw new AssetUnavailableError(`Storage could not hand over the file just now (HTTP ${chunkRes.status}).`, { status: chunkRes.status });
|
|
12707
|
+
return null;
|
|
12708
|
+
}
|
|
11933
12709
|
const { plaintext } = await openSymmetric({
|
|
11934
12710
|
keyForEpoch: () => perAssetKey,
|
|
11935
12711
|
envelope: new Uint8Array(await chunkRes.arrayBuffer()),
|
|
@@ -11947,7 +12723,7 @@ function createServerAssetStore(deps) {
|
|
|
11947
12723
|
return {
|
|
11948
12724
|
bytes: joined,
|
|
11949
12725
|
name: metadata.name,
|
|
11950
|
-
type:
|
|
12726
|
+
type: assetTypeFor(ref)
|
|
11951
12727
|
};
|
|
11952
12728
|
}
|
|
11953
12729
|
return {
|
|
@@ -12019,7 +12795,7 @@ function createServerAssetStore(deps) {
|
|
|
12019
12795
|
body: chunks[n]
|
|
12020
12796
|
});
|
|
12021
12797
|
} catch {
|
|
12022
|
-
throw new Error("Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's origin - see \"Asset storage\" in the Synced Graphs doc.");
|
|
12798
|
+
throw new Error(typeof navigator !== "undefined" && navigator.onLine === false ? "The connection dropped while uploading to the storage bucket." : "Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's origin - see \"Asset storage\" in the Synced Graphs doc.");
|
|
12023
12799
|
}
|
|
12024
12800
|
if (!r.ok) throw new HttpFailure(r.status, `chunk ${n} upload failed: ${r.status}`);
|
|
12025
12801
|
onBytes?.(Math.min(CHUNK_SIZE, bytes.length - n * CHUNK_SIZE));
|
|
@@ -12035,9 +12811,9 @@ function createServerAssetStore(deps) {
|
|
|
12035
12811
|
});
|
|
12036
12812
|
return saved(assetId, false);
|
|
12037
12813
|
},
|
|
12038
|
-
readBytes:
|
|
12814
|
+
readBytes: (ref) => fetchAssetBytes(ref).catch((error) => error instanceof AssetUnavailableError && error.status !== void 0 ? null : Promise.reject(error)),
|
|
12039
12815
|
async resolve(ref) {
|
|
12040
|
-
const asset = await
|
|
12816
|
+
const asset = await fetchAssetBytes(ref);
|
|
12041
12817
|
if (!asset) return null;
|
|
12042
12818
|
const url = URL.createObjectURL(new Blob([asset.bytes], { type: asset.type }));
|
|
12043
12819
|
objectUrls.push(url);
|
|
@@ -12079,6 +12855,20 @@ async function listServerAssets(deps) {
|
|
|
12079
12855
|
return assets;
|
|
12080
12856
|
}
|
|
12081
12857
|
//#endregion
|
|
12858
|
+
//#region ../client/src/lib/sync/write-refusal.ts
|
|
12859
|
+
var AGENT_REASONS = {
|
|
12860
|
+
entitlement_inactive: "the graph owner's plan does not allow changes at the moment (a lapsed, unpaid or unconfirmed plan)",
|
|
12861
|
+
owned_storage_limit: "the graph owner's storage allowance is used up"
|
|
12862
|
+
};
|
|
12863
|
+
/**
|
|
12864
|
+
* The Headless Client's refusal, for an agent: the edit was refused, not lost in transit, and it
|
|
12865
|
+
* needs no retry from the agent. The connection-loss wording ("will be delivered when the
|
|
12866
|
+
* connection recovers") is untrue of a refusal.
|
|
12867
|
+
*/
|
|
12868
|
+
function writeRefusalForAgent(refusal, outstanding) {
|
|
12869
|
+
return `The Sync Server refused the edit: ${(refusal.quotaCode && AGENT_REASONS[refusal.quotaCode]) ?? "a plan limit was reached"}. ${outstanding === 1 ? "1 operation is held" : `${outstanding} operations are held`} here and sent automatically once the server accepts changes again.`;
|
|
12870
|
+
}
|
|
12871
|
+
//#endregion
|
|
12082
12872
|
//#region src/headless-graph.ts
|
|
12083
12873
|
/**
|
|
12084
12874
|
* How often the cache and index are exported while a build is running. A build reports
|
|
@@ -12197,6 +12987,7 @@ function assembleHeadlessGraph(parts) {
|
|
|
12197
12987
|
publishing: parts.publishing,
|
|
12198
12988
|
themes: parts.themes,
|
|
12199
12989
|
settle: () => parts.settle(schedulePersist),
|
|
12990
|
+
accessLoss: () => parts.accessLoss?.() ?? null,
|
|
12200
12991
|
persist,
|
|
12201
12992
|
semantic,
|
|
12202
12993
|
semanticOpened: () => semanticOpening ? semanticOpening.catch(() => void 0) : Promise.resolve(void 0),
|
|
@@ -12232,6 +13023,7 @@ async function openHeadlessGraph(deps) {
|
|
|
12232
13023
|
...PRESENCE_PALETTE[0]
|
|
12233
13024
|
},
|
|
12234
13025
|
onError: deps.onError,
|
|
13026
|
+
onAccessLost: deps.onAccessLost,
|
|
12235
13027
|
publishName: deps.publishName
|
|
12236
13028
|
});
|
|
12237
13029
|
const store = createServerDocumentStore(sync, { readyTimeoutMs: deps.readyTimeoutMs });
|
|
@@ -12314,6 +13106,7 @@ async function openHeadlessGraph(deps) {
|
|
|
12314
13106
|
return assembleHeadlessGraph({
|
|
12315
13107
|
graphId: deps.graphId,
|
|
12316
13108
|
name: sync.getMeta().name ?? deps.graphId,
|
|
13109
|
+
accessLoss: () => sync.accessLoss(),
|
|
12317
13110
|
store: documents,
|
|
12318
13111
|
index,
|
|
12319
13112
|
assets,
|
|
@@ -12349,6 +13142,12 @@ async function openHeadlessGraph(deps) {
|
|
|
12349
13142
|
const result = await sync.awaitAcked({ stallMs: 1e4 });
|
|
12350
13143
|
schedulePersist();
|
|
12351
13144
|
if (result.settled) return { settled: true };
|
|
13145
|
+
if (result.refused) return {
|
|
13146
|
+
settled: false,
|
|
13147
|
+
outstanding: result.outstanding,
|
|
13148
|
+
code: "write_refused",
|
|
13149
|
+
message: writeRefusalForAgent(result.refused, result.outstanding)
|
|
13150
|
+
};
|
|
12352
13151
|
return {
|
|
12353
13152
|
settled: false,
|
|
12354
13153
|
outstanding: result.outstanding,
|
|
@@ -12626,6 +13425,43 @@ async function openHeadlessFolder(deps) {
|
|
|
12626
13425
|
}
|
|
12627
13426
|
}
|
|
12628
13427
|
//#endregion
|
|
13428
|
+
//#region ../client/src/lib/sync/recovery-unlock.ts
|
|
13429
|
+
/**
|
|
13430
|
+
* Unlock with a Recovery Code, checked against the account's vault.
|
|
13431
|
+
*
|
|
13432
|
+
* A Recovery Code derives the vault's wrap key, and any well-formed code derives *a* key. Caching
|
|
13433
|
+
* whatever came out would report a mistyped or retired code as success, and every graph would
|
|
13434
|
+
* then fail to open with a message that blamed the keys on the device, next to the control that
|
|
13435
|
+
* resets them. The browser and the Headless Client both use this one check: fetch the vault,
|
|
13436
|
+
* open it with the derived key, and hand back the vault key only once it has opened.
|
|
13437
|
+
*
|
|
13438
|
+
* What is returned is the vault key, never the wrap key, as Device Approval caches: it keeps
|
|
13439
|
+
* working after the Recovery Code is regenerated on another device.
|
|
13440
|
+
*/
|
|
13441
|
+
/** The account has no vault yet, so there is nothing for a Recovery Code to open. */
|
|
13442
|
+
var NoVaultError = class extends Error {
|
|
13443
|
+
constructor() {
|
|
13444
|
+
super("This account has no encryption keys yet; there is nothing for a Recovery Code to open.");
|
|
13445
|
+
this.name = "NoVaultError";
|
|
13446
|
+
}
|
|
13447
|
+
};
|
|
13448
|
+
/**
|
|
13449
|
+
* @throws RecoveryCodeError when the code is malformed or does not open this account's vault.
|
|
13450
|
+
* @throws NoVaultError when the account has no vault.
|
|
13451
|
+
* Anything else (the vault could not be fetched) propagates unchanged: it says nothing about the code.
|
|
13452
|
+
*/
|
|
13453
|
+
async function openVaultWithRecoveryCode(api, code) {
|
|
13454
|
+
const wrapKey = await deriveVaultWrapKey(code);
|
|
13455
|
+
const stored = await api.getVault();
|
|
13456
|
+
if (!stored) throw new NoVaultError();
|
|
13457
|
+
try {
|
|
13458
|
+
return (await openVault(fromBase64Url(stored.vault), wrapKey)).vaultKey;
|
|
13459
|
+
} catch (error) {
|
|
13460
|
+
if (error instanceof EnvelopeError) throw new RecoveryCodeError("That Recovery Code does not open this account’s keys. Check it character by character; only the most recently issued code works.");
|
|
13461
|
+
throw error;
|
|
13462
|
+
}
|
|
13463
|
+
}
|
|
13464
|
+
//#endregion
|
|
12629
13465
|
//#region ../client/src/lib/sync/device-approval.ts
|
|
12630
13466
|
/**
|
|
12631
13467
|
* Device approval (ADR 0026 flows): unlock a NEW device from an already-unlocked one, so
|
|
@@ -12697,18 +13533,66 @@ var ApprovalAbandoned = class extends Error {
|
|
|
12697
13533
|
/** Approval rows expire server-side after ten minutes; poll a little longer and then give up. */
|
|
12698
13534
|
var APPROVAL_TIMEOUT_MS = 11 * 6e4;
|
|
12699
13535
|
var APPROVAL_POLL_MS = 2e3;
|
|
13536
|
+
/** How long a quit waits for the server to cancel the approval before leaving anyway. */
|
|
13537
|
+
var CANCEL_TIMEOUT_MS = 5e3;
|
|
13538
|
+
/** Shell exit codes for the signals that end a login: 128 plus the signal's number. */
|
|
13539
|
+
var SIGNAL_EXIT_CODES = {
|
|
13540
|
+
SIGHUP: 129,
|
|
13541
|
+
SIGINT: 130,
|
|
13542
|
+
SIGTERM: 143
|
|
13543
|
+
};
|
|
13544
|
+
/**
|
|
13545
|
+
* How the approval wait ends early. `r` switches to the Recovery Code; Ctrl-C (a key while the
|
|
13546
|
+
* terminal is in raw mode) and SIGINT, SIGTERM or SIGHUP quit. Either way the wait aborts, so it
|
|
13547
|
+
* cancels its approval server-side before anything exits: a login left pending showed its stale
|
|
13548
|
+
* code in every unlocked tab for ten minutes. `exitCode` is set once the user has quit.
|
|
13549
|
+
*/
|
|
13550
|
+
function approvalWaitControls() {
|
|
13551
|
+
const abort = new AbortController();
|
|
13552
|
+
let exitCode = null;
|
|
13553
|
+
return {
|
|
13554
|
+
signal: abort.signal,
|
|
13555
|
+
get exitCode() {
|
|
13556
|
+
return exitCode;
|
|
13557
|
+
},
|
|
13558
|
+
onKey(key) {
|
|
13559
|
+
if (key === "r" || key === "R") abort.abort();
|
|
13560
|
+
if (key === "") {
|
|
13561
|
+
exitCode = SIGNAL_EXIT_CODES.SIGINT;
|
|
13562
|
+
abort.abort();
|
|
13563
|
+
}
|
|
13564
|
+
},
|
|
13565
|
+
onSignal(name) {
|
|
13566
|
+
exitCode = SIGNAL_EXIT_CODES[name];
|
|
13567
|
+
abort.abort();
|
|
13568
|
+
}
|
|
13569
|
+
};
|
|
13570
|
+
}
|
|
13571
|
+
/** A sleep that ends as soon as the signal aborts, so a quit does not wait out the poll interval. */
|
|
13572
|
+
function sleepUnlessAborted(ms, signal) {
|
|
13573
|
+
return new Promise((resolve) => {
|
|
13574
|
+
if (signal.aborted) return resolve();
|
|
13575
|
+
const done = () => {
|
|
13576
|
+
clearTimeout(timer);
|
|
13577
|
+
signal.removeEventListener("abort", done);
|
|
13578
|
+
resolve();
|
|
13579
|
+
};
|
|
13580
|
+
const timer = setTimeout(done, ms);
|
|
13581
|
+
signal.addEventListener("abort", done, { once: true });
|
|
13582
|
+
});
|
|
13583
|
+
}
|
|
12700
13584
|
async function unlockByDeviceApproval(api, io) {
|
|
12701
13585
|
const request = await beginDeviceApproval(api);
|
|
12702
13586
|
const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
|
|
12703
13587
|
io.say("");
|
|
12704
|
-
io.say(`To approve this device, ${where} (any page - it need not be a note)
|
|
12705
|
-
io.say("with its
|
|
13588
|
+
io.say(`To approve this device, ${where} (any page - it need not be a note), connected to this account`);
|
|
13589
|
+
io.say("with its keys unlocked. A prompt will show a code; confirm it matches this one:");
|
|
12706
13590
|
io.say("");
|
|
12707
13591
|
io.say(` ${request.sas}`);
|
|
12708
13592
|
io.say("");
|
|
12709
13593
|
io.say("Waiting (up to ten minutes)…");
|
|
12710
13594
|
const abandon = async () => {
|
|
12711
|
-
await api.cancelDeviceApproval(request.id).catch(() => {});
|
|
13595
|
+
await Promise.race([api.cancelDeviceApproval(request.id).catch(() => {}), new Promise((resolve) => setTimeout(resolve, CANCEL_TIMEOUT_MS).unref())]);
|
|
12712
13596
|
throw new ApprovalAbandoned();
|
|
12713
13597
|
};
|
|
12714
13598
|
const deadline = Date.now() + APPROVAL_TIMEOUT_MS;
|
|
@@ -12723,12 +13607,12 @@ async function unlockByDeviceApproval(api, io) {
|
|
|
12723
13607
|
}
|
|
12724
13608
|
throw new Error("The approval was not confirmed in time. Run login again.");
|
|
12725
13609
|
}
|
|
12726
|
-
/**
|
|
13610
|
+
/**
|
|
13611
|
+
* Recovery Code → wrap key → open the vault; what comes back is the vault key to cache. The same
|
|
13612
|
+
* check the browser makes (recovery-unlock.ts): a wrong code is refused as wrong and nothing is cached.
|
|
13613
|
+
*/
|
|
12727
13614
|
async function unlockByRecoveryCode(api, code) {
|
|
12728
|
-
|
|
12729
|
-
const stored = await api.getVault();
|
|
12730
|
-
if (!stored) throw new Error("This account has no encryption keys yet; there is nothing for a Recovery Code to open.");
|
|
12731
|
-
return (await openVault(fromBase64Url(stored.vault), wrapKey)).vaultKey;
|
|
13615
|
+
return openVaultWithRecoveryCode(api, code);
|
|
12732
13616
|
}
|
|
12733
13617
|
//#endregion
|
|
12734
13618
|
//#region ../client/src/lib/document/publish/selection.ts
|
|
@@ -12848,7 +13732,11 @@ function publicDocumentsInNoPublication(documents, publications) {
|
|
|
12848
13732
|
}
|
|
12849
13733
|
//#endregion
|
|
12850
13734
|
//#region ../client/src/lib/document/publish/host/site-writer.ts
|
|
12851
|
-
/**
|
|
13735
|
+
/**
|
|
13736
|
+
* Stale files are removed only from the places the publisher owns. `etherpk-publish.json` is not
|
|
13737
|
+
* produced but stays owned, so the first publish after the upgrade deletes the copy an older
|
|
13738
|
+
* build left in the folder.
|
|
13739
|
+
*/
|
|
12852
13740
|
function isOwnedPath(path) {
|
|
12853
13741
|
if (path.startsWith("assets/") || path.startsWith("theme/")) return true;
|
|
12854
13742
|
if (path.includes("/")) return false;
|
|
@@ -13509,6 +14397,50 @@ function archiveHtml(journals) {
|
|
|
13509
14397
|
return `<ul class="journal-archive">${journals.map((j) => `<li><time datetime="${escapeHtml$6(j.date ?? "")}">${escapeHtml$6(j.date ?? "")}</time> <a href="${escapeHtml$6(j.url)}">${escapeHtml$6(j.title)}</a>${j.excerpt ? `<p>${escapeHtml$6(j.excerpt)}</p>` : ""}</li>`).join("")}</ul>`;
|
|
13510
14398
|
}
|
|
13511
14399
|
//#endregion
|
|
14400
|
+
//#region ../client/src/lib/document/publish/diagram-id.ts
|
|
14401
|
+
/**
|
|
14402
|
+
* Renaming a drawn Mermaid diagram's id, so a published page can carry it.
|
|
14403
|
+
*
|
|
14404
|
+
* Mermaid renders a diagram under the id it is given and scopes everything else to that id: every
|
|
14405
|
+
* rule of its inline `<style>` (`#gk-mermaid-4 .node rect { fill: … }`), its marker definitions
|
|
14406
|
+
* (`gk-mermaid-4_flowchart-pointEnd`) and the `url(#…)` references to them, and its accessible
|
|
14407
|
+
* title and description ids. The published `<svg>` must keep an id its styles match; without one
|
|
14408
|
+
* no rule applies and every node rect takes SVG's default black fill.
|
|
14409
|
+
*
|
|
14410
|
+
* The host's own id is not good enough to keep as it is. It comes from a render counter, so it
|
|
14411
|
+
* changes from one publish to the next (and the browser host reuses the editor's cached drawings),
|
|
14412
|
+
* which would rewrite every page with a diagram on every publish; and one drawing is reused for
|
|
14413
|
+
* every occurrence of the same source, so a diagram shown twice on a page would put one id there
|
|
14414
|
+
* twice. The publisher therefore renames each occurrence to an id of its own choosing.
|
|
14415
|
+
*/
|
|
14416
|
+
/** The root element's id, or null. Only the opening tag is read. */
|
|
14417
|
+
function rootId(svg) {
|
|
14418
|
+
const open = /^\s*<svg\b[^>]*>/.exec(svg)?.[0];
|
|
14419
|
+
if (!open) return null;
|
|
14420
|
+
return /\sid="([^"]+)"/.exec(open)?.[1] ?? null;
|
|
14421
|
+
}
|
|
14422
|
+
/** The id the inline styles are scoped to, for a drawing whose root has lost its id. */
|
|
14423
|
+
function styleScope(svg) {
|
|
14424
|
+
const style = /<style\b[^>]*>([\s\S]*?)<\/style>/.exec(svg)?.[1];
|
|
14425
|
+
if (!style) return null;
|
|
14426
|
+
return /#([A-Za-z][\w-]*)(?=[\s{.,>:])/.exec(style)?.[1] ?? null;
|
|
14427
|
+
}
|
|
14428
|
+
function escapeRegExp(text) {
|
|
14429
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14430
|
+
}
|
|
14431
|
+
/**
|
|
14432
|
+
* The drawing with its id, and everything scoped to it, renamed to `id`. `id` must be a valid
|
|
14433
|
+
* CSS identifier that starts with a letter. A drawing with no id anywhere is returned as it is:
|
|
14434
|
+
* nothing in it is scoped, so there is nothing to keep in step.
|
|
14435
|
+
*/
|
|
14436
|
+
function withDiagramId(svg, id) {
|
|
14437
|
+
const current = rootId(svg);
|
|
14438
|
+
if (current) return svg.replace(new RegExp(`${escapeRegExp(current)}(?![A-Za-z0-9])`, "g"), id);
|
|
14439
|
+
const scope = styleScope(svg);
|
|
14440
|
+
if (!scope) return svg;
|
|
14441
|
+
return svg.replace(new RegExp(`${escapeRegExp(scope)}(?![A-Za-z0-9])`, "g"), id).replace(/^(\s*<svg\b)/, `$1 id="${id}"`);
|
|
14442
|
+
}
|
|
14443
|
+
//#endregion
|
|
13512
14444
|
//#region ../client/src/lib/document/code-languages.ts
|
|
13513
14445
|
/**
|
|
13514
14446
|
* The grammar for a [[Fenced Code Block]]'s info-string, from the registry the editor nests inside
|
|
@@ -13815,15 +14747,22 @@ function wikilinkRule(md, resolve) {
|
|
|
13815
14747
|
*/
|
|
13816
14748
|
/** A document-relative asset reference: any number of `../`, `assets/`, the name. */
|
|
13817
14749
|
var ASSET_REF = /^(?:\.\.\/)*assets\/(.+)$/;
|
|
14750
|
+
/**
|
|
14751
|
+
* The asset a reference names, decoded, or null when it names none. A name that decodes to
|
|
14752
|
+
* anything but one file inside `assets/` is not an asset: it would become a bundle path, and a site
|
|
14753
|
+
* folder, outside `assets/` (see `isSafeAssetName`). The link is then left as the author wrote it.
|
|
14754
|
+
*/
|
|
13818
14755
|
function assetNameOf(ref) {
|
|
13819
14756
|
const match = ASSET_REF.exec(ref);
|
|
13820
14757
|
if (!match) return null;
|
|
13821
14758
|
const raw = match[1].split(/[?#]/)[0];
|
|
14759
|
+
let name;
|
|
13822
14760
|
try {
|
|
13823
|
-
|
|
14761
|
+
name = decodeURIComponent(raw);
|
|
13824
14762
|
} catch {
|
|
13825
|
-
|
|
14763
|
+
name = raw;
|
|
13826
14764
|
}
|
|
14765
|
+
return isSafeAssetName(name) ? name : null;
|
|
13827
14766
|
}
|
|
13828
14767
|
function escapeHtml$3(text) {
|
|
13829
14768
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -14031,7 +14970,7 @@ function linkTargetPattern(excluded = "") {
|
|
|
14031
14970
|
* A regex may not repeat a group name, so compose this **once** per pattern. Wrap it in a group of
|
|
14032
14971
|
* your own where you need the whole link as one capture.
|
|
14033
14972
|
*/
|
|
14034
|
-
var MARKDOWN_LINK = `(?<bang>!?)\\[(?<label>[^\\]]*)\\]\\((?<target>${linkTargetPattern()})\\)`;
|
|
14973
|
+
var MARKDOWN_LINK = `(?<bang>!?)\\[(?<label>(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*)\\]\\((?<target>${linkTargetPattern()})\\)`;
|
|
14035
14974
|
/** A match's groups, typed. Every {@link MARKDOWN_LINK} match has all three. */
|
|
14036
14975
|
function linkGroups(match) {
|
|
14037
14976
|
return match.groups;
|
|
@@ -14133,6 +15072,24 @@ function contentOf(block) {
|
|
|
14133
15072
|
return first.replace(/^-\s+/, "").trim();
|
|
14134
15073
|
}
|
|
14135
15074
|
var WHOLE_LINK = new RegExp(`^${MARKDOWN_LINK}$`);
|
|
15075
|
+
/** The schemes an outline link may carry onto a published page; a target with no scheme is a path on the site. */
|
|
15076
|
+
var NAV_LINK_SCHEMES = new Set([
|
|
15077
|
+
"http",
|
|
15078
|
+
"https",
|
|
15079
|
+
"mailto"
|
|
15080
|
+
]);
|
|
15081
|
+
/**
|
|
15082
|
+
* An outline link's target, or null when a published page must not carry it. Links in page bodies
|
|
15083
|
+
* go through markdown-it's validateLink; the outline is parsed here, so it gets its own rule. The
|
|
15084
|
+
* scheme is read after removing the control characters and spaces a browser would ignore, so
|
|
15085
|
+
* '\u0001javascript:' is read as javascript.
|
|
15086
|
+
*/
|
|
15087
|
+
function navLinkTarget(target) {
|
|
15088
|
+
const visible = [...target].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
|
|
15089
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(visible);
|
|
15090
|
+
if (!scheme) return target;
|
|
15091
|
+
return NAV_LINK_SCHEMES.has(scheme[1].toLowerCase()) ? target : null;
|
|
15092
|
+
}
|
|
14136
15093
|
function buildNav(outline, resolver) {
|
|
14137
15094
|
const issues = [];
|
|
14138
15095
|
function convert(blocks) {
|
|
@@ -14166,10 +15123,24 @@ function buildNav(outline, resolver) {
|
|
|
14166
15123
|
const link = WHOLE_LINK.exec(content);
|
|
14167
15124
|
if (link && !linkGroups(link).bang) {
|
|
14168
15125
|
const { label, target } = linkGroups(link);
|
|
15126
|
+
const href = navLinkTarget(target);
|
|
15127
|
+
if (href === null) {
|
|
15128
|
+
issues.push({
|
|
15129
|
+
level: "warning",
|
|
15130
|
+
code: "nav-link-unsafe",
|
|
15131
|
+
message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths); the entry is shown without its link.`
|
|
15132
|
+
});
|
|
15133
|
+
out.push({
|
|
15134
|
+
label,
|
|
15135
|
+
labelHtml: escapeHtml$1(label),
|
|
15136
|
+
children
|
|
15137
|
+
});
|
|
15138
|
+
continue;
|
|
15139
|
+
}
|
|
14169
15140
|
out.push({
|
|
14170
15141
|
label,
|
|
14171
15142
|
labelHtml: escapeHtml$1(label),
|
|
14172
|
-
href
|
|
15143
|
+
href,
|
|
14173
15144
|
external: true,
|
|
14174
15145
|
children
|
|
14175
15146
|
});
|
|
@@ -14533,18 +15504,32 @@ async function publishPublication(source, publication, env, options = {}) {
|
|
|
14533
15504
|
const html = await highlight(fence.lang, fence.code);
|
|
14534
15505
|
if (html !== null) highlighted.set(key, html);
|
|
14535
15506
|
}
|
|
15507
|
+
let diagramScope = {
|
|
15508
|
+
prefix: "mermaid",
|
|
15509
|
+
n: 0
|
|
15510
|
+
};
|
|
15511
|
+
const scopeDiagrams = (prefix) => {
|
|
15512
|
+
diagramScope = {
|
|
15513
|
+
prefix: prefix.replace(/[^A-Za-z0-9_-]/g, "-"),
|
|
15514
|
+
n: 0
|
|
15515
|
+
};
|
|
15516
|
+
};
|
|
14536
15517
|
const renderer = createDocumentRenderer({
|
|
14537
15518
|
resolve: resolver.resolve,
|
|
14538
15519
|
assetHref: assetHrefOf,
|
|
14539
|
-
mermaidSvg: (src) =>
|
|
15520
|
+
mermaidSvg: (src) => {
|
|
15521
|
+
const svg = mermaidSvg.get(src);
|
|
15522
|
+
return svg === void 0 ? void 0 : withDiagramId(svg, `${diagramScope.prefix}_${++diagramScope.n}`);
|
|
15523
|
+
},
|
|
14540
15524
|
highlighted: (lang, code) => highlighted.get(`${lang}\n${code}`)
|
|
14541
15525
|
});
|
|
14542
15526
|
const pages = [];
|
|
14543
15527
|
let done = 0;
|
|
14544
15528
|
for (const doc of included) {
|
|
14545
15529
|
progress("rendering", done++, included.length);
|
|
14546
|
-
const rendered = renderer.render(bodyOf(doc));
|
|
14547
15530
|
const slug = slugs.get(conceptKey$1(doc.concept));
|
|
15531
|
+
scopeDiagrams(`mermaid_${slug}`);
|
|
15532
|
+
const rendered = renderer.render(bodyOf(doc));
|
|
14548
15533
|
const isHome = doc === homeDoc;
|
|
14549
15534
|
const page = {
|
|
14550
15535
|
doc,
|
|
@@ -14622,7 +15607,10 @@ async function publishPublication(source, publication, env, options = {}) {
|
|
|
14622
15607
|
continue;
|
|
14623
15608
|
}
|
|
14624
15609
|
customCss += (customCss ? "\n" : "") + fence.code;
|
|
14625
|
-
} else
|
|
15610
|
+
} else {
|
|
15611
|
+
scopeDiagrams(`mermaid__${slot}`);
|
|
15612
|
+
includes.set(slot, renderer.render(body).html);
|
|
15613
|
+
}
|
|
14626
15614
|
report.includes.push({
|
|
14627
15615
|
name: slot,
|
|
14628
15616
|
source: "page",
|
|
@@ -14781,7 +15769,7 @@ async function publishPublication(source, publication, env, options = {}) {
|
|
|
14781
15769
|
let assetsDone = 0;
|
|
14782
15770
|
for (const [name, from] of wanted) {
|
|
14783
15771
|
progress("assets", assetsDone++, wanted.size);
|
|
14784
|
-
const asset = await source.readAsset(`../assets/${name}`);
|
|
15772
|
+
const asset = await source.readAsset(`../assets/${encodeURIComponent(name)}`);
|
|
14785
15773
|
if (!asset) {
|
|
14786
15774
|
report.assets.missing.push({
|
|
14787
15775
|
name,
|
|
@@ -14931,6 +15919,8 @@ function themeFilesOfBundled(theme) {
|
|
|
14931
15919
|
files
|
|
14932
15920
|
};
|
|
14933
15921
|
}
|
|
15922
|
+
/** Far more files than any theme uses, and a bound on the fetches one publish can trigger. */
|
|
15923
|
+
var MAX_URL_THEME_FILES = 500;
|
|
14934
15924
|
/** Fetch a theme from its manifest URL; every file the manifest lists, relative to it. */
|
|
14935
15925
|
async function fetchTheme(url, fetchText) {
|
|
14936
15926
|
let json;
|
|
@@ -14942,10 +15932,12 @@ async function fetchTheme(url, fetchText) {
|
|
|
14942
15932
|
const { manifest, errors } = parseThemeManifest(json);
|
|
14943
15933
|
if (!manifest) throw new Error(`The theme at ${url} cannot be used: ${errors.join(" ")}`);
|
|
14944
15934
|
if (manifest.files.length === 0) throw new Error(`The theme at ${url} lists no files in its manifest, so nothing can be fetched.`);
|
|
15935
|
+
if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files; a theme may list at most ${MAX_URL_THEME_FILES}.`);
|
|
14945
15936
|
const base = new URL(url);
|
|
14946
15937
|
const files = /* @__PURE__ */ new Map();
|
|
14947
15938
|
for (const path of manifest.files) {
|
|
14948
15939
|
const fileUrl = new URL(path, base).toString();
|
|
15940
|
+
if (!isThemeFilePath(path) || new URL(fileUrl).origin !== base.origin) throw new Error(`The theme at ${url} lists "${path}", which is not a theme file beside its manifest (layouts/, partials/ or assets/).`);
|
|
14949
15941
|
try {
|
|
14950
15942
|
files.set(path, await fetchText(fileUrl));
|
|
14951
15943
|
} catch (error) {
|
|
@@ -14995,9 +15987,10 @@ function createThemeLoader(deps) {
|
|
|
14995
15987
|
/** Replace a document's frontmatter block with the one `rewrite` produces, through its live handle. */
|
|
14996
15988
|
async function rewriteFrontmatter(store, concept, rewrite) {
|
|
14997
15989
|
await store.whenReady?.(concept);
|
|
15990
|
+
await store.flushDocument?.(concept);
|
|
14998
15991
|
const handle = store.open(concept);
|
|
14999
15992
|
const text = handle.getText();
|
|
15000
|
-
const next = rewrite(text);
|
|
15993
|
+
const next = withAliasesInAddedBlock(text, rewrite(text), registryAliases(store, concept));
|
|
15001
15994
|
if (next === text) return false;
|
|
15002
15995
|
const before = frontmatterSpan(text)?.end ?? 0;
|
|
15003
15996
|
const after = frontmatterSpan(next)?.end ?? 0;
|
|
@@ -15009,6 +16002,11 @@ async function rewriteFrontmatter(store, concept, rewrite) {
|
|
|
15009
16002
|
await store.flushDocument?.(concept);
|
|
15010
16003
|
return true;
|
|
15011
16004
|
}
|
|
16005
|
+
/** The document's aliases as its store's registry holds them, found by its identity key. */
|
|
16006
|
+
function registryAliases(store, concept) {
|
|
16007
|
+
const key = conceptKey(concept);
|
|
16008
|
+
return store.listDocuments?.().find((entry) => entry.key === key)?.aliases ?? [];
|
|
16009
|
+
}
|
|
15012
16010
|
/** A publication id from a title: `Docs Site` → `docs-site`. */
|
|
15013
16011
|
function suggestPublicationId(title) {
|
|
15014
16012
|
return publishSlug(title);
|
|
@@ -15117,7 +16115,6 @@ async function runPublish(publication, deps) {
|
|
|
15117
16115
|
code: "documents-unsettled",
|
|
15118
16116
|
message: `${deps.unsettled.length} document${deps.unsettled.length === 1 ? " has" : "s have"} not finished syncing to this device and ${deps.unsettled.length === 1 ? "was" : "were"} left out: ${deps.unsettled.slice(0, 5).join(", ")}${deps.unsettled.length > 5 ? "…" : ""}. Publish again once sync has caught up.`
|
|
15119
16117
|
});
|
|
15120
|
-
if (report.ok) bundle.set("etherpk-publish.json", `${JSON.stringify(report, null, 2)}\n`);
|
|
15121
16118
|
return {
|
|
15122
16119
|
report,
|
|
15123
16120
|
bundle,
|
|
@@ -15262,7 +16259,6 @@ async function openDiagramRenderer(env) {
|
|
|
15262
16259
|
const { svg } = await m.render(renderId, text);
|
|
15263
16260
|
const el = new DOMParser().parseFromString(svg, "text/html").querySelector("svg");
|
|
15264
16261
|
if (!el) throw new Error("Mermaid produced no diagram.");
|
|
15265
|
-
el.removeAttribute("id");
|
|
15266
16262
|
el.setAttribute("role", "img");
|
|
15267
16263
|
return el.outerHTML;
|
|
15268
16264
|
} finally {
|
|
@@ -15277,6 +16273,150 @@ async function openDiagramRenderer(env) {
|
|
|
15277
16273
|
};
|
|
15278
16274
|
}
|
|
15279
16275
|
//#endregion
|
|
16276
|
+
//#region src/public-fetch.ts
|
|
16277
|
+
/**
|
|
16278
|
+
* Fetching text from the public internet, and nowhere else: the only network reads a tool makes
|
|
16279
|
+
* with an address it did not choose itself (a theme published at a URL, and every file its
|
|
16280
|
+
* manifest lists). The URL comes from a document or a tool argument, so a collaborator or a
|
|
16281
|
+
* prompt-injected agent could otherwise point this process at a service on the local machine or
|
|
16282
|
+
* the network it sits on, and read the answer back through `read_theme_file`.
|
|
16283
|
+
*
|
|
16284
|
+
* The rules: https only, no credentials in the URL, every address the host resolves to must be
|
|
16285
|
+
* public, redirects are followed by hand and each hop checked the same way, and a response is cut
|
|
16286
|
+
* off by a deadline and a size cap. The address check runs inside the socket's own DNS lookup, so
|
|
16287
|
+
* the address connected to is the address checked; resolving first and connecting afterwards
|
|
16288
|
+
* would let a second resolution answer differently.
|
|
16289
|
+
*/
|
|
16290
|
+
/** Ranges no theme is served from: loopback, private, link-local, shared, reserved, documentation, multicast. */
|
|
16291
|
+
var blocked = new BlockList();
|
|
16292
|
+
for (const [network, prefix] of [
|
|
16293
|
+
["0.0.0.0", 8],
|
|
16294
|
+
["10.0.0.0", 8],
|
|
16295
|
+
["100.64.0.0", 10],
|
|
16296
|
+
["127.0.0.0", 8],
|
|
16297
|
+
["169.254.0.0", 16],
|
|
16298
|
+
["172.16.0.0", 12],
|
|
16299
|
+
["192.0.0.0", 24],
|
|
16300
|
+
["192.0.2.0", 24],
|
|
16301
|
+
["192.168.0.0", 16],
|
|
16302
|
+
["198.18.0.0", 15],
|
|
16303
|
+
["198.51.100.0", 24],
|
|
16304
|
+
["203.0.113.0", 24],
|
|
16305
|
+
["224.0.0.0", 4],
|
|
16306
|
+
["240.0.0.0", 4]
|
|
16307
|
+
]) blocked.addSubnet(network, prefix, "ipv4");
|
|
16308
|
+
for (const [network, prefix] of [
|
|
16309
|
+
["::", 128],
|
|
16310
|
+
["::1", 128],
|
|
16311
|
+
["64:ff9b::", 96],
|
|
16312
|
+
["100::", 64],
|
|
16313
|
+
["2001:db8::", 32],
|
|
16314
|
+
["fc00::", 7],
|
|
16315
|
+
["fe80::", 10],
|
|
16316
|
+
["ff00::", 8]
|
|
16317
|
+
]) blocked.addSubnet(network, prefix, "ipv6");
|
|
16318
|
+
/** Whether an IP address is one on the public internet. Anything that is not an address is not. */
|
|
16319
|
+
function isPublicAddress(address) {
|
|
16320
|
+
const family = isIP(address);
|
|
16321
|
+
if (family === 4) return !blocked.check(address, "ipv4");
|
|
16322
|
+
if (family !== 6) return false;
|
|
16323
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
|
|
16324
|
+
if (dotted) return isPublicAddress(dotted[1]);
|
|
16325
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address);
|
|
16326
|
+
if (hex) {
|
|
16327
|
+
const [high, low] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
16328
|
+
return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
16329
|
+
}
|
|
16330
|
+
return !blocked.check(address, "ipv6");
|
|
16331
|
+
}
|
|
16332
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
16333
|
+
/** Far above any real theme file, and a bound on what one response can hold in memory. */
|
|
16334
|
+
var DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
16335
|
+
var DEFAULT_MAX_REDIRECTS = 3;
|
|
16336
|
+
var NotPublicError = class extends Error {
|
|
16337
|
+
code = "ENOTPUBLIC";
|
|
16338
|
+
};
|
|
16339
|
+
/** A lookup that answers only when every address the name resolves to is public. */
|
|
16340
|
+
function publicOnly(lookup) {
|
|
16341
|
+
return (hostname, options, callback) => {
|
|
16342
|
+
lookup(hostname, { all: true }, (error, addresses) => {
|
|
16343
|
+
if (error) return callback(error);
|
|
16344
|
+
const refused = addresses.find((entry) => !isPublicAddress(entry.address));
|
|
16345
|
+
if (refused || addresses.length === 0) return callback(new NotPublicError(`${hostname} resolves to ${refused?.address ?? "no address"}, which is not a public address.`));
|
|
16346
|
+
if (options.all) callback(null, addresses);
|
|
16347
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
16348
|
+
});
|
|
16349
|
+
};
|
|
16350
|
+
}
|
|
16351
|
+
function checkUrl(url) {
|
|
16352
|
+
if (url.protocol !== "https:") throw new Error(`Only https URLs can be fetched, not ${url.protocol}//${url.host}.`);
|
|
16353
|
+
if (url.username || url.password) throw new Error("A URL with credentials in it cannot be fetched.");
|
|
16354
|
+
}
|
|
16355
|
+
function fetchOnce(url, deps, deadline) {
|
|
16356
|
+
return new Promise((resolve, reject) => {
|
|
16357
|
+
const remaining = deadline - Date.now();
|
|
16358
|
+
if (remaining <= 0) return reject(/* @__PURE__ */ new Error(`Fetching ${url.href} took longer than ${deps.timeoutMs / 1e3} seconds.`));
|
|
16359
|
+
const req = deps.request(url, {
|
|
16360
|
+
method: "GET",
|
|
16361
|
+
lookup: publicOnly(deps.lookup),
|
|
16362
|
+
headers: {
|
|
16363
|
+
accept: "*/*",
|
|
16364
|
+
"user-agent": "etherpk-mcp"
|
|
16365
|
+
}
|
|
16366
|
+
}, (res) => {
|
|
16367
|
+
const status = res.statusCode ?? 0;
|
|
16368
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
16369
|
+
res.resume();
|
|
16370
|
+
return resolve({ redirect: new URL(res.headers.location, url) });
|
|
16371
|
+
}
|
|
16372
|
+
if (status < 200 || status >= 300) {
|
|
16373
|
+
res.resume();
|
|
16374
|
+
return reject(new Error(`${status} ${res.statusMessage ?? ""}`.trim()));
|
|
16375
|
+
}
|
|
16376
|
+
const chunks = [];
|
|
16377
|
+
let size = 0;
|
|
16378
|
+
res.on("data", (chunk) => {
|
|
16379
|
+
size += chunk.byteLength;
|
|
16380
|
+
if (size > deps.maxBytes) {
|
|
16381
|
+
req.destroy(/* @__PURE__ */ new Error(`${url.href} is larger than ${deps.maxBytes} bytes.`));
|
|
16382
|
+
return;
|
|
16383
|
+
}
|
|
16384
|
+
chunks.push(chunk);
|
|
16385
|
+
});
|
|
16386
|
+
res.on("end", () => {
|
|
16387
|
+
clearTimeout(timer);
|
|
16388
|
+
if (size <= deps.maxBytes) resolve({ text: Buffer.concat(chunks).toString("utf8") });
|
|
16389
|
+
});
|
|
16390
|
+
res.on("error", reject);
|
|
16391
|
+
});
|
|
16392
|
+
const timer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error(`Fetching ${url.href} took longer than ${deps.timeoutMs / 1e3} seconds.`)), remaining);
|
|
16393
|
+
req.on("error", (error) => {
|
|
16394
|
+
clearTimeout(timer);
|
|
16395
|
+
reject(error);
|
|
16396
|
+
});
|
|
16397
|
+
req.end();
|
|
16398
|
+
});
|
|
16399
|
+
}
|
|
16400
|
+
/** The text at a public https URL, under the rules at the top of this module. */
|
|
16401
|
+
async function fetchPublicText(href, options = {}) {
|
|
16402
|
+
const deps = {
|
|
16403
|
+
lookup: options.lookup ?? lookup,
|
|
16404
|
+
request: options.request ?? request,
|
|
16405
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
16406
|
+
maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
16407
|
+
maxRedirects: options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
|
16408
|
+
};
|
|
16409
|
+
const deadline = Date.now() + deps.timeoutMs;
|
|
16410
|
+
let url = new URL(href);
|
|
16411
|
+
for (let hops = 0;; hops += 1) {
|
|
16412
|
+
checkUrl(url);
|
|
16413
|
+
const hop = await fetchOnce(url, deps, deadline);
|
|
16414
|
+
if ("text" in hop) return hop.text;
|
|
16415
|
+
if (hops >= deps.maxRedirects) throw new Error(`${href} redirected more than ${deps.maxRedirects} times.`);
|
|
16416
|
+
url = hop.redirect;
|
|
16417
|
+
}
|
|
16418
|
+
}
|
|
16419
|
+
//#endregion
|
|
15280
16420
|
//#region src/publish-environment.ts
|
|
15281
16421
|
/**
|
|
15282
16422
|
* The publisher's environment in Node (ADR 0082, ADR 0084): themes resolved the way the Client
|
|
@@ -15286,11 +16426,8 @@ async function openDiagramRenderer(env) {
|
|
|
15286
16426
|
* counterpart of `host/browser-environment.ts`; the core is shared.
|
|
15287
16427
|
*/
|
|
15288
16428
|
var require = createRequire(import.meta.url);
|
|
15289
|
-
|
|
15290
|
-
|
|
15291
|
-
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
15292
|
-
return response.text();
|
|
15293
|
-
}
|
|
16429
|
+
/** A url theme's files, from public https hosts only (see public-fetch.ts). */
|
|
16430
|
+
var fetchText$1 = (url) => fetchPublicText(url);
|
|
15294
16431
|
var katexAssetsPromise = null;
|
|
15295
16432
|
/** `katex.min.css` plus its woff2 fonts under `fonts/`, as the stylesheet references them. */
|
|
15296
16433
|
async function katexAssets() {
|
|
@@ -15445,6 +16582,56 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
|
|
|
15445
16582
|
} };
|
|
15446
16583
|
}
|
|
15447
16584
|
//#endregion
|
|
16585
|
+
//#region src/local-folders.ts
|
|
16586
|
+
/**
|
|
16587
|
+
* Where the tools may write on this machine. A tool argument is chosen by the agent, and the agent
|
|
16588
|
+
* can be steered by what it reads in the notes, so a folder it names is only ever a place under
|
|
16589
|
+
* the graph's downloads directory (ADR 0086 keeps even that choice from `publish`). A name that
|
|
16590
|
+
* resolves outside it, directly or through a symbolic link, is refused.
|
|
16591
|
+
*/
|
|
16592
|
+
/** A folder the agent named that is not under the base; the tools report it as `invalid_argument`. */
|
|
16593
|
+
var FolderRefused = class extends Error {};
|
|
16594
|
+
/** Whether `child` is `parent` or a path beneath it; both already resolved. */
|
|
16595
|
+
function within(parent, child) {
|
|
16596
|
+
return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
16597
|
+
}
|
|
16598
|
+
/** The nearest part of `path` that exists on disk: the path itself, or its closest existing parent. */
|
|
16599
|
+
async function existingPart(path) {
|
|
16600
|
+
let at = path;
|
|
16601
|
+
for (;;) try {
|
|
16602
|
+
await lstat(at);
|
|
16603
|
+
return at;
|
|
16604
|
+
} catch {
|
|
16605
|
+
const up = dirname(at);
|
|
16606
|
+
if (up === at) return at;
|
|
16607
|
+
at = up;
|
|
16608
|
+
}
|
|
16609
|
+
}
|
|
16610
|
+
/**
|
|
16611
|
+
* The folder the agent asked for under `base`, or `fallback` (a path relative to `base`) when it
|
|
16612
|
+
* named none. A relative name is taken relative to `base`; an absolute one must already be under
|
|
16613
|
+
* it. Checked twice: as written, and after resolving every symbolic link in the part that exists.
|
|
16614
|
+
*/
|
|
16615
|
+
async function folderUnder(base, requested, fallback) {
|
|
16616
|
+
const root = resolve(base);
|
|
16617
|
+
const wanted = requested?.trim() ? requested.trim() : fallback;
|
|
16618
|
+
const target = resolve(root, wanted);
|
|
16619
|
+
if (!within(root, target)) throw new FolderRefused(`"${wanted}" is outside ${root}. Name a folder under it, or leave it out for the default.`);
|
|
16620
|
+
await mkdir(root, { recursive: true });
|
|
16621
|
+
if (!within(await realpath(root), await realpath(await existingPart(target)))) throw new FolderRefused(`"${wanted}" leads outside ${root} through a symbolic link.`);
|
|
16622
|
+
return target;
|
|
16623
|
+
}
|
|
16624
|
+
/**
|
|
16625
|
+
* Whether a preview page may load `url`: its own files and inline data, nothing else. A theme's
|
|
16626
|
+
* script runs when the preview is photographed, and a theme can come from a collaborator or a URL,
|
|
16627
|
+
* so the page is kept off the network and away from the rest of the disk.
|
|
16628
|
+
*/
|
|
16629
|
+
function previewRequestAllowed(url, folder) {
|
|
16630
|
+
if (url.startsWith("data:") || url.startsWith("blob:") || url === "about:blank") return true;
|
|
16631
|
+
const own = pathToFileURL(folder.endsWith(sep) ? folder : folder + sep).href;
|
|
16632
|
+
return url.startsWith(own);
|
|
16633
|
+
}
|
|
16634
|
+
//#endregion
|
|
15448
16635
|
//#region src/headless-assets.ts
|
|
15449
16636
|
/**
|
|
15450
16637
|
* What the asset tools need from a graph's [[Asset]]s, behind one seam for both backends
|
|
@@ -15455,9 +16642,46 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
|
|
|
15455
16642
|
* reference names an asset (`identify`), which is what the index is asked about when the tools
|
|
15456
16643
|
* decide whether an asset is reachable from a document the agent can read.
|
|
15457
16644
|
*/
|
|
15458
|
-
/**
|
|
15459
|
-
|
|
15460
|
-
|
|
16645
|
+
/**
|
|
16646
|
+
* The largest file `upload_asset` reads. The Sync Server applies its own, usually smaller, limit;
|
|
16647
|
+
* this one bounds what a single call can pull into memory whatever the backend.
|
|
16648
|
+
*/
|
|
16649
|
+
var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
16650
|
+
async function realOrResolved(path) {
|
|
16651
|
+
try {
|
|
16652
|
+
return await realpath(path);
|
|
16653
|
+
} catch {
|
|
16654
|
+
return resolve(path);
|
|
16655
|
+
}
|
|
16656
|
+
}
|
|
16657
|
+
/**
|
|
16658
|
+
* Refuse a file an agent should not be able to put into a graph. The path is a tool argument, and
|
|
16659
|
+
* an agent can be steered by what it reads, so an upload is a way to copy a local file somewhere
|
|
16660
|
+
* collaborators can read it. Refused: the Headless Client's cache (the graph's decrypted
|
|
16661
|
+
* contents) and config (sign-in tokens), and anything hidden (a dot-file, or a file inside a
|
|
16662
|
+
* dot-folder such as `.ssh` or `.aws`), which is where credentials and settings live. `real` has
|
|
16663
|
+
* every link resolved, so a link is judged by the file it leads to.
|
|
16664
|
+
*/
|
|
16665
|
+
async function refuseProtected(path, real, rules) {
|
|
16666
|
+
if (within(await realOrResolved(rules.downloadsDir), real)) return;
|
|
16667
|
+
if (within(await realOrResolved(cacheRoot(rules.env)), real)) throw new Error(`${path} is in the Headless Client's cache, which holds the graph's decrypted contents.`);
|
|
16668
|
+
const configFile = await realOrResolved(defaultConfigPath(rules.env));
|
|
16669
|
+
const configDir = await realOrResolved(dirname(defaultConfigPath({
|
|
16670
|
+
...rules.env,
|
|
16671
|
+
ETHERPK_MCP_CONFIG: void 0
|
|
16672
|
+
})));
|
|
16673
|
+
if (real === configFile || within(configDir, real)) throw new Error(`${path} is the Headless Client's config, which holds its sign-in tokens.`);
|
|
16674
|
+
const hidden = real.split(sep).find((part) => part.startsWith("."));
|
|
16675
|
+
if (hidden) throw new Error(`${path} is hidden (${hidden}), where credentials and settings are kept. Copy it to an ordinary folder to upload it.`);
|
|
16676
|
+
}
|
|
16677
|
+
/** A local file as `upload_asset` reads it: an ordinary file, not refused above, within the size limit. */
|
|
16678
|
+
async function readLocalFile(path, rules) {
|
|
16679
|
+
const real = await realpath(path);
|
|
16680
|
+
await refuseProtected(path, real, rules);
|
|
16681
|
+
const info = await stat(real);
|
|
16682
|
+
if (!info.isFile()) throw new Error(`${path} is not a file.`);
|
|
16683
|
+
if (info.size > 104857600) throw new Error(`${path} is larger than ${MAX_UPLOAD_BYTES / (1024 * 1024)} MiB.`);
|
|
16684
|
+
const buffer = await readFile(real);
|
|
15461
16685
|
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
|
15462
16686
|
bytes.set(buffer);
|
|
15463
16687
|
return {
|
|
@@ -15465,24 +16689,54 @@ async function readLocalFile(path) {
|
|
|
15465
16689
|
bytes
|
|
15466
16690
|
};
|
|
15467
16691
|
}
|
|
15468
|
-
/**
|
|
15469
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
16692
|
+
/** Names Windows reserves for devices, whatever the extension. */
|
|
16693
|
+
var RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
|
|
16694
|
+
var MAX_NAME_LENGTH = 120;
|
|
16695
|
+
/**
|
|
16696
|
+
* A file name for an asset's stored name. The stored name is metadata any collaborator can set,
|
|
16697
|
+
* so it is reduced to one plain file: the last path segment, letters, digits, marks, spaces and
|
|
16698
|
+
* `. _ - ( )` kept and everything else replaced, no leading dot (so never hidden, never `..`),
|
|
16699
|
+
* no trailing dot or space (Windows drops them), not a Windows device name, and short.
|
|
16700
|
+
*/
|
|
16701
|
+
function downloadName(name) {
|
|
16702
|
+
let safe = (name.split(/[\\/]/).pop() ?? "").replace(/[^\p{L}\p{M}\p{N} ._()-]/gu, "_").replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
|
|
16703
|
+
if (safe === "") return "asset";
|
|
16704
|
+
if (RESERVED.test(safe.split(".")[0])) safe = `_${safe}`;
|
|
16705
|
+
if (Array.from(safe).length <= MAX_NAME_LENGTH) return safe;
|
|
16706
|
+
const dot = safe.lastIndexOf(".");
|
|
16707
|
+
const ext = dot > 0 && safe.length - dot <= 20 ? safe.slice(dot) : "";
|
|
16708
|
+
return Array.from(safe.slice(0, safe.length - ext.length)).slice(0, MAX_NAME_LENGTH - ext.length).join("") + ext;
|
|
16709
|
+
}
|
|
16710
|
+
/** Whether `path` is an ordinary file (not a link) holding exactly `bytes`. */
|
|
16711
|
+
async function holds(path, bytes) {
|
|
16712
|
+
const info = await lstat(path);
|
|
16713
|
+
if (!info.isFile() || info.size !== bytes.byteLength) return false;
|
|
16714
|
+
return (await readFile(path)).equals(bytes);
|
|
16715
|
+
}
|
|
16716
|
+
/**
|
|
16717
|
+
* Write an asset's bytes under `directory`, named by {@link downloadName}, and never over
|
|
16718
|
+
* anything already there: the write fails rather than replace a file or follow a link left in
|
|
16719
|
+
* its place, and the next candidate is tried. A second asset that shares a display name lands
|
|
16720
|
+
* beside the first with a suffix, so an agent reading two `diagram.png`s from two pages gets
|
|
16721
|
+
* both; the same bytes again answer the file already written.
|
|
15472
16722
|
*/
|
|
15473
16723
|
async function writeDownload(directory, name, bytes) {
|
|
15474
16724
|
await mkdir(directory, { recursive: true });
|
|
15475
|
-
const
|
|
15476
|
-
const
|
|
15477
|
-
const
|
|
15478
|
-
|
|
15479
|
-
for (let n =
|
|
15480
|
-
const
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
|
|
15485
|
-
|
|
16725
|
+
const safe = downloadName(name);
|
|
16726
|
+
const dot = safe.lastIndexOf(".");
|
|
16727
|
+
const stem = dot > 0 ? safe.slice(0, dot) : safe;
|
|
16728
|
+
const ext = dot > 0 ? safe.slice(dot) : "";
|
|
16729
|
+
for (let n = 1; n <= 1e3; n += 1) {
|
|
16730
|
+
const candidate = join(directory, n === 1 ? safe : `${stem}-${n}${ext}`);
|
|
16731
|
+
try {
|
|
16732
|
+
await writeFile(candidate, bytes, { flag: "wx" });
|
|
16733
|
+
return candidate;
|
|
16734
|
+
} catch (error) {
|
|
16735
|
+
if (error.code !== "EEXIST") throw error;
|
|
16736
|
+
}
|
|
16737
|
+
if (await holds(candidate, bytes)) return candidate;
|
|
16738
|
+
}
|
|
16739
|
+
throw new Error(`${directory} already holds a thousand files named like ${safe}.`);
|
|
15486
16740
|
}
|
|
15487
16741
|
/** UTF-16 units of document text returned before `truncated` is set. */
|
|
15488
16742
|
var READ_TEXT_CAP = 2e5;
|
|
@@ -15604,7 +16858,7 @@ function bounded(value, fallback, max) {
|
|
|
15604
16858
|
*/
|
|
15605
16859
|
async function settle$2(graph) {
|
|
15606
16860
|
const result = await graph.settle();
|
|
15607
|
-
if (!result.settled) throw new ToolError("not_settled", result.message);
|
|
16861
|
+
if (!result.settled) throw new ToolError(result.code ?? "not_settled", result.message);
|
|
15608
16862
|
}
|
|
15609
16863
|
async function listDocuments(graph, args = {}) {
|
|
15610
16864
|
await graph.store.refresh();
|
|
@@ -15844,7 +17098,7 @@ async function setFrontmatter(graph, args) {
|
|
|
15844
17098
|
const body = await liveText(graph, identity);
|
|
15845
17099
|
refuseIfProtected(identity.concept, body);
|
|
15846
17100
|
const raw = graph.store.openRaw(identity.concept).getText();
|
|
15847
|
-
const next = patchedText(raw, args.patch);
|
|
17101
|
+
const next = withAliasesInAddedBlock(raw, patchedText(raw, args.patch), identity.aliases);
|
|
15848
17102
|
applyBlock(graph, identity.concept, raw, next);
|
|
15849
17103
|
await settle$2(graph);
|
|
15850
17104
|
return {
|
|
@@ -16029,7 +17283,10 @@ async function uploadAsset(graph, args) {
|
|
|
16029
17283
|
if (!args.path || args.path.trim() === "") throw new ToolError("invalid_argument", "path must name a file on this machine.");
|
|
16030
17284
|
let file;
|
|
16031
17285
|
try {
|
|
16032
|
-
file = await readLocalFile(args.path.trim()
|
|
17286
|
+
file = await readLocalFile(args.path.trim(), {
|
|
17287
|
+
env: process.env,
|
|
17288
|
+
downloadsDir: assets.downloadsDir
|
|
17289
|
+
});
|
|
16033
17290
|
} catch (error) {
|
|
16034
17291
|
throw new ToolError("invalid_argument", `Cannot read "${args.path}": ${error instanceof Error ? error.message : String(error)}`);
|
|
16035
17292
|
}
|
|
@@ -16074,8 +17331,15 @@ async function readAsset(graph, args) {
|
|
|
16074
17331
|
if (!documents) throw new ToolError("asset_not_found", `No document you can read references "${displayNameOf(ref)}", so it is not available here.`);
|
|
16075
17332
|
const asset = await assets.store.readBytes(ref);
|
|
16076
17333
|
if (!asset) throw new ToolError("asset_not_found", `The graph does not hold "${displayNameOf(ref)}" (the reference may be broken).`);
|
|
17334
|
+
let directory;
|
|
17335
|
+
try {
|
|
17336
|
+
directory = await folderUnder(assets.downloadsDir, args.out_dir, ".");
|
|
17337
|
+
} catch (error) {
|
|
17338
|
+
if (error instanceof FolderRefused) throw new ToolError("invalid_argument", error.message);
|
|
17339
|
+
throw error;
|
|
17340
|
+
}
|
|
16077
17341
|
return {
|
|
16078
|
-
path: await writeDownload(
|
|
17342
|
+
path: await writeDownload(directory, asset.name || displayNameOf(ref), asset.bytes),
|
|
16079
17343
|
name: asset.name || displayNameOf(ref),
|
|
16080
17344
|
type: asset.type,
|
|
16081
17345
|
bytes: asset.bytes.byteLength,
|
|
@@ -16308,9 +17572,30 @@ async function graphInfo(graph) {
|
|
|
16308
17572
|
function hostOf$1(host) {
|
|
16309
17573
|
return {
|
|
16310
17574
|
env: host?.env ?? process.env,
|
|
16311
|
-
cmd: host?.cmd ?? "etherpk-mcp"
|
|
17575
|
+
cmd: host?.cmd ?? "etherpk-mcp",
|
|
17576
|
+
via: host?.via ?? "agent"
|
|
16312
17577
|
};
|
|
16313
17578
|
}
|
|
17579
|
+
/**
|
|
17580
|
+
* The refusal for an id no publication has, naming the ids there are. An agent is pointed at
|
|
17581
|
+
* its tool; a person at the command line, who has no agent tool to run, at the Publish tab.
|
|
17582
|
+
*/
|
|
17583
|
+
function publicationNotFound(id, publications, via) {
|
|
17584
|
+
const where = via === "cli" ? "Settings → Publish in EtherPK lists them." : "list_publications shows them.";
|
|
17585
|
+
return new ToolError("publication_not_found", `No publication has the id "${id}". ${publications.length === 0 ? "This graph defines no publication yet." : `Its publications: ${publications.map((p) => p.id).join(", ")}.`} ${where}`);
|
|
17586
|
+
}
|
|
17587
|
+
/**
|
|
17588
|
+
* The publication with this id, or the refusal a publish would give. The command line asks
|
|
17589
|
+
* before it remembers a publish folder, so a mistyped id is not written down.
|
|
17590
|
+
*/
|
|
17591
|
+
async function findPublication(graph, id, host) {
|
|
17592
|
+
const { via } = hostOf$1(host);
|
|
17593
|
+
await graph.store.refresh();
|
|
17594
|
+
const { publications } = summarisePublishing((await graph.publishing.readSource()).source);
|
|
17595
|
+
const found = publications.find((p) => p.id === id.trim());
|
|
17596
|
+
if (!found) throw publicationNotFound(id, publications, via);
|
|
17597
|
+
return found;
|
|
17598
|
+
}
|
|
16314
17599
|
/** The publication page's mapping and body, through the raw handles: what `publish-service` writes to. */
|
|
16315
17600
|
function frontmatterStore(graph) {
|
|
16316
17601
|
return {
|
|
@@ -16411,8 +17696,9 @@ async function createPublication(graph, args, host) {
|
|
|
16411
17696
|
async function updatePublication(graph, args, host) {
|
|
16412
17697
|
const { env } = hostOf$1(host);
|
|
16413
17698
|
await graph.store.refresh();
|
|
16414
|
-
const
|
|
16415
|
-
|
|
17699
|
+
const all = summarisePublishing((await graph.publishing.readSource()).source).publications;
|
|
17700
|
+
const current = all.find((p) => p.id === args.id.trim());
|
|
17701
|
+
if (!current) throw publicationNotFound(args.id, all, hostOf$1(host).via);
|
|
16416
17702
|
const changes = args.changes ?? {};
|
|
16417
17703
|
if (changes.kind !== void 0 && changes.kind !== null && changes.kind !== "docs" && changes.kind !== "blog") throw new ToolError("invalid_argument", "kind must be \"docs\" or \"blog\".");
|
|
16418
17704
|
if (changes.selection !== void 0 && changes.selection !== null && changes.selection !== "named" && changes.selection !== "all-public") throw new ToolError("invalid_argument", "selection must be \"named\" or \"all-public\".");
|
|
@@ -16425,6 +17711,12 @@ async function updatePublication(graph, args, host) {
|
|
|
16425
17711
|
const folderOf = await foldersFor(graph, env);
|
|
16426
17712
|
return { publication: publicationView(after ?? current, folderOf(current.id)) };
|
|
16427
17713
|
}
|
|
17714
|
+
/** How many items fall under each key. */
|
|
17715
|
+
function countBy(items, keyOf) {
|
|
17716
|
+
const counts = {};
|
|
17717
|
+
for (const item of items) counts[keyOf(item)] = (counts[keyOf(item)] ?? 0) + 1;
|
|
17718
|
+
return counts;
|
|
17719
|
+
}
|
|
16428
17720
|
/** The first entries of a long list, and how many there were. */
|
|
16429
17721
|
function head(items, n = 20) {
|
|
16430
17722
|
return {
|
|
@@ -16435,15 +17727,22 @@ function head(items, n = 20) {
|
|
|
16435
17727
|
/**
|
|
16436
17728
|
* Publish one publication into its Publish Folder and report. The folder is the one a person
|
|
16437
17729
|
* set for this graph and publication on this machine (ADR 0086); a publish with Mermaid needs
|
|
16438
|
-
* the browser (ADR 0084).
|
|
17730
|
+
* the browser (ADR 0084).
|
|
17731
|
+
*
|
|
17732
|
+
* The result IS the report, trimmed to counts and first entries: nothing of it is written into
|
|
17733
|
+
* the folder, because a static host serves whatever the folder holds and the report names every
|
|
17734
|
+
* document the site leaves out, protected ones included. The documents left out are counted by
|
|
17735
|
+
* reason rather than listed: that is every document outside the publication, most of a large
|
|
17736
|
+
* graph. The command line prints {@link cliPublishOutput} instead, which names nothing the site
|
|
17737
|
+
* leaves out.
|
|
16439
17738
|
*/
|
|
16440
17739
|
async function publish(graph, args, host) {
|
|
16441
|
-
const { env, cmd } = hostOf$1(host);
|
|
17740
|
+
const { env, cmd, via } = hostOf$1(host);
|
|
16442
17741
|
await graph.store.refresh();
|
|
16443
17742
|
const { source, unsettled } = await graph.publishing.readSource();
|
|
16444
17743
|
const summary = summarisePublishing(source);
|
|
16445
17744
|
const publication = summary.publications.find((p) => p.id === args.id.trim());
|
|
16446
|
-
if (!publication) throw
|
|
17745
|
+
if (!publication) throw publicationNotFound(args.id, summary.publications, via);
|
|
16447
17746
|
const folder = (await foldersFor(graph, env))(publication.id);
|
|
16448
17747
|
if (!folder) {
|
|
16449
17748
|
const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
|
|
@@ -16490,20 +17789,47 @@ async function publish(graph, args, host) {
|
|
|
16490
17789
|
},
|
|
16491
17790
|
errors: run.report.errors,
|
|
16492
17791
|
warnings: run.report.warnings,
|
|
16493
|
-
missingLinks:
|
|
17792
|
+
missingLinks: {
|
|
17793
|
+
...head(run.report.missingLinks),
|
|
17794
|
+
byStatus: countBy(run.report.missingLinks, (link) => link.status)
|
|
17795
|
+
},
|
|
16494
17796
|
assets: {
|
|
16495
17797
|
copied: run.report.assets.copied.length,
|
|
16496
17798
|
missing: run.report.assets.missing
|
|
16497
17799
|
},
|
|
16498
17800
|
collisions: run.report.collisions,
|
|
16499
17801
|
publicInNoPublication: run.report.publicInNoPublication,
|
|
16500
|
-
...unsettled.length > 0 ? { unsettled } : {}
|
|
16501
|
-
report: run.report.ok ? `${folder}/etherpk-publish.json` : null
|
|
17802
|
+
...unsettled.length > 0 ? { unsettled } : {}
|
|
16502
17803
|
};
|
|
16503
17804
|
} finally {
|
|
16504
17805
|
await renderer?.dispose();
|
|
16505
17806
|
}
|
|
16506
17807
|
}
|
|
17808
|
+
/**
|
|
17809
|
+
* What `etherpk-mcp publish` prints: the tool's result with every name the site leaves out
|
|
17810
|
+
* reduced to a count. A scheduled publish's log can be as public as the site (a public CI run),
|
|
17811
|
+
* so links are counted by status (a "private" status says a hidden page exists), documents not
|
|
17812
|
+
* yet synced and public documents in no publication are counted, and warnings keep their codes
|
|
17813
|
+
* without the sentences that name pages. What was published, and the errors that stopped a
|
|
17814
|
+
* publish, are printed as they are: the site shows the one, and the other is what to fix.
|
|
17815
|
+
*/
|
|
17816
|
+
function cliPublishOutput(result) {
|
|
17817
|
+
const { unsettled, ...rest } = result;
|
|
17818
|
+
return {
|
|
17819
|
+
...rest,
|
|
17820
|
+
missingLinks: {
|
|
17821
|
+
total: result.missingLinks.total,
|
|
17822
|
+
byStatus: result.missingLinks.byStatus
|
|
17823
|
+
},
|
|
17824
|
+
warnings: {
|
|
17825
|
+
total: result.warnings.length,
|
|
17826
|
+
byCode: countBy(result.warnings, (warning) => warning.code)
|
|
17827
|
+
},
|
|
17828
|
+
publicInNoPublication: result.publicInNoPublication.length,
|
|
17829
|
+
...unsettled ? { unsettled: unsettled.length } : {},
|
|
17830
|
+
note: "Documents the site leaves out are counted here, not named. Settings → Publish in EtherPK shows the full report."
|
|
17831
|
+
};
|
|
17832
|
+
}
|
|
16507
17833
|
/** The publishing half of `graph_info`: each publication with its folder here, and whether diagrams can be drawn. */
|
|
16508
17834
|
async function publishingInfo(graph, host) {
|
|
16509
17835
|
const { env, cmd } = hostOf$1(host);
|
|
@@ -16626,15 +17952,25 @@ function hostOf(host) {
|
|
|
16626
17952
|
cmd: host?.cmd ?? "etherpk-mcp"
|
|
16627
17953
|
};
|
|
16628
17954
|
}
|
|
17955
|
+
/** The graph's downloads directory: every folder these tools write to, or read a theme back from, is under it. */
|
|
17956
|
+
function downloadsOf(graph) {
|
|
17957
|
+
return graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads");
|
|
17958
|
+
}
|
|
17959
|
+
/** A folder under the downloads directory, or the tool's refusal. */
|
|
17960
|
+
async function toolFolder(graph, requested, fallback) {
|
|
17961
|
+
try {
|
|
17962
|
+
return await folderUnder(downloadsOf(graph), requested, fallback);
|
|
17963
|
+
} catch (error) {
|
|
17964
|
+
if (error instanceof FolderRefused) throw new ToolError("invalid_argument", error.message);
|
|
17965
|
+
throw error;
|
|
17966
|
+
}
|
|
17967
|
+
}
|
|
16629
17968
|
async function settle(graph) {
|
|
16630
17969
|
const result = await graph.settle();
|
|
16631
17970
|
if (!result.settled) throw new ToolError("not_settled", result.message);
|
|
16632
17971
|
}
|
|
16633
|
-
|
|
16634
|
-
|
|
16635
|
-
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
16636
|
-
return response.text();
|
|
16637
|
-
}
|
|
17972
|
+
/** A url theme's files, from public https hosts only (see public-fetch.ts). */
|
|
17973
|
+
var fetchText = (url) => fetchPublicText(url);
|
|
16638
17974
|
/** The publications whose saved mapping names a theme: what makes it undeletable. */
|
|
16639
17975
|
async function publicationsUsing(graph, themeId) {
|
|
16640
17976
|
const { source } = await graph.publishing.readSource();
|
|
@@ -16731,12 +18067,11 @@ async function readTheme(graph, args) {
|
|
|
16731
18067
|
await graph.store.refresh();
|
|
16732
18068
|
const ref = args.ref.trim();
|
|
16733
18069
|
const { source, files, theme } = await filesOf(graph, ref);
|
|
16734
|
-
const folder =
|
|
18070
|
+
const folder = await toolFolder(graph, args.out_dir, join("themes", ref.replace(/[^A-Za-z0-9._-]/g, "_")));
|
|
18071
|
+
const site = nodeSiteFolder(folder);
|
|
16735
18072
|
const written = [];
|
|
16736
18073
|
for (const [path, text] of [...files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
16737
|
-
|
|
16738
|
-
await mkdir(dirname(full), { recursive: true });
|
|
16739
|
-
await writeFile(full, text);
|
|
18074
|
+
await site.writeFile(path, text);
|
|
16740
18075
|
written.push({
|
|
16741
18076
|
path,
|
|
16742
18077
|
bytes: Buffer.byteLength(text)
|
|
@@ -16888,22 +18223,43 @@ async function deleteThemeFile(graph, args) {
|
|
|
16888
18223
|
errors: validationOf(after)
|
|
16889
18224
|
};
|
|
16890
18225
|
}
|
|
16891
|
-
/**
|
|
16892
|
-
|
|
16893
|
-
|
|
18226
|
+
/** Bounds on a theme folder: far beyond a real theme, and short of a whole disk. */
|
|
18227
|
+
var THEME_FOLDER_LIMITS = {
|
|
18228
|
+
files: 500,
|
|
18229
|
+
depth: 6,
|
|
18230
|
+
fileBytes: 1024 * 1024,
|
|
18231
|
+
totalBytes: 8 * 1024 * 1024
|
|
18232
|
+
};
|
|
18233
|
+
/**
|
|
18234
|
+
* Every allowed file under a directory, as the theme's whole file set. Symbolic links are not
|
|
18235
|
+
* followed, so a folder cannot pull in a file from elsewhere on disk, and the walk stops at the
|
|
18236
|
+
* limits above rather than reading whatever tree it was pointed at.
|
|
18237
|
+
*/
|
|
18238
|
+
async function filesUnder(base) {
|
|
16894
18239
|
const files = /* @__PURE__ */ new Map();
|
|
16895
|
-
|
|
18240
|
+
let seen = 0;
|
|
18241
|
+
let total = 0;
|
|
18242
|
+
const walk = async (at, depth) => {
|
|
18243
|
+
if (depth > THEME_FOLDER_LIMITS.depth) return;
|
|
16896
18244
|
for (const entry of await readdir(at, { withFileTypes: true })) {
|
|
18245
|
+
if (entry.isSymbolicLink()) continue;
|
|
16897
18246
|
const full = join(at, entry.name);
|
|
16898
18247
|
if (entry.isDirectory()) {
|
|
16899
|
-
if (entry.name !== ".git" && entry.name !== "node_modules") await walk(full);
|
|
18248
|
+
if (entry.name !== ".git" && entry.name !== "node_modules") await walk(full, depth + 1);
|
|
16900
18249
|
continue;
|
|
16901
18250
|
}
|
|
18251
|
+
if (!entry.isFile()) continue;
|
|
18252
|
+
if (++seen > THEME_FOLDER_LIMITS.files) throw new Error(`it holds more than ${THEME_FOLDER_LIMITS.files} files`);
|
|
16902
18253
|
const path = relative(base, full).split(sep).join("/");
|
|
16903
|
-
if (isThemeFilePath(path))
|
|
18254
|
+
if (!isThemeFilePath(path)) continue;
|
|
18255
|
+
const { size } = await lstat(full);
|
|
18256
|
+
if (size > THEME_FOLDER_LIMITS.fileBytes) throw new Error(`${path} is larger than ${THEME_FOLDER_LIMITS.fileBytes} bytes`);
|
|
18257
|
+
total += size;
|
|
18258
|
+
if (total > THEME_FOLDER_LIMITS.totalBytes) throw new Error(`its theme files come to more than ${THEME_FOLDER_LIMITS.totalBytes} bytes`);
|
|
18259
|
+
files.set(path, await readFile(full, "utf8"));
|
|
16904
18260
|
}
|
|
16905
18261
|
};
|
|
16906
|
-
await walk(base);
|
|
18262
|
+
await walk(base, 0);
|
|
16907
18263
|
return files;
|
|
16908
18264
|
}
|
|
16909
18265
|
/**
|
|
@@ -16914,9 +18270,11 @@ async function filesUnder(dir) {
|
|
|
16914
18270
|
async function importThemeFolder(graph, args) {
|
|
16915
18271
|
await graph.store.refresh();
|
|
16916
18272
|
const theme = await requireEditable(graph, args.id.trim());
|
|
18273
|
+
if (!args.dir?.trim()) throw new ToolError("invalid_argument", "dir must name the theme folder, as read_theme returned it.");
|
|
18274
|
+
const dir = await toolFolder(graph, args.dir, "");
|
|
16917
18275
|
let files;
|
|
16918
18276
|
try {
|
|
16919
|
-
files = await filesUnder(
|
|
18277
|
+
files = await filesUnder(dir);
|
|
16920
18278
|
} catch (error) {
|
|
16921
18279
|
throw new ToolError("invalid_argument", `Cannot read "${args.dir}": ${error instanceof Error ? error.message : String(error)}`);
|
|
16922
18280
|
}
|
|
@@ -16953,8 +18311,8 @@ async function deleteTheme(graph, args) {
|
|
|
16953
18311
|
/**
|
|
16954
18312
|
* Render a theme to a folder on this machine and say where: the publication's real site when
|
|
16955
18313
|
* one is named, the sample site the Theme editor previews over otherwise. A preview is scratch,
|
|
16956
|
-
* not a [[Publish Folder]]:
|
|
16957
|
-
* emptied first
|
|
18314
|
+
* not a [[Publish Folder]]: it is always under the graph's downloads directory, the default folder
|
|
18315
|
+
* there is emptied first, and a named `out_dir` (also under it) is written into as it is. With `screenshots`, the browser
|
|
16958
18316
|
* photographs the front page and the first other page at desktop and phone widths, so the agent
|
|
16959
18317
|
* can look at what it changed.
|
|
16960
18318
|
*/
|
|
@@ -16987,7 +18345,7 @@ async function previewTheme(graph, args, host) {
|
|
|
16987
18345
|
});
|
|
16988
18346
|
const { bundle, report } = await publishPublication(source, publication, environment);
|
|
16989
18347
|
const scratch = !args.out_dir?.trim();
|
|
16990
|
-
const folder =
|
|
18348
|
+
const folder = await toolFolder(graph, args.out_dir, join("previews", themeRef.replace(/[^A-Za-z0-9._-]/g, "_")));
|
|
16991
18349
|
if (scratch) await rm(folder, {
|
|
16992
18350
|
recursive: true,
|
|
16993
18351
|
force: true
|
|
@@ -17018,12 +18376,10 @@ async function previewTheme(graph, args, host) {
|
|
|
17018
18376
|
await renderer?.dispose();
|
|
17019
18377
|
}
|
|
17020
18378
|
}
|
|
18379
|
+
/** The rendered site into the folder, through the writer `publish` uses: no path may leave the folder. */
|
|
17021
18380
|
async function writeBundle(folder, bundle) {
|
|
17022
|
-
|
|
17023
|
-
|
|
17024
|
-
await mkdir(dirname(full), { recursive: true });
|
|
17025
|
-
await writeFile(full, content);
|
|
17026
|
-
}
|
|
18381
|
+
const site = nodeSiteFolder(folder);
|
|
18382
|
+
for (const [path, content] of bundle) await site.writeFile(path, content);
|
|
17027
18383
|
}
|
|
17028
18384
|
/** The front page and the first other page, desktop and phone, as PNGs beside the site. */
|
|
17029
18385
|
async function photograph(env, folder, pages) {
|
|
@@ -17033,12 +18389,16 @@ async function photograph(env, folder, pages) {
|
|
|
17033
18389
|
const shots = [];
|
|
17034
18390
|
const targets = ["index.html", ...pages.filter((p) => p !== "index.html" && p !== "404.html").slice(0, 1)];
|
|
17035
18391
|
for (const page of targets) for (const width of [1280, 390]) {
|
|
17036
|
-
const context = await browser.newContext({
|
|
17037
|
-
|
|
17038
|
-
|
|
17039
|
-
|
|
18392
|
+
const context = await browser.newContext({
|
|
18393
|
+
viewport: {
|
|
18394
|
+
width,
|
|
18395
|
+
height: width === 390 ? 844 : 800
|
|
18396
|
+
},
|
|
18397
|
+
offline: true
|
|
18398
|
+
});
|
|
18399
|
+
await context.route("**/*", (route) => previewRequestAllowed(route.request().url(), folder) ? route.continue() : route.abort());
|
|
17040
18400
|
const tab = await context.newPage();
|
|
17041
|
-
await tab.goto(
|
|
18401
|
+
await tab.goto(pathToFileURL(join(folder, page)).href, { waitUntil: "load" });
|
|
17042
18402
|
const out = join(folder, `preview-${page.replace(/\.html$/, "")}-${width}.png`);
|
|
17043
18403
|
await tab.screenshot({
|
|
17044
18404
|
path: out,
|
|
@@ -17085,13 +18445,22 @@ function failed(error) {
|
|
|
17085
18445
|
isError: true
|
|
17086
18446
|
};
|
|
17087
18447
|
}
|
|
17088
|
-
async function
|
|
18448
|
+
async function runTool(work) {
|
|
17089
18449
|
try {
|
|
17090
18450
|
return ok(await work());
|
|
17091
18451
|
} catch (error) {
|
|
17092
18452
|
return failed(error);
|
|
17093
18453
|
}
|
|
17094
18454
|
}
|
|
18455
|
+
/**
|
|
18456
|
+
* The refusal every tool gives once the Sync Server has ended access, instead of answering from
|
|
18457
|
+
* a cache that can no longer be trusted or saved.
|
|
18458
|
+
*/
|
|
18459
|
+
function accessEndedError(graph, cmd) {
|
|
18460
|
+
const loss = graph.accessLoss();
|
|
18461
|
+
if (!loss) return null;
|
|
18462
|
+
return loss.kind === "membership" ? new ToolError("access_removed", `This account no longer has access to the graph: it left, was removed by the owner, or the graph was deleted. Run ${cmd} graphs to see the graphs it can reach.`) : new ToolError("token_revoked", `The Sync Server no longer accepts this computer's access token: it was revoked or has expired, or the account's password was reset. Run ${cmd} login again.`);
|
|
18463
|
+
}
|
|
17095
18464
|
var concept = z.string().min(1).describe("A page title, one of its aliases, a journal day as YYYY-MM-DD, or \"today\".");
|
|
17096
18465
|
var offset = z.number().int().nonnegative().optional().describe("Skip this many results (paging).");
|
|
17097
18466
|
/** A frontmatter value as JSON carries it; the writer turns it into YAML. */
|
|
@@ -17104,6 +18473,11 @@ var frontmatterValue = z.union([
|
|
|
17104
18473
|
z.record(z.string(), z.unknown())
|
|
17105
18474
|
]);
|
|
17106
18475
|
function createMcpServer(graph, info) {
|
|
18476
|
+
const run = (work) => runTool(() => {
|
|
18477
|
+
const ended = accessEndedError(graph, info.cmd ?? "etherpk-mcp");
|
|
18478
|
+
if (ended) throw ended;
|
|
18479
|
+
return work();
|
|
18480
|
+
});
|
|
17107
18481
|
const server = new McpServer({
|
|
17108
18482
|
name: "etherpk",
|
|
17109
18483
|
version: info.version
|
|
@@ -17118,7 +18492,7 @@ function createMcpServer(graph, info) {
|
|
|
17118
18492
|
"Start with graph_info to see what you are connected to. read_documents reads several documents in one call; tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
|
|
17119
18493
|
"Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it; its outline is the site navigation) and the public documents none takes; a document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings; publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
|
|
17120
18494
|
"Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own; read_theme writes a theme's files to a folder on this machine to read and edit; customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme); write_theme_file, delete_theme_file and import_theme_folder change a graph theme; preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
|
|
17121
|
-
"Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session).",
|
|
18495
|
+
"Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path; name a folder relative to it, never elsewhere.",
|
|
17122
18496
|
"The user documentation is at https://docs.etherpk.com."
|
|
17123
18497
|
].join("\n") });
|
|
17124
18498
|
server.registerTool("list_documents", {
|
|
@@ -17277,18 +18651,18 @@ function createMcpServer(graph, info) {
|
|
|
17277
18651
|
}, async (args) => run(() => setFrontmatter(graph, args)));
|
|
17278
18652
|
server.registerTool("upload_asset", {
|
|
17279
18653
|
title: "Upload an asset",
|
|
17280
|
-
description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"asset_refused\" when the server declines it on a quota.",
|
|
18654
|
+
description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache; with error \"asset_refused\" when the server declines it on a quota.",
|
|
17281
18655
|
inputSchema: {
|
|
17282
|
-
path: z.string().min(1).describe("Absolute path of
|
|
18656
|
+
path: z.string().min(1).describe("Absolute path of an ordinary file on this machine."),
|
|
17283
18657
|
name: z.string().min(1).optional().describe("The name to store it under; the file's own name by default.")
|
|
17284
18658
|
}
|
|
17285
18659
|
}, async (args) => run(() => uploadAsset(graph, args)));
|
|
17286
18660
|
server.registerTool("read_asset", {
|
|
17287
18661
|
title: "Read an asset",
|
|
17288
|
-
description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\".",
|
|
18662
|
+
description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
|
|
17289
18663
|
inputSchema: {
|
|
17290
18664
|
ref: z.string().min(1),
|
|
17291
|
-
out_dir: z.string().min(1).optional().describe("
|
|
18665
|
+
out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; the downloads directory itself by default. A folder outside it is refused.")
|
|
17292
18666
|
}
|
|
17293
18667
|
}, async (args) => run(() => readAsset(graph, args)));
|
|
17294
18668
|
server.registerTool("list_assets", {
|
|
@@ -17336,7 +18710,7 @@ function createMcpServer(graph, info) {
|
|
|
17336
18710
|
}, async (args) => run(() => updatePublication(graph, args, host)));
|
|
17337
18711
|
server.registerTool("publish", {
|
|
17338
18712
|
title: "Publish",
|
|
17339
|
-
description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then; the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The
|
|
18713
|
+
description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then; the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries; nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files; it does not deploy them.",
|
|
17340
18714
|
inputSchema: { id: z.string().min(1) }
|
|
17341
18715
|
}, async (args) => run(() => publish(graph, args, host)));
|
|
17342
18716
|
server.registerTool("list_themes", {
|
|
@@ -17349,7 +18723,7 @@ function createMcpServer(graph, info) {
|
|
|
17349
18723
|
description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back; a bundled or url theme cannot be edited in place at all (create_theme copies it).",
|
|
17350
18724
|
inputSchema: {
|
|
17351
18725
|
ref: z.string().min(1),
|
|
17352
|
-
out_dir: z.string().min(1).optional().describe("
|
|
18726
|
+
out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; themes/<ref> there by default. A folder outside it is refused.")
|
|
17353
18727
|
}
|
|
17354
18728
|
}, async (args) => run(() => readTheme(graph, args)));
|
|
17355
18729
|
server.registerTool("read_theme_file", {
|
|
@@ -17397,7 +18771,7 @@ function createMcpServer(graph, info) {
|
|
|
17397
18771
|
}, async (args) => run(() => deleteThemeFile(graph, args)));
|
|
17398
18772
|
server.registerTool("import_theme_folder", {
|
|
17399
18773
|
title: "Import a theme folder",
|
|
17400
|
-
description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. Validated afterwards.",
|
|
18774
|
+
description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory; a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
|
|
17401
18775
|
inputSchema: {
|
|
17402
18776
|
id: z.string().min(1),
|
|
17403
18777
|
dir: z.string().min(1)
|
|
@@ -17410,7 +18784,7 @@ function createMcpServer(graph, info) {
|
|
|
17410
18784
|
}, async (args) => run(() => deleteTheme(graph, args)));
|
|
17411
18785
|
server.registerTool("preview_theme", {
|
|
17412
18786
|
title: "Preview a theme",
|
|
17413
|
-
description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder.",
|
|
18787
|
+
description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default); a folder outside it is refused.",
|
|
17414
18788
|
inputSchema: {
|
|
17415
18789
|
theme: z.string().min(1).optional(),
|
|
17416
18790
|
publication: z.string().min(1).optional(),
|
|
@@ -17460,9 +18834,18 @@ function standalone(bytes) {
|
|
|
17460
18834
|
copy.set(bytes);
|
|
17461
18835
|
return copy;
|
|
17462
18836
|
}
|
|
18837
|
+
/**
|
|
18838
|
+
* `name` as one file of `folder`, or a refusal. The store passes names that came from documents
|
|
18839
|
+
* (an asset reference decodes to one), and `join` would follow a `..` or a separator out of the
|
|
18840
|
+
* graph; the browser's directory handles refuse such names, and this keeps the two adapters alike.
|
|
18841
|
+
*/
|
|
18842
|
+
function fileIn(folder, name) {
|
|
18843
|
+
if (!isSingleFileName(name)) throw new Error(`"${name}" is not a file name.`);
|
|
18844
|
+
return join(folder, name);
|
|
18845
|
+
}
|
|
17463
18846
|
function createNodeDirectoryAdapter(root) {
|
|
17464
18847
|
const dir = resolve(root);
|
|
17465
|
-
const path = (subdir, name) => join(dir, subdir, name);
|
|
18848
|
+
const path = (subdir, name) => fileIn(join(dir, subdir), name);
|
|
17466
18849
|
/**
|
|
17467
18850
|
* The file's mtime as integer milliseconds, as the browser's `File.lastModified` is. Node's
|
|
17468
18851
|
* `mtimeMs` is a float built from seconds plus nanoseconds, and on some filesystems the
|
|
@@ -17543,7 +18926,7 @@ function createNodeDirectoryAdapter(root) {
|
|
|
17543
18926
|
for (const subdir of SUBDIRS) await mkdir(join(dir, subdir), { recursive: true });
|
|
17544
18927
|
},
|
|
17545
18928
|
async readRootFile(name) {
|
|
17546
|
-
const file =
|
|
18929
|
+
const file = fileIn(dir, name);
|
|
17547
18930
|
let text;
|
|
17548
18931
|
try {
|
|
17549
18932
|
text = await readFile(file, "utf8");
|
|
@@ -17557,7 +18940,7 @@ function createNodeDirectoryAdapter(root) {
|
|
|
17557
18940
|
};
|
|
17558
18941
|
},
|
|
17559
18942
|
async writeRootFile(name, text) {
|
|
17560
|
-
const file =
|
|
18943
|
+
const file = fileIn(dir, name);
|
|
17561
18944
|
await writeFile(file, text, "utf8");
|
|
17562
18945
|
return {
|
|
17563
18946
|
text,
|
|
@@ -17609,7 +18992,7 @@ function bindServeLifetime(deps) {
|
|
|
17609
18992
|
* etherpk-mcp diagrams setup | status
|
|
17610
18993
|
*
|
|
17611
18994
|
* One config file holds a login per Sync Server (ADR 0075). `--sync-server` names the one a
|
|
17612
|
-
* command means and may be left out while only one
|
|
18995
|
+
* command means and may be left out while there is only one login.
|
|
17613
18996
|
*
|
|
17614
18997
|
* `serve` speaks MCP over stdio, so everything for the human goes to stderr; stdout belongs
|
|
17615
18998
|
* to the agent. `login` and `graphs` are interactive and print to stdout.
|
|
@@ -17628,11 +19011,11 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
|
|
|
17628
19011
|
Sign this machine in as a device of your account. Prompts for a Personal Access
|
|
17629
19012
|
Token (an account-wide one, from the Sync Server portal at <url>/account/tokens)
|
|
17630
19013
|
unless --pat or ETHERPK_PAT is given, then unlocks your keys by Device Approval:
|
|
17631
|
-
open EtherPK in a browser
|
|
19014
|
+
open EtherPK in a browser connected to the account with its keys unlocked and
|
|
17632
19015
|
confirm the code shown. Press r while waiting, or pass --recovery-code, to type
|
|
17633
19016
|
your Recovery Code instead (or ETHERPK_RECOVERY_CODE, for a scripted setup).
|
|
17634
19017
|
${CMD} graphs [--sync-server <url>]
|
|
17635
|
-
List the synced graphs each
|
|
19018
|
+
List the synced graphs each logged-in account can reach, by name and id.
|
|
17636
19019
|
${CMD} serve --graph <id or name> [--sync-server <url>] [--no-semantic]
|
|
17637
19020
|
Serve one synced graph to an agent over stdio. For Claude Code:
|
|
17638
19021
|
claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --sync-server <url> --graph <id>
|
|
@@ -17670,9 +19053,9 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
|
|
|
17670
19053
|
${CMD} diagrams status
|
|
17671
19054
|
Which browser a publish would use, if any.
|
|
17672
19055
|
|
|
17673
|
-
This machine can
|
|
17674
|
-
|
|
17675
|
-
|
|
19056
|
+
This machine can hold logins for several Sync Servers at once; --sync-server says which one
|
|
19057
|
+
a command means, and can be left out while there is only one. The config file is
|
|
19058
|
+
${defaultConfigPath()} (override with
|
|
17676
19059
|
ETHERPK_MCP_CONFIG); cached graphs live under ~/.cache/etherpk/mcp (override with
|
|
17677
19060
|
ETHERPK_MCP_CACHE_DIR).
|
|
17678
19061
|
Docs: https://docs.etherpk.com/using-ai-agents-with-your-notes
|
|
@@ -17681,8 +19064,12 @@ function fail(message) {
|
|
|
17681
19064
|
console.error(message);
|
|
17682
19065
|
process.exit(1);
|
|
17683
19066
|
}
|
|
17684
|
-
|
|
17685
|
-
|
|
19067
|
+
/**
|
|
19068
|
+
* Ask on the terminal; `hint` says how to give the answer when there is none (a script, or
|
|
19069
|
+
* an agent starting the process), since each question has its own option or variable.
|
|
19070
|
+
*/
|
|
19071
|
+
async function ask(question, { secret = false, hint }) {
|
|
19072
|
+
if (!process.stdin.isTTY) fail(`${question.replace(/:\s*$/, "")}: no terminal to ask on; ${hint}.`);
|
|
17686
19073
|
if (!secret) {
|
|
17687
19074
|
const rl = createInterface({
|
|
17688
19075
|
input: process.stdin,
|
|
@@ -17730,24 +19117,30 @@ function requireServer(config, wanted) {
|
|
|
17730
19117
|
if (selection.ok) return selection.credentials;
|
|
17731
19118
|
switch (selection.reason) {
|
|
17732
19119
|
case "none": return fail(`Not logged in on this machine. Run: ${CMD} login --sync-server <url>`);
|
|
17733
|
-
case "unknown": return fail(`Not logged in to ${selection.syncServer}.
|
|
17734
|
-
case "ambiguous": return fail(`
|
|
19120
|
+
case "unknown": return fail(`Not logged in to ${selection.syncServer}. Logged in to: ${selection.known.join(", ") || "(none)"}. Run: ${CMD} login --sync-server ${selection.syncServer}`);
|
|
19121
|
+
case "ambiguous": return fail(`Logged in to more than one Sync Server here: ${selection.known.join(", ")}. Say which with --sync-server <url>.`);
|
|
17735
19122
|
}
|
|
17736
19123
|
}
|
|
17737
19124
|
async function login(args) {
|
|
17738
19125
|
const path = defaultConfigPath();
|
|
17739
19126
|
const config = await readConfig(path) ?? emptyConfig();
|
|
17740
19127
|
const known = Object.keys(config.servers);
|
|
17741
|
-
const syncServer = normaliseSyncServer(args["sync-server"] ?? (known.length === 1 ? known[0] : await ask("Sync Server URL: ")));
|
|
19128
|
+
const syncServer = normaliseSyncServer(args["sync-server"] ?? (known.length === 1 ? known[0] : await ask("Sync Server URL: ", { hint: "pass --sync-server <url>" })));
|
|
17742
19129
|
if (!/^https?:\/\//.test(syncServer)) fail("The Sync Server must be an http(s) URL.");
|
|
17743
|
-
const pat = args.pat ?? process.env.ETHERPK_PAT ?? await ask(`Personal Access Token (account-wide, from ${syncServer}/account/tokens): `, {
|
|
19130
|
+
const pat = args.pat ?? process.env.ETHERPK_PAT ?? await ask(`Personal Access Token (account-wide, from ${syncServer}/account/tokens): `, {
|
|
19131
|
+
secret: true,
|
|
19132
|
+
hint: "set ETHERPK_PAT or pass --pat <token>"
|
|
19133
|
+
});
|
|
17744
19134
|
if (!pat) fail("A Personal Access Token is required.");
|
|
17745
19135
|
const account = await connectAccount({
|
|
17746
19136
|
syncServer,
|
|
17747
19137
|
pat
|
|
17748
19138
|
});
|
|
17749
|
-
console.log(`
|
|
17750
|
-
const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", {
|
|
19139
|
+
console.log(`Connected to ${syncServer} as ${account.principal.email ?? account.principal.name ?? account.principal.id}.`);
|
|
19140
|
+
const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", {
|
|
19141
|
+
secret: true,
|
|
19142
|
+
hint: "set ETHERPK_RECOVERY_CODE"
|
|
19143
|
+
}));
|
|
17751
19144
|
const vaultKey = args["recovery-code"] ? await byRecoveryCode() : await approveOrFallBack(account, byRecoveryCode);
|
|
17752
19145
|
config.servers[syncServer] = {
|
|
17753
19146
|
pat,
|
|
@@ -17756,7 +19149,7 @@ async function login(args) {
|
|
|
17756
19149
|
await writeConfig(path, config);
|
|
17757
19150
|
console.log(`Keys unlocked and cached in ${path} (owner-only). Anyone who can read your files on this machine can read this account, as with a signed-in browser.`);
|
|
17758
19151
|
const others = Object.keys(config.servers).filter((server) => server !== syncServer);
|
|
17759
|
-
if (others.length > 0) console.log(`Also
|
|
19152
|
+
if (others.length > 0) console.log(`Also logged in to ${others.join(", ")}; commands now need --sync-server <url> to say which.`);
|
|
17760
19153
|
await listGraphs({
|
|
17761
19154
|
syncServer,
|
|
17762
19155
|
pat,
|
|
@@ -17770,23 +19163,23 @@ async function login(args) {
|
|
|
17770
19163
|
* code instead; without a terminal the wait runs to its outcome.
|
|
17771
19164
|
*/
|
|
17772
19165
|
async function approveOrFallBack(account, byRecoveryCode) {
|
|
17773
|
-
const
|
|
19166
|
+
const controls = approvalWaitControls();
|
|
17774
19167
|
const io = {
|
|
17775
19168
|
say: (line) => console.log(line),
|
|
17776
|
-
sleep: (ms) =>
|
|
17777
|
-
signal:
|
|
19169
|
+
sleep: (ms) => sleepUnlessAborted(ms, controls.signal),
|
|
19170
|
+
signal: controls.signal,
|
|
17778
19171
|
clientUrl: account.clientUrl
|
|
17779
19172
|
};
|
|
17780
19173
|
const stdin = process.stdin;
|
|
17781
19174
|
const interactive = stdin.isTTY === true;
|
|
17782
|
-
const onKey = (chunk) =>
|
|
17783
|
-
|
|
17784
|
-
|
|
17785
|
-
|
|
17786
|
-
|
|
17787
|
-
|
|
17788
|
-
|
|
17789
|
-
|
|
19175
|
+
const onKey = (chunk) => controls.onKey(chunk.toString("utf8"));
|
|
19176
|
+
const quitSignals = [
|
|
19177
|
+
"SIGINT",
|
|
19178
|
+
"SIGTERM",
|
|
19179
|
+
"SIGHUP"
|
|
19180
|
+
];
|
|
19181
|
+
const onSignal = (name) => controls.onSignal(name);
|
|
19182
|
+
for (const name of quitSignals) process.once(name, onSignal);
|
|
17790
19183
|
if (interactive) {
|
|
17791
19184
|
console.log("(Press r to type your Recovery Code instead.)");
|
|
17792
19185
|
stdin.setRawMode(true);
|
|
@@ -17800,12 +19193,17 @@ async function approveOrFallBack(account, byRecoveryCode) {
|
|
|
17800
19193
|
if (!(error instanceof ApprovalAbandoned)) throw error;
|
|
17801
19194
|
abandoned = true;
|
|
17802
19195
|
} finally {
|
|
19196
|
+
for (const name of quitSignals) process.off(name, onSignal);
|
|
17803
19197
|
if (interactive) {
|
|
17804
19198
|
stdin.off("data", onKey);
|
|
17805
19199
|
stdin.setRawMode(false);
|
|
17806
19200
|
stdin.pause();
|
|
17807
19201
|
}
|
|
17808
19202
|
}
|
|
19203
|
+
if (controls.exitCode !== null) {
|
|
19204
|
+
console.log("");
|
|
19205
|
+
process.exit(controls.exitCode);
|
|
19206
|
+
}
|
|
17809
19207
|
if (!abandoned) throw new Error("unreachable");
|
|
17810
19208
|
console.log("Approval cancelled; unlocking with your Recovery Code instead.");
|
|
17811
19209
|
return byRecoveryCode();
|
|
@@ -17922,7 +19320,13 @@ async function publishCommand(args) {
|
|
|
17922
19320
|
"no-semantic": true
|
|
17923
19321
|
};
|
|
17924
19322
|
const { graph, graphName } = folder ? await openFolderForServe(folder, quiet) : await openSyncedForServe(wanted, quiet);
|
|
19323
|
+
const host = {
|
|
19324
|
+
env: process.env,
|
|
19325
|
+
cmd: CMD,
|
|
19326
|
+
via: "cli"
|
|
19327
|
+
};
|
|
17925
19328
|
try {
|
|
19329
|
+
await findPublication(graph, publication, host);
|
|
17926
19330
|
const configPath = defaultPublishFoldersPath(process.env);
|
|
17927
19331
|
const key = publishGraphKey(graph.backend, graph.graphId);
|
|
17928
19332
|
if (args.out?.trim()) {
|
|
@@ -17930,11 +19334,8 @@ async function publishCommand(args) {
|
|
|
17930
19334
|
await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, publication, out));
|
|
17931
19335
|
console.error(`etherpk-mcp: publish folder for "${publication}" of "${graphName}" set to ${out} (remembered in ${configPath}).`);
|
|
17932
19336
|
} else if (!publishFolderOf(await readPublishFolders(configPath), key, publication)) fail(`No publish folder is set for "${publication}" of "${graphName}" on this machine. Pass --out <dir> once; it is remembered.`);
|
|
17933
|
-
const result = await publish(graph, { id: publication },
|
|
17934
|
-
|
|
17935
|
-
cmd: CMD
|
|
17936
|
-
});
|
|
17937
|
-
console.log(JSON.stringify(result, null, 2));
|
|
19337
|
+
const result = await publish(graph, { id: publication }, host);
|
|
19338
|
+
console.log(JSON.stringify(cliPublishOutput(result), null, 2));
|
|
17938
19339
|
if (!result.ok) process.exitCode = 1;
|
|
17939
19340
|
} catch (error) {
|
|
17940
19341
|
if (error instanceof ToolError) fail(`etherpk-mcp: ${error.message}`);
|
|
@@ -18016,6 +19417,11 @@ async function openSyncedForServe(wanted, args) {
|
|
|
18016
19417
|
const account = await connectAccount(login);
|
|
18017
19418
|
const vault = await openAccountVault(account.api, fromBase64Url(login.vaultKey));
|
|
18018
19419
|
const graphs = await account.api.listGraphs();
|
|
19420
|
+
const swept = await removeUnlistedGraphCaches(process.env, account.serverBaseUrl, account.principal.id, graphs.map((graph) => graph.id)).catch((error) => {
|
|
19421
|
+
console.error(`etherpk-mcp: could not tidy the cache of graphs this server no longer lists: ${error instanceof Error ? error.message : String(error)}`);
|
|
19422
|
+
return [];
|
|
19423
|
+
});
|
|
19424
|
+
if (swept.length > 0) console.error(`etherpk-mcp: removed this computer's copy of ${swept.length === 1 ? "a graph" : `${swept.length} graphs`} ${account.serverBaseUrl} no longer lists for you: ${swept.join(", ")}.`);
|
|
18019
19425
|
let graphId = graphs.find((graph) => graph.id === wanted)?.id;
|
|
18020
19426
|
let graphName = null;
|
|
18021
19427
|
if (!graphId) {
|
|
@@ -18027,6 +19433,8 @@ async function openSyncedForServe(wanted, args) {
|
|
|
18027
19433
|
}
|
|
18028
19434
|
if (!graphId) fail(`No synced graph on ${login.syncServer} is named or identified by "${wanted}". Run: ${CMD} graphs`);
|
|
18029
19435
|
const { record, keyring } = resolveGraphById(graphs, vault, graphId);
|
|
19436
|
+
const persistDir = graphCacheDir(process.env, account.serverBaseUrl, graphId);
|
|
19437
|
+
await stampGraphCacheOwner(persistDir, account.principal.id);
|
|
18030
19438
|
console.error(`etherpk-mcp: opening graph ${graphId} on ${account.serverBaseUrl}…`);
|
|
18031
19439
|
const graph = await openHeadlessGraph({
|
|
18032
19440
|
graphId,
|
|
@@ -18036,11 +19444,12 @@ async function openSyncedForServe(wanted, args) {
|
|
|
18036
19444
|
token: account.tokenFor(graphId),
|
|
18037
19445
|
presenceName: `Agent on ${hostname()}`,
|
|
18038
19446
|
readyTimeoutMs: 2e4,
|
|
18039
|
-
persistDir
|
|
19447
|
+
persistDir,
|
|
18040
19448
|
assets: { baseUrl: account.serverBaseUrl },
|
|
18041
19449
|
embeddingModel: embeddingModelFor(args),
|
|
18042
19450
|
onSemanticProgress: reportSemanticProgress,
|
|
18043
19451
|
onError: (error) => console.error(`etherpk-mcp: ${error.message}`),
|
|
19452
|
+
onAccessLost: (loss) => console.error(loss.kind === "membership" ? `etherpk-mcp: ${account.serverBaseUrl} ended this account's access to graph ${graphId}: it left, was removed, or the graph was deleted. Stopped syncing. Run: ${CMD} graphs` : `etherpk-mcp: the token for ${account.serverBaseUrl} was revoked or is no longer valid. Stopped syncing. Run: ${CMD} login --sync-server ${login.syncServer}`),
|
|
18044
19453
|
publishName: createGraphNamePublisher({
|
|
18045
19454
|
api: account.api,
|
|
18046
19455
|
keyring,
|