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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/relic-mcp.js +34 -7
- package/package.json +1 -1
- package/src/publish.ts +42 -0
- package/src/republish.ts +42 -7
- package/src/server.ts +4 -0
- package/src/state.ts +25 -1
|
@@ -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.
|
|
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.
|
|
24
|
+
"version": "0.10.0"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relic",
|
|
3
|
-
"version": "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.
|
|
6858
|
+
var SERVER_VERSION = "0.10.0";
|
|
6832
6859
|
var SERVER_INFO = {
|
|
6833
6860
|
name: "relic",
|
|
6834
6861
|
title: "Relic",
|
package/package.json
CHANGED
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 ` +
|