relic-mcp 0.9.0 → 0.10.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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "relic",
12
12
  "description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
13
- "version": "0.9.0",
13
+ "version": "0.10.0",
14
14
  "source": "./",
15
15
  "author": {
16
16
  "name": "The Bushido Collective",
@@ -21,6 +21,6 @@
21
21
  }
22
22
  ],
23
23
  "metadata": {
24
- "version": "0.9.0"
24
+ "version": "0.10.0"
25
25
  }
26
26
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
5
5
  "mcpServers": "./mcp-servers.json",
6
6
  "author": {
package/dist/relic-mcp.js CHANGED
@@ -3540,7 +3540,7 @@ function validatePublishStateEntry(entry, relicId) {
3540
3540
  throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + `relic ${relicId}.`);
3541
3541
  }
3542
3542
  const candidate = entry;
3543
- if (typeof candidate["key"] !== "string" || typeof candidate["publish_token"] !== "string" || typeof candidate["version"] !== "number" || !Number.isSafeInteger(candidate["version"]) || candidate["version"] < 1 || candidate["source"] !== undefined && !isSourceIdentity(candidate["source"])) {
3543
+ if (typeof candidate["key"] !== "string" || typeof candidate["publish_token"] !== "string" || typeof candidate["version"] !== "number" || !Number.isSafeInteger(candidate["version"]) || candidate["version"] < 1 || candidate["source"] !== undefined && !isSourceIdentity(candidate["source"]) || candidate["content_sha256"] !== undefined && (typeof candidate["content_sha256"] !== "string" || !/^[0-9a-f]{64}$/.test(candidate["content_sha256"]))) {
3544
3544
  throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + `relic ${relicId}.`);
3545
3545
  }
3546
3546
  return candidate;
@@ -3825,6 +3825,11 @@ async function publish(input, deps) {
3825
3825
  const filename = input.filename ?? source.basename;
3826
3826
  const normalizedTitle = normalizeTitle(input.title ?? filename);
3827
3827
  const rendererClass = deriveRendererClass(source.bytes, filename);
3828
+ const digest = await contentDigest({
3829
+ content: source.bytes,
3830
+ filename,
3831
+ title: normalizedTitle
3832
+ });
3828
3833
  const retries = deps.maxCollisionRetries ?? 3;
3829
3834
  let lastCollision;
3830
3835
  for (let attempt = 0;attempt <= retries; attempt++) {
@@ -3870,6 +3875,7 @@ async function publish(input, deps) {
3870
3875
  version: 1,
3871
3876
  source: sourceLookup.source,
3872
3877
  filename,
3878
+ content_sha256: digest,
3873
3879
  published_at: new Date().toISOString(),
3874
3880
  expires_at: relicExpiresAt
3875
3881
  });
@@ -3892,6 +3898,14 @@ async function publish(input, deps) {
3892
3898
  }
3893
3899
  throw lastCollision ?? new PublishError("service_unreachable", "could not obtain a grant");
3894
3900
  }
3901
+ async function contentDigest(input) {
3902
+ const header = new TextEncoder().encode(`${JSON.stringify([input.filename, input.title])}\x00`);
3903
+ const material = new Uint8Array(header.length + input.content.length);
3904
+ material.set(header, 0);
3905
+ material.set(input.content, header.length);
3906
+ const digest = await crypto.subtle.digest("SHA-256", material.slice().buffer);
3907
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
3908
+ }
3895
3909
  async function readSource(path, files) {
3896
3910
  const resolvedPath = files.resolve(path);
3897
3911
  const stat2 = await files.stat(resolvedPath);
@@ -5870,6 +5884,22 @@ async function republish(input, deps) {
5870
5884
  seen.add(entry.comment_id);
5871
5885
  }
5872
5886
  }
5887
+ const source = await readSource(input.path, deps.files);
5888
+ const filename = input.filename ?? source.basename;
5889
+ const normalizedTitle = normalizeTitle(input.title ?? filename);
5890
+ const rendererClass = deriveRendererClass(source.bytes, filename);
5891
+ const digest = await contentDigest({
5892
+ content: source.bytes,
5893
+ filename,
5894
+ title: normalizedTitle
5895
+ });
5896
+ if (state.content_sha256 !== undefined && state.content_sha256 === digest) {
5897
+ throw new PublishError("duplicate_version", `version ${state.version} of relic ${input.relic_id} already holds ` + "exactly this content, under this name and title, so a new version " + "would show a reader nothing they are not already reading at the " + "same link. Change the file, or leave the relic where it is.", {
5898
+ relic_id: input.relic_id,
5899
+ version: state.version,
5900
+ content_sha256: digest
5901
+ });
5902
+ }
5873
5903
  const commentResult = await readComments(input.relic_id, deps, {
5874
5904
  version: "*"
5875
5905
  });
@@ -5907,10 +5937,6 @@ async function republish(input, deps) {
5907
5937
  }))
5908
5938
  });
5909
5939
  }
5910
- const source = await readSource(input.path, deps.files);
5911
- const filename = input.filename ?? source.basename;
5912
- const normalizedTitle = normalizeTitle(input.title ?? filename);
5913
- const rendererClass = deriveRendererClass(source.bytes, filename);
5914
5940
  const container2 = await encryptRelic({
5915
5941
  content: source.bytes,
5916
5942
  filename,
@@ -5933,6 +5959,7 @@ async function republish(input, deps) {
5933
5959
  ...state,
5934
5960
  version,
5935
5961
  filename,
5962
+ content_sha256: digest,
5936
5963
  updated_at: new Date().toISOString()
5937
5964
  });
5938
5965
  } catch (error) {
@@ -5981,7 +6008,7 @@ var RESOLVE_COMMENT_TOOL_NAME = "relic_resolve_comment";
5981
6008
  var COMMENT_MACHINE_BOUNDARY = "Only works for a relic this machine published: the comment key is derived " + "from that relic's key, which lives in local publish state and nowhere the " + "service can reach.";
5982
6009
  var MAX_TTL_DAYS = 3650;
5983
6010
  var VERSION_HISTORY_DISCLOSURE = "Anyone holding a relic's link can fetch every version it has ever held, " + "so republishing does not withdraw earlier content.";
5984
- var REPUBLISH_DISCIPLINE_DISCLOSURE = " Before publishing, it reads the relic's comments, on every version, and " + "refuses if any are unanswered. Clear open comments either by replying with " + "`relic_comment`, by resolving them with `relic_resolve_comment`, or by " + "passing `addresses: [{ comment_id, note }]` here to acknowledge them " + "in the new version. A resolved comment counts as handled. Unreadable " + "comments block republishing. This is workflow " + "discipline in the client, not a boundary: the machine holding the publish " + "token can call the HTTP API directly, and the service cannot enforce this " + "because it cannot read comments.";
6011
+ var REPUBLISH_DISCIPLINE_DISCLOSURE = " A version whose content, name and title are identical to the one already " + "live is refused locally, before any request: it would tell recipients to " + "look again at what they are already reading. Change the file rather than " + "retrying." + " Before publishing, it reads the relic's comments, on every version, and " + "refuses if any are unanswered. Clear open comments either by replying with " + "`relic_comment`, by resolving them with `relic_resolve_comment`, or by " + "passing `addresses: [{ comment_id, note }]` here to acknowledge them " + "in the new version. A resolved comment counts as handled. Unreadable " + "comments block republishing. This is workflow " + "discipline in the client, not a boundary: the machine holding the publish " + "token can call the HTTP API directly, and the service cannot enforce this " + "because it cannot read comments.";
5985
6012
  var TOOL_DEFINITION = {
5986
6013
  name: TOOL_NAME,
5987
6014
  title: "Publish a relic",
@@ -6828,7 +6855,7 @@ var DESCRIBE_TOOL_DEFINITION = {
6828
6855
  additionalProperties: false
6829
6856
  }
6830
6857
  };
6831
- var SERVER_VERSION = "0.9.0";
6858
+ var SERVER_VERSION = "0.10.0";
6832
6859
  var SERVER_INFO = {
6833
6860
  name: "relic",
6834
6861
  title: "Relic",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Publish a file as an encrypted relic. The key is generated on your machine and never sent to the service.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/publish.ts CHANGED
@@ -81,6 +81,7 @@ export type ClientCode =
81
81
  | 'local_comment_addresses_invalid'
82
82
  | 'local_comment_addresses_too_long'
83
83
  | 'unaddressed_comments'
84
+ | 'duplicate_version'
84
85
  | 'empty_acknowledgement_note'
85
86
  | 'invalid_acknowledgement_comment_id'
86
87
  | 'duplicate_acknowledgement'
@@ -332,6 +333,13 @@ export async function publish(
332
333
  // model-attested, and the metric's second clause would then have an
333
334
  // unreliable narrator reporting its only input.
334
335
  const rendererClass = deriveRendererClass(source.bytes, filename);
336
+ // What this version's plaintext is, recorded so the next republish from
337
+ // this machine can tell a change from a re-upload of the same thing.
338
+ const digest = await contentDigest({
339
+ content: source.bytes,
340
+ filename,
341
+ title: normalizedTitle,
342
+ });
335
343
 
336
344
  const retries = deps.maxCollisionRetries ?? 3;
337
345
  let lastCollision: ServerRefusal | undefined;
@@ -425,6 +433,7 @@ export async function publish(
425
433
  version: 1,
426
434
  source: sourceLookup.source,
427
435
  filename,
436
+ content_sha256: digest,
428
437
  published_at: new Date().toISOString(),
429
438
  expires_at: relicExpiresAt,
430
439
  });
@@ -465,6 +474,39 @@ export async function publish(
465
474
  );
466
475
  }
467
476
 
477
+ /**
478
+ * Name one version's plaintext, so the next one can be recognised as the
479
+ * same.
480
+ *
481
+ * Over the name and title as well as the bytes, because those two ride in
482
+ * the envelope and show on the card: retitling a relic changes what a
483
+ * recipient is handed even when the content does not, so it is a real
484
+ * version. The header is JSON and cannot contain a NUL, which is what keeps
485
+ * the separator unambiguous without a length prefix.
486
+ *
487
+ * Never sent anywhere. It is a local record of what this machine last put
488
+ * behind the link; the service is not asked and does not know.
489
+ */
490
+ export async function contentDigest(input: {
491
+ readonly content: Uint8Array;
492
+ readonly filename: string;
493
+ readonly title: string;
494
+ }): Promise<string> {
495
+ const header = new TextEncoder().encode(
496
+ `${JSON.stringify([input.filename, input.title])}\0`
497
+ );
498
+ const material = new Uint8Array(header.length + input.content.length);
499
+ material.set(header, 0);
500
+ material.set(input.content, header.length);
501
+ const digest = await crypto.subtle.digest(
502
+ 'SHA-256',
503
+ material.slice().buffer as ArrayBuffer
504
+ );
505
+ return Array.from(new Uint8Array(digest))
506
+ .map((byte) => byte.toString(16).padStart(2, '0'))
507
+ .join('');
508
+ }
509
+
468
510
  export async function readSource(
469
511
  path: string,
470
512
  files: FileReader
package/src/republish.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  readComments,
27
27
  } from './comments.ts';
28
28
  import {
29
+ contentDigest,
29
30
  guessMimetype,
30
31
  type PublishDeps,
31
32
  PublishError,
@@ -178,6 +179,46 @@ export async function republish(
178
179
  }
179
180
  }
180
181
 
182
+ // The duplicate gate, still local: read the file, name what its plaintext
183
+ // is, and refuse when that is already what the link serves.
184
+ //
185
+ // A version is a thing recipients are told to look at again. One that
186
+ // changes nothing cannot be told apart from a real one until it has been
187
+ // read, and the reader has to hold both in their head to discover that
188
+ // nothing moved, so an identical version spends their attention and
189
+ // returns nothing. It is refused here rather than uploaded, because the
190
+ // service has no way to know: it holds ciphertext, and a fresh salt makes
191
+ // two encryptions of one file differ byte for byte.
192
+ //
193
+ // Before the comment read, which is the first thing here that touches the
194
+ // network. An identical file is also the likeliest reason comments are
195
+ // still open, so naming the duplicate first names the cause.
196
+ const source = await readSource(input.path, deps.files);
197
+ const filename = input.filename ?? source.basename;
198
+ const normalizedTitle = normalizeTitle(input.title ?? filename);
199
+ // Same rule as a first publish: the class comes from bytes in hand, never
200
+ // from a tool input, so the taxonomy stays machine-attested.
201
+ const rendererClass = deriveRendererClass(source.bytes, filename);
202
+ const digest = await contentDigest({
203
+ content: source.bytes,
204
+ filename,
205
+ title: normalizedTitle,
206
+ });
207
+ if (state.content_sha256 !== undefined && state.content_sha256 === digest) {
208
+ throw new PublishError(
209
+ 'duplicate_version',
210
+ `version ${state.version} of relic ${input.relic_id} already holds ` +
211
+ 'exactly this content, under this name and title, so a new version ' +
212
+ 'would show a reader nothing they are not already reading at the ' +
213
+ 'same link. Change the file, or leave the relic where it is.',
214
+ {
215
+ relic_id: input.relic_id,
216
+ version: state.version,
217
+ content_sha256: digest,
218
+ }
219
+ );
220
+ }
221
+
181
222
  // The comment gate: before publishing a new version, read the relic's
182
223
  // comments, decrypt them locally, and verify every comment has been
183
224
  // addressed. Unaddressed or unreadable comments refuse the republish.
@@ -254,13 +295,6 @@ export async function republish(
254
295
  );
255
296
  }
256
297
 
257
- const source = await readSource(input.path, deps.files);
258
- const filename = input.filename ?? source.basename;
259
- const normalizedTitle = normalizeTitle(input.title ?? filename);
260
- // Same rule as a first publish: the class comes from bytes in hand, never
261
- // from a tool input, so the taxonomy stays machine-attested.
262
- const rendererClass = deriveRendererClass(source.bytes, filename);
263
-
264
298
  // The stored key, not a fresh one. This is the line that keeps the
265
299
  // existing share URL decrypting the new version; a fresh key here would
266
300
  // silently cut every recipient off from content they were told this link
@@ -312,6 +346,7 @@ export async function republish(
312
346
  ...state,
313
347
  version,
314
348
  filename,
349
+ content_sha256: digest,
315
350
  updated_at: new Date().toISOString(),
316
351
  });
317
352
  } catch (error) {
package/src/server.ts CHANGED
@@ -205,6 +205,10 @@ const VERSION_HISTORY_DISCLOSURE =
205
205
  "Anyone holding a relic's link can fetch every version it has ever held, " +
206
206
  'so republishing does not withdraw earlier content.';
207
207
  export const REPUBLISH_DISCIPLINE_DISCLOSURE =
208
+ ' A version whose content, name and title are identical to the one already ' +
209
+ 'live is refused locally, before any request: it would tell recipients to ' +
210
+ 'look again at what they are already reading. Change the file rather than ' +
211
+ 'retrying.' +
208
212
  " Before publishing, it reads the relic's comments, on every version, and " +
209
213
  'refuses if any are unanswered. Clear open comments either by replying with ' +
210
214
  '`relic_comment`, by resolving them with `relic_resolve_comment`, or by ' +
package/src/state.ts CHANGED
@@ -86,6 +86,23 @@ export interface PublishState {
86
86
  readonly published_at?: string | undefined;
87
87
  /** When this machine last published a new version, ISO 8601. */
88
88
  readonly updated_at?: string | undefined;
89
+ /**
90
+ * SHA-256, hex, of the plaintext of the newest version this machine
91
+ * published: its bytes, its name and its title together.
92
+ *
93
+ * It exists to refuse a republish that would put identical content behind
94
+ * the link a second time. A version is a thing recipients are told to look
95
+ * at again, so one that changes nothing is a false alarm they cannot tell
96
+ * from a real one until they have read it.
97
+ *
98
+ * Absent on every entry written before this client, and absence is legacy
99
+ * state rather than an error. It cannot be repaired from anywhere: the
100
+ * service holds ciphertext and would not hand a client the plaintext to
101
+ * hash even if it were asked. So an entry without it cannot take part in
102
+ * the check, its next republish is allowed on that ground, and the digest
103
+ * is recorded as that republish lands.
104
+ */
105
+ readonly content_sha256?: string | undefined;
89
106
  /**
90
107
  * The lifetime the first grant fixed, ISO 8601, or null for a relic kept
91
108
  * until it is deleted.
@@ -328,7 +345,14 @@ function validatePublishStateEntry(
328
345
  !Number.isSafeInteger(candidate['version']) ||
329
346
  candidate['version'] < 1 ||
330
347
  (candidate['source'] !== undefined &&
331
- !isSourceIdentity(candidate['source']))
348
+ !isSourceIdentity(candidate['source'])) ||
349
+ // Checked rather than trusted, because the duplicate-version refusal
350
+ // compares against it: a digest of the wrong shape would match nothing,
351
+ // and that refusal would then fail open on every republish, with no
352
+ // word about it anywhere.
353
+ (candidate['content_sha256'] !== undefined &&
354
+ (typeof candidate['content_sha256'] !== 'string' ||
355
+ !/^[0-9a-f]{64}$/.test(candidate['content_sha256'])))
332
356
  ) {
333
357
  throw new Error(
334
358
  `publish state at ${publishStatePath()} holds a malformed entry for ` +