@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/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.0",
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": "^1.30.0",
56
- "@noble/curves": "^2.2.0",
57
- "@noble/hashes": "^2.2.0",
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": "^0.2.117",
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": "^1.0.7",
69
+ "y-protocols": "1.0.7",
67
70
  "yaml": "^2.9.0",
68
- "yjs": "^13.6.31",
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
- throw new SyncApiError((typeof body?.error === "object" ? body.error.message : body?.code) ?? `HTTP ${res.status}`, res.status, body?.code, body?.retryable === true);
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(`/api/v1/sync/identity?email=${encodeURIComponent(email)}`).catch((e) => {
675
- if (e instanceof SyncApiError && (e.status === 404 || e.status === 409)) return null;
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
- const me = await account.api.me();
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 = 12`);
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 === 12;
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
- /** Write one document's derived rows under an already-established page id. */
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) db.run("INSERT INTO block_fts (rowid, text) VALUES (?,?)", [pageId * BLOCK_FTS_STRIDE + b.localId, b.text]);
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})`, [generation, ...names]);
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.v12.sqlite`;
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
- /** Reconnect after a dropped socket (simple; backoff tuned later). */
6479
- var RECONNECT_MS = 50;
6480
- /** Retry after a token mint failed - a server round trip, so slower than a reconnect. */
6481
- var TOKEN_RETRY_MS = 2e3;
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(/* @__PURE__ */ new Error("the sync relay did not answer a watermark check"));
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") ackRoute.set(message.outboxId, docId);
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) engine(docId).receive(message).then(() => ackRoute.delete(message.outboxId)).catch(report);
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. Sends
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
- deps.token().then((token) => {
6842
- if (disposed) return;
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
- subscribed.clear();
6873
- ackRoute.clear();
6874
- catchupInFlight = void 0;
6875
- foregroundCatchups.length = 0;
6876
- backgroundCatchups.length = 0;
6877
- for (const pending of watermarkRequests.values()) {
6878
- clearTimeout(pending.timer);
6879
- pending.reject(new WatermarkConnectionInterruptedError());
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
- watermarkRequests.clear();
6882
- if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
7394
+ backOff();
6883
7395
  });
6884
- }, () => {
6885
- if (!disposed) setTimeout(connect, TOKEN_RETRY_MS);
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 Promise.all(docIds.map((docId) => readied.get(docId) ?? Promise.resolve()));
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 Promise.all(docIds.map((docId) => readied.get(docId) ?? Promise.resolve()));
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
- /** The on-disk name referenced by a doc-relative asset ref, or `null` if `ref` is not an asset reference. */
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
- return match ? decodeURIComponent(match[1]) : null;
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 existingFm = parseFrontmatter((await adapter.read(survivor.subdir, survivor.fileName)).text);
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
- const key = conceptKey(target);
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 (open.get(conceptKey(target)) ?? (this.open(target), open.get(conceptKey(target))))?.ready;
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 release = () => graph.retireDocs(unique);
11682
+ const held = [];
11683
+ const release = () => graph.retireDocs(held);
10997
11684
  try {
10998
- for (let start = 0; start < unique.length; start += ENGINE_BATCH) await graph.readyDocs(unique.slice(start, 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
- out.set(conceptKey(conceptOf(entry)), docId);
11059
- for (const alias of entry.aliases ?? []) out.set(conceptKey(alias), docId);
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
- const entry = docId ? registry.get(docId) : void 0;
11494
- if (!docId || !entry) return null;
11495
- return indexSnapshotFor(entry, graph.docSync(docId).doc.getText("content").toString());
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 === 401 || error.status === 408 || error.status === 429 || error.status >= 500;
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
- * Fetch, decrypt and reassemble one asset. Both read paths go through here: the mirror
11914
- * wants the bytes themselves, a viewer wants them behind an object URL.
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 readAssetBytes(ref) {
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 res = await f(`${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`, { headers: { "x-sync-token": await deps.syncToken() } });
11920
- if (!res.ok) return null;
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 f(body.downloadUrls[n]);
11932
- if (!chunkRes.ok) return null;
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: metadata.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: readAssetBytes,
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 readAssetBytes(ref);
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) signed in to this account`);
12705
- io.say("with its graphs unlocked. A prompt will show a code; confirm it matches this one:");
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
- /** Recovery Code → wrap key → open the vault; what comes back is the vault key to cache. */
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
- const wrapKey = await deriveVaultWrapKey(normalizeRecoveryCode(code));
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
- /** Stale files are removed only from the places the publisher owns. */
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
- return decodeURIComponent(raw);
14761
+ name = decodeURIComponent(raw);
13824
14762
  } catch {
13825
- return raw;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -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: target,
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) => mermaidSvg.get(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 includes.set(slot, renderer.render(body).html);
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
- async function fetchText$1(url) {
15290
- const response = await fetch(url);
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
- /** A local file as `upload_asset` reads it. */
15459
- async function readLocalFile(path) {
15460
- const buffer = await readFile(path);
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
- * Write an asset's bytes under `directory` as `name`, never over a different file of the same
15470
- * name: a second asset that happens to share a display name lands beside it with a suffix, so
15471
- * an agent reading two `diagram.png`s from two pages gets both.
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 dot = name.lastIndexOf(".");
15476
- const stem = dot > 0 ? name.slice(0, dot) : name;
15477
- const ext = dot > 0 ? name.slice(dot) : "";
15478
- let candidate = join(directory, name);
15479
- for (let n = 2; existsSync(candidate); n += 1) {
15480
- const existing = await readFile(candidate);
15481
- if (existing.byteLength === bytes.byteLength && existing.every((b, i) => b === bytes[i])) return candidate;
15482
- candidate = join(directory, `${stem}-${n}${ext}`);
15483
- }
15484
- await writeFile(candidate, bytes);
15485
- return candidate;
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(args.out_dir?.trim() || assets.downloadsDir, asset.name || displayNameOf(ref), asset.bytes),
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 current = summarisePublishing((await graph.publishing.readSource()).source).publications.find((p) => p.id === args.id.trim());
16415
- if (!current) throw new ToolError("publication_not_found", `No publication has the id "${args.id}"; list_publications shows them.`);
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). The full report is in the folder as `etherpk-publish.json`.
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 new ToolError("publication_not_found", `No publication has the id "${args.id}"; list_publications shows them.`);
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: head(run.report.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
- async function fetchText(url) {
16634
- const response = await fetch(url);
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 = resolve(args.out_dir?.trim() || join(graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads"), "themes", ref.replace(/[^A-Za-z0-9._-]/g, "_")));
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
- const full = join(folder, ...path.split("/"));
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
- /** Every allowed file under a directory, as the theme's whole file set. */
16892
- async function filesUnder(dir) {
16893
- const base = resolve(dir);
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
- const walk = async (at) => {
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)) files.set(path, await readFile(full, "utf8"));
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(args.dir);
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]]: the default location is under the graph's downloads directory and is
16957
- * emptied first; a named `out_dir` is written into as it is. With `screenshots`, the browser
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 = resolve(args.out_dir?.trim() || join(graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads"), "previews", themeRef.replace(/[^A-Za-z0-9._-]/g, "_")));
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
- for (const [path, content] of bundle) {
17023
- const full = join(folder, ...path.split("/"));
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({ viewport: {
17037
- width,
17038
- height: width === 390 ? 844 : 800
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(`file://${join(folder, page)}`, { waitUntil: "load" });
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 run(work) {
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 the file on this machine."),
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("Directory to write into; the graph's downloads directory by default.")
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 full report is written to the folder as etherpk-publish.json. Publishing writes files; it does not deploy them.",
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("Directory to write into; the graph's downloads directory by default.")
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 = join(dir, name);
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 = join(dir, name);
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 is signed in.
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 signed in to the account with its graphs unlocked and
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 signed-in account can reach, by name and id.
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 be signed in to several Sync Servers at once (a self-hosted one beside the
17674
- managed service, say); --sync-server says which one a command means, and can be left out
17675
- while only one is signed in. The config file is ${defaultConfigPath()} (override with
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
- async function ask(question, { secret = false } = {}) {
17685
- if (!process.stdin.isTTY) fail(`${question} - no terminal to ask on; pass it as an option.`);
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}. Signed in to: ${selection.known.join(", ") || "(none)"}. Run: ${CMD} login --sync-server ${selection.syncServer}`);
17734
- case "ambiguous": return fail(`Signed in to more than one Sync Server here: ${selection.known.join(", ")}. Say which with --sync-server <url>.`);
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): `, { secret: true });
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(`Signed in to ${syncServer} as ${account.principal.email ?? account.principal.name ?? account.principal.id}.`);
17750
- const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", { secret: true }));
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 signed in to ${others.join(", ")}; commands now need --sync-server <url> to say which.`);
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 abort = new AbortController();
19166
+ const controls = approvalWaitControls();
17774
19167
  const io = {
17775
19168
  say: (line) => console.log(line),
17776
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
17777
- signal: abort.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
- const key = chunk.toString("utf8");
17784
- if (key === "r" || key === "R") abort.abort();
17785
- if (key === "") {
17786
- console.log("");
17787
- process.exit(130);
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
- env: process.env,
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: graphCacheDir(process.env, account.serverBaseUrl, graphId),
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,