relic-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/relic-mcp.js CHANGED
@@ -190,6 +190,24 @@ function encodeKey(key) {
190
190
  }
191
191
  return out;
192
192
  }
193
+ function decodeKey(encoded) {
194
+ const bytes = [];
195
+ let accumulator = 0;
196
+ let bitsHeld = 0;
197
+ for (const ch of encoded) {
198
+ const index = B64URL.indexOf(ch);
199
+ if (index < 0) {
200
+ throw new MalformedFragmentError(`key contains ${JSON.stringify(ch)}, outside base64url`);
201
+ }
202
+ accumulator = accumulator << 6 | index;
203
+ bitsHeld += 6;
204
+ if (bitsHeld >= 8) {
205
+ bitsHeld -= 8;
206
+ bytes.push(accumulator >>> bitsHeld & 255);
207
+ }
208
+ }
209
+ return new Uint8Array(bytes);
210
+ }
193
211
  function generateKey() {
194
212
  const key = new Uint8Array(KEY_BYTES);
195
213
  crypto.getRandomValues(key);
@@ -329,6 +347,7 @@ function concat(parts) {
329
347
  // ../relic-format/src/id.ts
330
348
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
331
349
  var ID_ENTROPY_BYTES = 16;
350
+ var ID_LENGTH = 26;
332
351
  var RESERVED_SEGMENTS = [
333
352
  "abuse",
334
353
  "policy",
@@ -341,7 +360,8 @@ var RESERVED_SEGMENTS = [
341
360
  "api",
342
361
  "health",
343
362
  ".well-known",
344
- "sandbox.html"
363
+ "sandbox.html",
364
+ "install"
345
365
  ];
346
366
  var RESERVED_NORMALIZED = new Set(RESERVED_SEGMENTS.map((word) => foldAliases(word.toLowerCase())));
347
367
 
@@ -365,6 +385,32 @@ function foldAliases(lowered) {
365
385
  }
366
386
  return out;
367
387
  }
388
+ function normalizeRelicId(candidate) {
389
+ return foldAliases(candidate.toLowerCase());
390
+ }
391
+ function parseRelicId(candidate) {
392
+ if (candidate.length !== ID_LENGTH) {
393
+ throw new InvalidRelicIdError("length", `relic id must be ${ID_LENGTH} characters, got ${candidate.length}`);
394
+ }
395
+ const normalized = normalizeRelicId(candidate);
396
+ for (const ch of normalized) {
397
+ if (!ALPHABET.includes(ch)) {
398
+ throw new InvalidRelicIdError("alphabet", `relic id contains ${JSON.stringify(ch)}, outside Crockford base32`);
399
+ }
400
+ }
401
+ if (RESERVED_NORMALIZED.has(normalized)) {
402
+ throw new InvalidRelicIdError("reserved", `relic id normalizes onto the reserved segment ${normalized}`);
403
+ }
404
+ return normalized;
405
+ }
406
+ function isValidRelicId(candidate) {
407
+ try {
408
+ parseRelicId(candidate);
409
+ return true;
410
+ } catch {
411
+ return false;
412
+ }
413
+ }
368
414
  function encodeRelicId(entropy) {
369
415
  if (entropy.length !== ID_ENTROPY_BYTES) {
370
416
  throw new InvalidRelicIdError("length", `relic id entropy must be ${ID_ENTROPY_BYTES} bytes`);
@@ -446,9 +492,9 @@ var EXTENSIONS = {
446
492
  "7z": "archive",
447
493
  rar: "archive",
448
494
  ts: "code",
449
- tsx: "code",
450
495
  js: "code",
451
- jsx: "code",
496
+ jsx: "jsx",
497
+ tsx: "jsx",
452
498
  mjs: "code",
453
499
  cjs: "code",
454
500
  json: "code",
@@ -540,7 +586,40 @@ function looksTextual(content) {
540
586
  }
541
587
  return suspicious / limit < 0.01;
542
588
  }
543
- var HTML_PREFIXES = ["<!doctype html", "<html", "<!-- ", "<svg"];
589
+ var HTML_PREFIXES = [
590
+ "<!doctype",
591
+ "<?xml",
592
+ "<html",
593
+ "<head",
594
+ "<body",
595
+ "<meta",
596
+ "<title",
597
+ "<link",
598
+ "<style",
599
+ "<script",
600
+ "<svg",
601
+ "<div",
602
+ "<main",
603
+ "<section",
604
+ "<article",
605
+ "<header",
606
+ "<footer",
607
+ "<nav",
608
+ "<aside",
609
+ "<table",
610
+ "<form",
611
+ "<ul",
612
+ "<ol",
613
+ "<dl",
614
+ "<h1",
615
+ "<h2",
616
+ "<h3",
617
+ "<h4",
618
+ "<h5",
619
+ "<h6",
620
+ "<p>",
621
+ "<!--"
622
+ ];
544
623
  function deriveRendererClass(content, filename) {
545
624
  if (content.length === 0)
546
625
  return "binary";
@@ -562,6 +641,80 @@ function deriveRendererClass(content, filename) {
562
641
  }
563
642
  return "binary";
564
643
  }
644
+ // src/state.ts
645
+ import { mkdir, readFile as readFile2, rename, stat, writeFile } from "node:fs/promises";
646
+ import { homedir } from "node:os";
647
+ import { dirname, join } from "node:path";
648
+ function publishStatePath() {
649
+ const override = process.env["RELIC_PUBLISH_STATE"];
650
+ if (override !== undefined && override.length > 0)
651
+ return override;
652
+ const configRoot = process.env["XDG_CONFIG_HOME"] !== undefined && process.env["XDG_CONFIG_HOME"].length > 0 ? process.env["XDG_CONFIG_HOME"] : join(homedir(), ".config");
653
+ return join(configRoot, "relic-mcp", "publish-state.json");
654
+ }
655
+ var writeChain = Promise.resolve();
656
+ async function loadPublishState(relicId) {
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(await readFile2(publishStatePath(), "utf8"));
660
+ } catch (error) {
661
+ const code = error.code;
662
+ if (code === "ENOENT")
663
+ return;
664
+ if (error instanceof SyntaxError) {
665
+ throw new Error(`publish state at ${publishStatePath()} is not valid JSON. It cannot ` + "be read, so relics recorded in it cannot be republished from this " + "machine until it is fixed or removed.");
666
+ }
667
+ throw new Error(`could not read publish state at ${publishStatePath()}: ${code}`);
668
+ }
669
+ const entry = parsed?.relics?.[relicId];
670
+ if (entry === undefined)
671
+ return;
672
+ if (typeof entry !== "object" || typeof entry.key !== "string" || typeof entry.publish_token !== "string" || typeof entry.version !== "number" || !Number.isSafeInteger(entry.version) || entry.version < 1) {
673
+ throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + "this relic id.");
674
+ }
675
+ return entry;
676
+ }
677
+ async function savePublishState(relicId, state) {
678
+ const run = writeChain.then(() => writeEntry(relicId, state));
679
+ writeChain = run.catch(() => {
680
+ return;
681
+ });
682
+ await run;
683
+ }
684
+ async function writeEntry(relicId, state) {
685
+ const path = publishStatePath();
686
+ const dir = dirname(path);
687
+ await mkdir(dir, { mode: 448, recursive: true });
688
+ const existing = await readWholeFile(path);
689
+ const relics = { ...existing?.relics, [relicId]: state };
690
+ const temp = `${path}.tmp`;
691
+ await writeFile(temp, `${JSON.stringify({ relics }, null, 2)}
692
+ `, {
693
+ mode: 384
694
+ });
695
+ await rename(temp, path);
696
+ }
697
+ async function readWholeFile(path) {
698
+ let raw;
699
+ try {
700
+ raw = await readFile2(path, "utf8");
701
+ } catch (error) {
702
+ const code = error.code;
703
+ if (code === "ENOENT")
704
+ return;
705
+ throw new Error(`could not read publish state at ${path}: ${code}`);
706
+ }
707
+ try {
708
+ const parsed = JSON.parse(raw);
709
+ if (parsed === null || typeof parsed !== "object") {
710
+ throw new Error("not an object");
711
+ }
712
+ return parsed;
713
+ } catch {
714
+ throw new Error(`publish state at ${path} is not valid JSON. It cannot be updated, so ` + "this publish cannot be recorded and this relic cannot later be " + "republished from this machine until the file is fixed or removed.");
715
+ }
716
+ }
717
+
565
718
  // src/publish.ts
566
719
  class PublishError extends Error {
567
720
  code;
@@ -619,7 +772,8 @@ async function publish(input, deps) {
619
772
  renderer_class: rendererClass,
620
773
  publishing_client: deps.clientName,
621
774
  declared_size_bytes: source.bytes.length,
622
- declared_ciphertext_bytes: container2.length
775
+ declared_ciphertext_bytes: container2.length,
776
+ ...input.ttl_days === undefined ? {} : { ttl_days: input.ttl_days }
623
777
  });
624
778
  } catch (error) {
625
779
  if (error instanceof ServerRefusal && error.code === "relic_id_collision") {
@@ -628,21 +782,27 @@ async function publish(input, deps) {
628
782
  }
629
783
  throw error;
630
784
  }
631
- const upload = await deps.fetch(String(grant["upload_url"]), {
632
- method: "PUT",
633
- headers: { "content-length": String(container2.length) },
634
- body: container2
635
- });
636
- if (!upload.ok) {
637
- throw new PublishError("upload_failed", `upload returned ${upload.status}`, { relic_id: relicId, status: upload.status });
785
+ const publishToken = grant["publish_token"];
786
+ if (typeof publishToken !== "string" || publishToken.length === 0) {
787
+ throw new PublishError("grant_missing_publish_token", "the grant carried no publish_token, so the relic would never be " + "republishable from this machine", { relic_id: relicId });
638
788
  }
789
+ await putContainer(deps, relicId, String(grant["upload_url"]), container2);
790
+ await reportComplete(deps, relicId);
791
+ const url = relicUrl(deps.relicOrigin, relicId, key);
639
792
  try {
640
- await postJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/complete`, {});
641
- } catch {}
793
+ await savePublishState(relicId, {
794
+ key: encodeKey(key),
795
+ publish_token: publishToken,
796
+ version: 1
797
+ });
798
+ } catch (error) {
799
+ throw new PublishError("local_state_write_failed", "the relic is published and the link works, but recording it for " + `later republishing failed, so it cannot be republished from this ` + `machine: ${error.message}`, { relic_id: relicId, url });
800
+ }
642
801
  return {
643
- url: relicUrl(deps.relicOrigin, relicId, key),
802
+ url,
644
803
  relic_id: relicId,
645
- relic_expires_at: String(grant["relic_expires_at"]),
804
+ version: 1,
805
+ relic_expires_at: grant["relic_expires_at"] == null ? null : String(grant["relic_expires_at"]),
646
806
  renderer_class: rendererClass,
647
807
  filename,
648
808
  resolved_path: source.resolvedPath,
@@ -654,11 +814,11 @@ async function publish(input, deps) {
654
814
  }
655
815
  async function readSource(path, files) {
656
816
  const resolvedPath = files.resolve(path);
657
- const stat = await files.stat(resolvedPath);
658
- if (stat.kind === "directory") {
817
+ const stat2 = await files.stat(resolvedPath);
818
+ if (stat2.kind === "directory") {
659
819
  throw new PublishError("source_is_directory", `${resolvedPath} is a directory. Create an archive yourself and ` + "publish that, so the download-only outcome is yours to choose.");
660
820
  }
661
- if (stat.kind === "other") {
821
+ if (stat2.kind === "other") {
662
822
  throw new PublishError("source_not_regular_file", `${resolvedPath} is not a regular file`);
663
823
  }
664
824
  let bytes;
@@ -686,6 +846,24 @@ async function postJson(deps, url, body) {
686
846
  }
687
847
  return parsed;
688
848
  }
849
+ async function putContainer(deps, relicId, uploadUrl, container2) {
850
+ const upload = await deps.fetch(uploadUrl, {
851
+ method: "PUT",
852
+ headers: { "content-length": String(container2.length) },
853
+ body: container2
854
+ });
855
+ if (!upload.ok) {
856
+ throw new PublishError("upload_failed", `upload returned ${upload.status}`, {
857
+ relic_id: relicId,
858
+ status: upload.status
859
+ });
860
+ }
861
+ }
862
+ async function reportComplete(deps, relicId) {
863
+ try {
864
+ await postJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/complete`, {});
865
+ } catch {}
866
+ }
689
867
  var MIMETYPES = {
690
868
  md: "text/markdown",
691
869
  markdown: "text/markdown",
@@ -708,12 +886,15 @@ var MIMETYPES = {
708
886
  ogg: "audio/ogg",
709
887
  zip: "application/zip",
710
888
  gz: "application/gzip",
711
- pdf: "application/pdf"
889
+ pdf: "application/pdf",
890
+ jsx: "text/jsx",
891
+ tsx: "text/tsx"
712
892
  };
713
893
  var CLASS_FALLBACK = {
714
894
  markdown: "text/markdown",
715
895
  code: "text/plain",
716
896
  html: "text/html",
897
+ jsx: "text/jsx",
717
898
  image: "application/octet-stream",
718
899
  media: "application/octet-stream",
719
900
  archive: "application/octet-stream",
@@ -730,9 +911,64 @@ function guessMimetype(filename, rendererClass) {
730
911
  return CLASS_FALLBACK[rendererClass];
731
912
  }
732
913
 
914
+ // src/republish.ts
915
+ async function republish(input, deps) {
916
+ if (!isValidRelicId(input.relic_id)) {
917
+ throw new PublishError("no_local_publish_state", `"${input.relic_id}" is not a relic id. Republish takes the ` + "26-character id the original publish returned.");
918
+ }
919
+ let state;
920
+ try {
921
+ const loaded = await loadPublishState(input.relic_id);
922
+ if (loaded === undefined) {
923
+ throw new PublishError("no_local_publish_state", `relic ${input.relic_id} was published from another machine and ` + "cannot be republished here. The key and the publish token live " + "only on the machine that made the first publish, and neither can " + "be reconstructed from the link or from the service. Publish the " + "content as a new relic instead.");
924
+ }
925
+ state = loaded;
926
+ } catch (error) {
927
+ if (error instanceof PublishError)
928
+ throw error;
929
+ throw new PublishError("local_state_unreadable", error.message);
930
+ }
931
+ const source = await readSource(input.path, deps.files);
932
+ const filename = input.filename ?? source.basename;
933
+ const rendererClass = deriveRendererClass(source.bytes, filename);
934
+ const container2 = await encryptRelic({
935
+ content: source.bytes,
936
+ filename,
937
+ mimetype: guessMimetype(filename, rendererClass),
938
+ key: decodeKey(state.key)
939
+ });
940
+ const grant = await postJson(deps, `${deps.serviceOrigin}/api/relics/${input.relic_id}/republish`, {
941
+ publish_token: state.publish_token,
942
+ renderer_class: rendererClass,
943
+ declared_size_bytes: source.bytes.length,
944
+ declared_ciphertext_bytes: container2.length,
945
+ ...input.ttl_days === undefined ? {} : { ttl_days: input.ttl_days }
946
+ });
947
+ await putContainer(deps, input.relic_id, String(grant["upload_url"]), container2);
948
+ await reportComplete(deps, input.relic_id);
949
+ const version = state.version + 1;
950
+ try {
951
+ await savePublishState(input.relic_id, { ...state, version });
952
+ } catch (error) {
953
+ throw new PublishError("local_state_write_failed", "the new version is live at the existing link, but updating the " + "local record failed, so the next republish from this machine may " + `report a stale version number: ${error.message}`, { relic_id: input.relic_id, version });
954
+ }
955
+ return {
956
+ relic_id: input.relic_id,
957
+ version,
958
+ relic_expires_at: grant["relic_expires_at"] == null ? null : String(grant["relic_expires_at"]),
959
+ renderer_class: rendererClass,
960
+ filename,
961
+ resolved_path: source.resolvedPath,
962
+ report_url: String(grant["report_url"]),
963
+ disclosure_url: String(grant["disclosure_url"])
964
+ };
965
+ }
966
+
733
967
  // src/server.ts
734
968
  var TOOL_NAME = "relic_publish";
735
969
  var DESCRIBE_TOOL_NAME = "relic_describe_client";
970
+ var REPUBLISH_TOOL_NAME = "relic_republish";
971
+ var MAX_TTL_DAYS = 3650;
736
972
  var TOOL_DEFINITION = {
737
973
  name: TOOL_NAME,
738
974
  title: "Publish a relic",
@@ -747,6 +983,12 @@ var TOOL_DEFINITION = {
747
983
  filename: {
748
984
  type: "string",
749
985
  description: "Optional. Overrides the name written into the encrypted envelope " + "header. Defaults to the basename of `path`."
986
+ },
987
+ ttl_days: {
988
+ type: "integer",
989
+ minimum: 1,
990
+ maximum: MAX_TTL_DAYS,
991
+ description: "Optional. Gives the relic a lifetime in days. Omit it and the " + "relic is kept until it is deleted. Shorter is better for " + "sensitive content."
750
992
  }
751
993
  },
752
994
  required: ["path"],
@@ -757,7 +999,12 @@ var TOOL_DEFINITION = {
757
999
  properties: {
758
1000
  url: { type: "string" },
759
1001
  relic_id: { type: "string" },
760
- relic_expires_at: { type: "string" },
1002
+ version: {
1003
+ type: "integer",
1004
+ minimum: 1,
1005
+ description: "Always 1 from this tool; republish counts upward."
1006
+ },
1007
+ relic_expires_at: { type: ["string", "null"] },
761
1008
  renderer_class: { type: "string" },
762
1009
  filename: { type: "string" },
763
1010
  resolved_path: { type: "string" },
@@ -767,6 +1014,65 @@ var TOOL_DEFINITION = {
767
1014
  required: [
768
1015
  "url",
769
1016
  "relic_id",
1017
+ "version",
1018
+ "relic_expires_at",
1019
+ "renderer_class",
1020
+ "filename",
1021
+ "resolved_path",
1022
+ "report_url",
1023
+ "disclosure_url"
1024
+ ],
1025
+ additionalProperties: false
1026
+ }
1027
+ };
1028
+ var REPUBLISH_TOOL_DEFINITION = {
1029
+ name: REPUBLISH_TOOL_NAME,
1030
+ title: "Republish a relic",
1031
+ description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + "Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
1032
+ inputSchema: {
1033
+ type: "object",
1034
+ properties: {
1035
+ relic_id: {
1036
+ type: "string",
1037
+ description: "The 26-character relic id the original publish returned."
1038
+ },
1039
+ path: {
1040
+ type: "string",
1041
+ description: "Filesystem path to the file that becomes the new version."
1042
+ },
1043
+ filename: {
1044
+ type: "string",
1045
+ description: "Optional. Overrides the name written into the encrypted envelope " + "header of the new version. Defaults to the basename of `path`."
1046
+ },
1047
+ ttl_days: {
1048
+ type: "integer",
1049
+ minimum: 1,
1050
+ maximum: MAX_TTL_DAYS,
1051
+ description: "Optional. A lifetime in days, forwarded on the republish " + "request. The service fixes a relic's lifetime at its first " + "publish, so treat this as reserved."
1052
+ }
1053
+ },
1054
+ required: ["relic_id", "path"],
1055
+ additionalProperties: false
1056
+ },
1057
+ outputSchema: {
1058
+ type: "object",
1059
+ properties: {
1060
+ relic_id: { type: "string" },
1061
+ version: {
1062
+ type: "integer",
1063
+ minimum: 2,
1064
+ description: "The version just published."
1065
+ },
1066
+ relic_expires_at: { type: ["string", "null"] },
1067
+ renderer_class: { type: "string" },
1068
+ filename: { type: "string" },
1069
+ resolved_path: { type: "string" },
1070
+ report_url: { type: "string" },
1071
+ disclosure_url: { type: "string" }
1072
+ },
1073
+ required: [
1074
+ "relic_id",
1075
+ "version",
770
1076
  "relic_expires_at",
771
1077
  "renderer_class",
772
1078
  "filename",
@@ -787,10 +1093,11 @@ var DESCRIBE_TOOL_DEFINITION = {
787
1093
  additionalProperties: false
788
1094
  }
789
1095
  };
1096
+ var SERVER_VERSION = "0.3.0";
790
1097
  var SERVER_INFO = {
791
1098
  name: "relic",
792
1099
  title: "Relic",
793
- version: "0.1.0"
1100
+ version: SERVER_VERSION
794
1101
  };
795
1102
  var CAPABILITIES = { tools: {} };
796
1103
  async function handleMessage(message, deps) {
@@ -831,7 +1138,13 @@ async function handleMessage(message, deps) {
831
1138
  return {
832
1139
  jsonrpc: "2.0",
833
1140
  id: id2,
834
- result: { tools: [TOOL_DEFINITION, DESCRIBE_TOOL_DEFINITION] }
1141
+ result: {
1142
+ tools: [
1143
+ TOOL_DEFINITION,
1144
+ REPUBLISH_TOOL_DEFINITION,
1145
+ DESCRIBE_TOOL_DEFINITION
1146
+ ]
1147
+ }
835
1148
  };
836
1149
  case "tools/call":
837
1150
  return callTool(id2, message.params ?? {}, deps);
@@ -839,6 +1152,12 @@ async function handleMessage(message, deps) {
839
1152
  return errorResponse(id2, ERROR_CODES.methodNotFound, `unknown method: ${message.method}`);
840
1153
  }
841
1154
  }
1155
+ function isolationNote(rendererClass) {
1156
+ if (rendererClass !== "html" && rendererClass !== "jsx")
1157
+ return "";
1158
+ return "It renders in an isolated frame with no network access, so external " + "images, fonts, scripts, and fetches will not load. Inline whatever the " + `page needs.
1159
+ `;
1160
+ }
842
1161
  async function callTool(id2, params, deps) {
843
1162
  if (params["name"] === DESCRIBE_TOOL_NAME) {
844
1163
  return {
@@ -852,12 +1171,16 @@ async function callTool(id2, params, deps) {
852
1171
  key_transmitted_to_service: false,
853
1172
  plaintext_transmitted_to_service: false,
854
1173
  ciphertext_destination: "object storage, via a signed URL",
1174
+ local_publish_state: "relic id, key, and publish token per relic, written 0600 under " + "the user config directory; never printed, never sent",
855
1175
  service_origin: deps.serviceOrigin
856
1176
  },
857
1177
  isError: false
858
1178
  }
859
1179
  };
860
1180
  }
1181
+ if (params["name"] === REPUBLISH_TOOL_NAME) {
1182
+ return callRepublish(id2, params, deps);
1183
+ }
861
1184
  if (params["name"] !== TOOL_NAME) {
862
1185
  return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
863
1186
  }
@@ -867,8 +1190,12 @@ async function callTool(id2, params, deps) {
867
1190
  return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
868
1191
  }
869
1192
  const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
1193
+ const ttlDays = parseTtlDays(args["ttl_days"]);
1194
+ if (!ttlDays.ok) {
1195
+ return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to keep the relic until it is deleted");
1196
+ }
870
1197
  try {
871
- const result = await publish({ path, filename }, deps);
1198
+ const result = await publish({ path, filename, ttl_days: ttlDays.days }, deps);
872
1199
  return {
873
1200
  jsonrpc: "2.0",
874
1201
  id: id2,
@@ -876,11 +1203,51 @@ async function callTool(id2, params, deps) {
876
1203
  content: [
877
1204
  {
878
1205
  type: "text",
879
- text: `Published ${result.filename} as a relic.
1206
+ text: `Published ${result.filename} as version 1 of a new relic.
880
1207
 
881
- ${result.url}
1208
+ ` + `${result.url}
882
1209
 
883
- ` + `Expires ${result.relic_expires_at}. Anyone with this link, ` + "including its fragment, can read the file. The key is in the " + `fragment and it is now in this transcript.
1210
+ ` + (result.relic_expires_at === null ? "It does not expire; it is kept until it is deleted. " : `Expires ${result.relic_expires_at}. `) + "Anyone with this link, " + "including its fragment, can read the file. The key is in the " + "fragment and it is now in this transcript. This machine can " + `republish it later; the link will not change.
1211
+ ` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
1212
+ }
1213
+ ],
1214
+ structuredContent: result,
1215
+ isError: false
1216
+ }
1217
+ };
1218
+ } catch (error) {
1219
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1220
+ }
1221
+ }
1222
+ async function callRepublish(id2, params, deps) {
1223
+ const args = params["arguments"] ?? {};
1224
+ const relicId = args["relic_id"];
1225
+ if (typeof relicId !== "string" || relicId.length === 0) {
1226
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
1227
+ }
1228
+ const path = args["path"];
1229
+ if (typeof path !== "string" || path.length === 0) {
1230
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
1231
+ }
1232
+ const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
1233
+ const ttlDays = parseTtlDays(args["ttl_days"]);
1234
+ if (!ttlDays.ok) {
1235
+ return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to leave the lifetime as the first publish set it");
1236
+ }
1237
+ try {
1238
+ const result = await republish({ relic_id: relicId, path, filename, ttl_days: ttlDays.days }, deps);
1239
+ return {
1240
+ jsonrpc: "2.0",
1241
+ id: id2,
1242
+ result: {
1243
+ content: [
1244
+ {
1245
+ type: "text",
1246
+ text: `Republished ${result.filename} as version ${result.version} ` + `of relic ${result.relic_id}.
1247
+
1248
+ ` + "The share URL is unchanged: everyone holding the existing " + "link, including its fragment, now sees this content. There " + `is no new link to hand out.
1249
+
1250
+ ` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
884
1251
  ` + `What Relic knows: ${result.disclosure_url}`
885
1252
  }
886
1253
  ],
@@ -892,6 +1259,18 @@ ${result.url}
892
1259
  return { jsonrpc: "2.0", id: id2, result: toolError(error) };
893
1260
  }
894
1261
  }
1262
+ function parseTtlDays(raw) {
1263
+ if (raw === undefined || raw === null)
1264
+ return { ok: true, days: undefined };
1265
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1 || raw > MAX_TTL_DAYS) {
1266
+ return { ok: false };
1267
+ }
1268
+ return { ok: true, days: raw };
1269
+ }
1270
+ var REFUSAL_GUIDANCE = {
1271
+ invalid_publish_token: "The publish token this machine holds for that relic was rejected. It " + "is issued once, at first publish, and never changes, so a rejection " + "means the local record no longer matches the service's. The relic can " + "still be read at its existing link, but it cannot be republished from " + "here.",
1272
+ relic_removed: "That relic was taken down. A takedown is permanent: republishing " + "cannot revive it, whatever token is presented. Publish the content as " + "a new relic instead."
1273
+ };
895
1274
  function toolError(error) {
896
1275
  if (error instanceof PublishError) {
897
1276
  return {
@@ -901,8 +1280,15 @@ function toolError(error) {
901
1280
  };
902
1281
  }
903
1282
  if (error instanceof ServerRefusal) {
1283
+ const guidance = REFUSAL_GUIDANCE[error.code];
904
1284
  return {
905
- content: [{ type: "text", text: `${error.code}: ${error.message}` }],
1285
+ content: [
1286
+ {
1287
+ type: "text",
1288
+ text: `${error.code}: ${error.message}` + (guidance === undefined ? "" : `
1289
+ ${guidance}`)
1290
+ }
1291
+ ],
906
1292
  structuredContent: { code: error.code, ...error.problem },
907
1293
  isError: true
908
1294
  };
@@ -937,6 +1323,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
937
1323
  what coarse class it was declared as, the publishing IP, and when it was
938
1324
  fetched. Never the contents, and never the key.
939
1325
 
1326
+ What this keeps on disk: for each relic you publish, its id, its key, and a
1327
+ publish token, in a 0600 file under your user config directory. That record
1328
+ is what lets a relic be republished later, and it is why republishing works
1329
+ only on the machine that published. The token's SHA-256 is the only copy the
1330
+ service ever holds, and neither secret is ever printed or logged. Deleting
1331
+ the file changes nothing for existing links; it only ends this machine's
1332
+ ability to update those relics.
1333
+
940
1334
  What this does NOT protect against: the key is returned to your agent in the
941
1335
  URL, so it enters the model's context and your session transcript. That is
942
1336
  structural, not a defect. Anyone who can read this conversation can open the
@@ -1044,9 +1438,9 @@ function createHttpHandler(deps, options = {}) {
1044
1438
 
1045
1439
  // src/installer.ts
1046
1440
  import { spawnSync } from "node:child_process";
1047
- import { copyFile, mkdir, readFile as readFile2, stat, writeFile } from "node:fs/promises";
1048
- import { homedir } from "node:os";
1049
- import { dirname, join, resolve } from "node:path";
1441
+ import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
1442
+ import { homedir as homedir2 } from "node:os";
1443
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
1050
1444
  import { fileURLToPath } from "node:url";
1051
1445
 
1052
1446
  // src/install.ts
@@ -1094,6 +1488,12 @@ var HARNESSES = [
1094
1488
  configPath: ".codex/config.toml"
1095
1489
  }
1096
1490
  ];
1491
+ var DEFAULT_SERVER_SPEC = {
1492
+ name: "relic",
1493
+ command: "npx",
1494
+ args: ["-y", "relic-mcp@latest"],
1495
+ env: {}
1496
+ };
1097
1497
 
1098
1498
  class UnknownHarnessError extends Error {
1099
1499
  }
@@ -1269,10 +1669,10 @@ function parseArgs(argv) {
1269
1669
  return options;
1270
1670
  }
1271
1671
  function packageRoot() {
1272
- return resolve(dirname(fileURLToPath(import.meta.url)), "..");
1672
+ return resolve(dirname2(fileURLToPath(import.meta.url)), "..");
1273
1673
  }
1274
1674
  async function exists(path) {
1275
- return readFile2(path).then(() => true).catch(() => false);
1675
+ return readFile3(path).then(() => true).catch(() => false);
1276
1676
  }
1277
1677
  async function runInstall(argv) {
1278
1678
  let options;
@@ -1284,12 +1684,7 @@ async function runInstall(argv) {
1284
1684
  ${USAGE}`);
1285
1685
  process.exit(2);
1286
1686
  }
1287
- const spec = {
1288
- name: options.name,
1289
- command: "npx",
1290
- args: ["-y", "relic-mcp"],
1291
- env: {}
1292
- };
1687
+ const spec = { ...DEFAULT_SERVER_SPEC, name: options.name };
1293
1688
  if (options.print && options.client === undefined) {
1294
1689
  process.stdout.write(snippetFor(withOrigin(spec, options)));
1295
1690
  return;
@@ -1303,15 +1698,15 @@ ${USAGE}`);
1303
1698
  await installClaudeCode(options);
1304
1699
  return;
1305
1700
  }
1306
- const home = homedir();
1701
+ const home = homedir2();
1307
1702
  const harness = HARNESSES.find((h) => h.id === options.client);
1308
1703
  if (harness === undefined) {
1309
1704
  process.stderr.write(`Unknown client ${options.client}. Known: ` + `${HARNESSES.map((h) => h.id).join(", ")}
1310
1705
  `);
1311
1706
  process.exit(2);
1312
1707
  }
1313
- const path = join(home, harness.configPath);
1314
- const existing = await readFile2(path, "utf8").catch(() => {
1708
+ const path = join2(home, harness.configPath);
1709
+ const existing = await readFile3(path, "utf8").catch(() => {
1315
1710
  return;
1316
1711
  });
1317
1712
  let plan;
@@ -1339,8 +1734,8 @@ ${USAGE}`);
1339
1734
  if (existing !== undefined) {
1340
1735
  await copyFile(path, `${path}.relic-backup`);
1341
1736
  }
1342
- await mkdir(dirname(path), { recursive: true });
1343
- await writeFile(path, plan.contents);
1737
+ await mkdir2(dirname2(path), { recursive: true });
1738
+ await writeFile2(path, plan.contents);
1344
1739
  process.stdout.write(`${plan.replaced ? "Replaced" : "Added"} "${resolved.name}" in ${path}
1345
1740
  ` + (existing === undefined ? "" : `Previous config saved to ${path}.relic-backup
1346
1741
  `) + `Restart ${harness.label} to pick it up.
@@ -1348,7 +1743,7 @@ ${USAGE}`);
1348
1743
  }
1349
1744
  async function installClaudeCode(options) {
1350
1745
  const root = packageRoot();
1351
- if (!await exists(join(root, ".claude-plugin", "marketplace.json"))) {
1746
+ if (!await exists(join2(root, ".claude-plugin", "marketplace.json"))) {
1352
1747
  process.stderr.write("This copy of relic-mcp does not carry the plugin manifests, so it " + "cannot be installed as a Claude Code plugin. Use --print and add " + `the server manually, or install a newer version.
1353
1748
  `);
1354
1749
  process.exit(1);
@@ -1382,14 +1777,14 @@ function withOrigin(spec, options) {
1382
1777
  } catch (error) {
1383
1778
  process.stderr.write(`${error.message}
1384
1779
 
1385
- Pass --origin https://your-relic-service
1780
+ Pass --origin https://relik.link
1386
1781
  `);
1387
1782
  process.exit(2);
1388
1783
  }
1389
1784
  return { ...spec, env: { RELIC_SERVICE_ORIGIN: origin } };
1390
1785
  }
1391
1786
  async function reportDetected() {
1392
- const home = homedir();
1787
+ const home = homedir2();
1393
1788
  const lines = ["Harnesses detected here:", ""];
1394
1789
  for (const harness of HARNESSES) {
1395
1790
  if (harness.format === "plugin") {
@@ -1397,8 +1792,8 @@ async function reportDetected() {
1397
1792
  lines.push(` ${found2.status === 0 ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
1398
1793
  continue;
1399
1794
  }
1400
- const dir = dirname(join(home, harness.configPath));
1401
- const found = await stat(dir).then((info) => info.isDirectory()).catch(() => false);
1795
+ const dir = dirname2(join2(home, harness.configPath));
1796
+ const found = await stat2(dir).then((info) => info.isDirectory()).catch(() => false);
1402
1797
  lines.push(` ${found ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
1403
1798
  }
1404
1799
  lines.push("", "Install with:", " relic-mcp install --client <id>", "");