relic-mcp 0.3.2 → 0.4.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/README.md +42 -0
- package/dist/relic-mcp.js +506 -16
- package/package.json +1 -1
- package/skills/relic/SKILL.md +50 -1
- package/src/comments.ts +286 -0
- package/src/publish.ts +59 -8
- package/src/server.ts +343 -17
package/dist/relic-mcp.js
CHANGED
|
@@ -117,6 +117,179 @@ class ContentTooLargeError extends RelicFormatError {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
class MalformedCommentError extends RelicFormatError {
|
|
121
|
+
name = "MalformedCommentError";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class CommentDecryptFailedError extends RelicFormatError {
|
|
125
|
+
name = "CommentDecryptFailedError";
|
|
126
|
+
constructor() {
|
|
127
|
+
super("the comment failed authentication");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class CommentTooLargeError extends RelicFormatError {
|
|
132
|
+
field;
|
|
133
|
+
declaredBytes;
|
|
134
|
+
limitBytes;
|
|
135
|
+
name = "CommentTooLargeError";
|
|
136
|
+
constructor(field, declaredBytes, limitBytes) {
|
|
137
|
+
super(`comment ${field} is ${declaredBytes} bytes of UTF-8, over the ` + `${limitBytes} cap`);
|
|
138
|
+
this.field = field;
|
|
139
|
+
this.declaredBytes = declaredBytes;
|
|
140
|
+
this.limitBytes = limitBytes;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ../relic-format/src/comments.ts
|
|
145
|
+
var COMMENT_INFO = new TextEncoder().encode("relic/comments/v1");
|
|
146
|
+
var COMMENT_NONCE_BYTES = 12;
|
|
147
|
+
var COMMENT_BODY_LIMIT_BYTES = 4096;
|
|
148
|
+
var COMMENT_DISPLAY_NAME_LIMIT_BYTES = 64;
|
|
149
|
+
var COMMENT_ANCHOR_QUOTE_LIMIT_BYTES = 512;
|
|
150
|
+
async function deriveCommentKey(keyBytes) {
|
|
151
|
+
const material = await crypto.subtle.importKey("raw", toBufferSource(keyBytes), "HKDF", false, ["deriveBits"]);
|
|
152
|
+
const bits = await crypto.subtle.deriveBits({
|
|
153
|
+
name: "HKDF",
|
|
154
|
+
hash: "SHA-256",
|
|
155
|
+
salt: new Uint8Array(0),
|
|
156
|
+
info: COMMENT_INFO
|
|
157
|
+
}, material, 128);
|
|
158
|
+
return crypto.subtle.importKey("raw", bits, { name: "AES-GCM" }, false, [
|
|
159
|
+
"encrypt",
|
|
160
|
+
"decrypt"
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
async function encryptComment(commentKey, plaintext) {
|
|
164
|
+
const bodyBytes = new TextEncoder().encode(plaintext.body);
|
|
165
|
+
if (bodyBytes.length > COMMENT_BODY_LIMIT_BYTES) {
|
|
166
|
+
throw new CommentTooLargeError("body", bodyBytes.length, COMMENT_BODY_LIMIT_BYTES);
|
|
167
|
+
}
|
|
168
|
+
if (plaintext.display_name !== null) {
|
|
169
|
+
const nameBytes = new TextEncoder().encode(plaintext.display_name).length;
|
|
170
|
+
if (nameBytes > COMMENT_DISPLAY_NAME_LIMIT_BYTES) {
|
|
171
|
+
throw new CommentTooLargeError("display_name", nameBytes, COMMENT_DISPLAY_NAME_LIMIT_BYTES);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (plaintext.anchor != null && plaintext.anchor.kind === "text") {
|
|
175
|
+
const quoteBytes = new TextEncoder().encode(plaintext.anchor.quote).length;
|
|
176
|
+
if (quoteBytes > COMMENT_ANCHOR_QUOTE_LIMIT_BYTES) {
|
|
177
|
+
throw new CommentTooLargeError("anchor", quoteBytes, COMMENT_ANCHOR_QUOTE_LIMIT_BYTES);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const encoded = new TextEncoder().encode(JSON.stringify({
|
|
181
|
+
body: plaintext.body,
|
|
182
|
+
display_name: plaintext.display_name,
|
|
183
|
+
...plaintext.anchor == null ? {} : { anchor: plaintext.anchor }
|
|
184
|
+
}));
|
|
185
|
+
const nonce = crypto.getRandomValues(new Uint8Array(COMMENT_NONCE_BYTES));
|
|
186
|
+
const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: toBufferSource(nonce) }, commentKey, toBufferSource(encoded)));
|
|
187
|
+
const framed = new Uint8Array(nonce.length + sealed.length);
|
|
188
|
+
framed.set(nonce, 0);
|
|
189
|
+
framed.set(sealed, nonce.length);
|
|
190
|
+
return base64url(framed);
|
|
191
|
+
}
|
|
192
|
+
async function decryptComment(commentKey, ciphertext) {
|
|
193
|
+
const framed = decodeBase64url(ciphertext);
|
|
194
|
+
if (framed.length <= COMMENT_NONCE_BYTES) {
|
|
195
|
+
throw new MalformedCommentError(`comment is ${framed.length} bytes, too short to carry a ` + `${COMMENT_NONCE_BYTES}-byte nonce and a tag`);
|
|
196
|
+
}
|
|
197
|
+
let opened;
|
|
198
|
+
try {
|
|
199
|
+
opened = new Uint8Array(await crypto.subtle.decrypt({
|
|
200
|
+
name: "AES-GCM",
|
|
201
|
+
iv: toBufferSource(framed.subarray(0, COMMENT_NONCE_BYTES))
|
|
202
|
+
}, commentKey, toBufferSource(framed.subarray(COMMENT_NONCE_BYTES))));
|
|
203
|
+
} catch {
|
|
204
|
+
throw new CommentDecryptFailedError;
|
|
205
|
+
}
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opened));
|
|
209
|
+
} catch {
|
|
210
|
+
throw new MalformedCommentError("comment plaintext is not valid JSON");
|
|
211
|
+
}
|
|
212
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
213
|
+
throw new MalformedCommentError("comment plaintext is not a JSON object");
|
|
214
|
+
}
|
|
215
|
+
const fields = parsed;
|
|
216
|
+
const unknown = Object.keys(fields).filter((key) => key !== "body" && key !== "display_name" && key !== "anchor");
|
|
217
|
+
if (unknown.length > 0) {
|
|
218
|
+
throw new MalformedCommentError(`comment carries unknown field(s): ${unknown.join(", ")}`);
|
|
219
|
+
}
|
|
220
|
+
const body = fields["body"];
|
|
221
|
+
if (typeof body !== "string") {
|
|
222
|
+
throw new MalformedCommentError("comment body is missing or not a string");
|
|
223
|
+
}
|
|
224
|
+
const displayName = fields["display_name"];
|
|
225
|
+
if (displayName !== null && typeof displayName !== "string") {
|
|
226
|
+
throw new MalformedCommentError("comment display_name is present and is neither a string nor null");
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
body,
|
|
230
|
+
display_name: displayName,
|
|
231
|
+
anchor: parseAnchor(fields["anchor"])
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function parseAnchor(value) {
|
|
235
|
+
if (value === undefined)
|
|
236
|
+
return null;
|
|
237
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
238
|
+
throw new MalformedCommentError("comment anchor is not an object");
|
|
239
|
+
}
|
|
240
|
+
const anchor = value;
|
|
241
|
+
const kind = anchor["kind"];
|
|
242
|
+
if (kind === "text") {
|
|
243
|
+
const quote = anchor["quote"];
|
|
244
|
+
if (typeof quote !== "string" || quote.length === 0) {
|
|
245
|
+
throw new MalformedCommentError("text anchor quote is missing");
|
|
246
|
+
}
|
|
247
|
+
const extra = Object.keys(anchor).filter((key) => key !== "kind" && key !== "quote");
|
|
248
|
+
if (extra.length > 0) {
|
|
249
|
+
throw new MalformedCommentError(`text anchor carries unknown field(s): ${extra.join(", ")}`);
|
|
250
|
+
}
|
|
251
|
+
return { kind: "text", quote };
|
|
252
|
+
}
|
|
253
|
+
if (kind === "pin") {
|
|
254
|
+
const x = anchor["x"];
|
|
255
|
+
const y = anchor["y"];
|
|
256
|
+
if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) {
|
|
257
|
+
throw new MalformedCommentError("pin anchor is out of unit range");
|
|
258
|
+
}
|
|
259
|
+
const extra = Object.keys(anchor).filter((key) => key !== "kind" && key !== "x" && key !== "y");
|
|
260
|
+
if (extra.length > 0) {
|
|
261
|
+
throw new MalformedCommentError(`pin anchor carries unknown field(s): ${extra.join(", ")}`);
|
|
262
|
+
}
|
|
263
|
+
return { kind: "pin", x, y };
|
|
264
|
+
}
|
|
265
|
+
throw new MalformedCommentError("comment anchor kind is not text or pin");
|
|
266
|
+
}
|
|
267
|
+
function base64url(bytes) {
|
|
268
|
+
let binary = "";
|
|
269
|
+
for (const byte of bytes)
|
|
270
|
+
binary += String.fromCharCode(byte);
|
|
271
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
272
|
+
}
|
|
273
|
+
function decodeBase64url(encoded) {
|
|
274
|
+
if (!/^[A-Za-z0-9_-]*$/.test(encoded)) {
|
|
275
|
+
throw new MalformedCommentError("comment is not unpadded base64url");
|
|
276
|
+
}
|
|
277
|
+
const padded = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
278
|
+
let binary;
|
|
279
|
+
try {
|
|
280
|
+
binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
|
|
281
|
+
} catch {
|
|
282
|
+
throw new MalformedCommentError("comment is not decodable base64url");
|
|
283
|
+
}
|
|
284
|
+
const bytes = new Uint8Array(binary.length);
|
|
285
|
+
for (let index = 0;index < binary.length; index += 1) {
|
|
286
|
+
bytes[index] = binary.charCodeAt(index);
|
|
287
|
+
}
|
|
288
|
+
return bytes;
|
|
289
|
+
}
|
|
290
|
+
function toBufferSource(bytes) {
|
|
291
|
+
return bytes.slice().buffer;
|
|
292
|
+
}
|
|
120
293
|
// ../relic-format/src/envelope.ts
|
|
121
294
|
var MAX_FILENAME_BYTES = 1024;
|
|
122
295
|
var MAX_MIMETYPE_BYTES = 255;
|
|
@@ -237,17 +410,17 @@ function recordCapacity(rs) {
|
|
|
237
410
|
var CEK_INFO = new TextEncoder().encode("Content-Encoding: aes128gcm\x00");
|
|
238
411
|
var NONCE_INFO = new TextEncoder().encode("Content-Encoding: nonce\x00");
|
|
239
412
|
async function deriveKeys(ikm, salt) {
|
|
240
|
-
const material = await crypto.subtle.importKey("raw",
|
|
413
|
+
const material = await crypto.subtle.importKey("raw", toBufferSource2(ikm), "HKDF", false, ["deriveBits"]);
|
|
241
414
|
const cekBits = await crypto.subtle.deriveBits({
|
|
242
415
|
name: "HKDF",
|
|
243
416
|
hash: "SHA-256",
|
|
244
|
-
salt:
|
|
417
|
+
salt: toBufferSource2(salt),
|
|
245
418
|
info: CEK_INFO
|
|
246
419
|
}, material, 128);
|
|
247
420
|
const nonceBits = await crypto.subtle.deriveBits({
|
|
248
421
|
name: "HKDF",
|
|
249
422
|
hash: "SHA-256",
|
|
250
|
-
salt:
|
|
423
|
+
salt: toBufferSource2(salt),
|
|
251
424
|
info: NONCE_INFO
|
|
252
425
|
}, material, 96);
|
|
253
426
|
const cek = await crypto.subtle.importKey("raw", cekBits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
@@ -282,10 +455,10 @@ async function encryptRecord(keys, seq, data, isLast, targetDataBytes = data.len
|
|
|
282
455
|
const plaintext = new Uint8Array(targetDataBytes + DELIMITER_BYTES);
|
|
283
456
|
plaintext.set(data, 0);
|
|
284
457
|
plaintext[data.length] = isLast ? 2 : 1;
|
|
285
|
-
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv:
|
|
458
|
+
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv: toBufferSource2(recordNonce(keys.nonceBase, seq)) }, keys.cek, toBufferSource2(plaintext));
|
|
286
459
|
return new Uint8Array(sealed);
|
|
287
460
|
}
|
|
288
|
-
function
|
|
461
|
+
function toBufferSource2(bytes) {
|
|
289
462
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
290
463
|
}
|
|
291
464
|
|
|
@@ -1033,9 +1206,25 @@ async function postJson(deps, url, body) {
|
|
|
1033
1206
|
} catch (error) {
|
|
1034
1207
|
throw new PublishError("service_unreachable", `could not reach ${url}: ${error.message}`);
|
|
1035
1208
|
}
|
|
1036
|
-
const parsed = await response
|
|
1209
|
+
const parsed = await readJson(response);
|
|
1210
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
1211
|
+
}
|
|
1212
|
+
async function getJson(deps, url) {
|
|
1213
|
+
let response;
|
|
1214
|
+
try {
|
|
1215
|
+
response = await deps.fetch(url);
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
throw new PublishError("service_unreachable", `could not reach ${url}: ${error.message}`);
|
|
1218
|
+
}
|
|
1219
|
+
return readJson(response);
|
|
1220
|
+
}
|
|
1221
|
+
async function readJson(response) {
|
|
1222
|
+
const parsed = await response.json().catch(() => {
|
|
1223
|
+
return;
|
|
1224
|
+
});
|
|
1037
1225
|
if (!response.ok) {
|
|
1038
|
-
|
|
1226
|
+
const problem = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
1227
|
+
throw new ServerRefusal(String(problem["code"] ?? "unknown"), response.status, problem);
|
|
1039
1228
|
}
|
|
1040
1229
|
return parsed;
|
|
1041
1230
|
}
|
|
@@ -1104,6 +1293,112 @@ function guessMimetype(filename, rendererClass) {
|
|
|
1104
1293
|
return CLASS_FALLBACK[rendererClass];
|
|
1105
1294
|
}
|
|
1106
1295
|
|
|
1296
|
+
// src/comments.ts
|
|
1297
|
+
async function readComments(relicId, deps) {
|
|
1298
|
+
const state = await localState(relicId);
|
|
1299
|
+
const listed = await getJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/comments`);
|
|
1300
|
+
if (!Array.isArray(listed)) {
|
|
1301
|
+
throw new PublishError("app_response_unusable", "the comment list did not come back as a JSON array, so there is no " + "way to tell an empty conversation from an unreadable response", { relic_id: relicId, leg: "comments" });
|
|
1302
|
+
}
|
|
1303
|
+
const commentKey = await deriveCommentKey(decodeKey(state.key));
|
|
1304
|
+
const comments2 = [];
|
|
1305
|
+
let unreadable = 0;
|
|
1306
|
+
for (const [index, entry] of listed.entries()) {
|
|
1307
|
+
const row = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : {};
|
|
1308
|
+
const commentId = typeof row["comment_id"] === "string" ? row["comment_id"] : `unidentified-${index}`;
|
|
1309
|
+
const author = typeof row["author"] === "string" ? row["author"] : "unknown";
|
|
1310
|
+
const createdAt = typeof row["created_at"] === "string" ? row["created_at"] : "unknown";
|
|
1311
|
+
const ciphertext = row["ciphertext"];
|
|
1312
|
+
if (typeof ciphertext !== "string") {
|
|
1313
|
+
unreadable += 1;
|
|
1314
|
+
comments2.push({
|
|
1315
|
+
comment_id: commentId,
|
|
1316
|
+
author,
|
|
1317
|
+
created_at: createdAt,
|
|
1318
|
+
display_name: null,
|
|
1319
|
+
body: null,
|
|
1320
|
+
readable: false,
|
|
1321
|
+
unreadable_reason: "the row carried no ciphertext"
|
|
1322
|
+
});
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
const plaintext = await decryptComment(commentKey, ciphertext);
|
|
1327
|
+
comments2.push({
|
|
1328
|
+
comment_id: commentId,
|
|
1329
|
+
author,
|
|
1330
|
+
created_at: createdAt,
|
|
1331
|
+
display_name: plaintext.display_name,
|
|
1332
|
+
body: plaintext.body,
|
|
1333
|
+
readable: true,
|
|
1334
|
+
unreadable_reason: null
|
|
1335
|
+
});
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
unreadable += 1;
|
|
1338
|
+
comments2.push({
|
|
1339
|
+
comment_id: commentId,
|
|
1340
|
+
author,
|
|
1341
|
+
created_at: createdAt,
|
|
1342
|
+
display_name: null,
|
|
1343
|
+
body: null,
|
|
1344
|
+
readable: false,
|
|
1345
|
+
unreadable_reason: `it did not decrypt under this relic's comment key: ${error.message}`
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return {
|
|
1350
|
+
relic_id: relicId,
|
|
1351
|
+
count: comments2.length,
|
|
1352
|
+
unreadable_count: unreadable,
|
|
1353
|
+
comments: comments2
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
async function postComment(input, deps) {
|
|
1357
|
+
const state = await localState(input.relic_id);
|
|
1358
|
+
const bodyBytes = new TextEncoder().encode(input.body).length;
|
|
1359
|
+
if (input.body.trim().length === 0) {
|
|
1360
|
+
throw new PublishError("local_comment_body_empty", "a comment needs a body. An empty one is attributable noise nobody can " + "answer.");
|
|
1361
|
+
}
|
|
1362
|
+
if (bodyBytes > COMMENT_BODY_LIMIT_BYTES) {
|
|
1363
|
+
throw new PublishError("local_comment_body_too_long", `the comment body is ${bodyBytes} bytes of UTF-8 and the limit is ` + `${COMMENT_BODY_LIMIT_BYTES}. Shorten it; a truncated comment would ` + "change what it says.", { body_bytes: bodyBytes, limit_bytes: COMMENT_BODY_LIMIT_BYTES });
|
|
1364
|
+
}
|
|
1365
|
+
const displayName = input.display_name ?? null;
|
|
1366
|
+
if (displayName !== null) {
|
|
1367
|
+
const nameBytes = new TextEncoder().encode(displayName).length;
|
|
1368
|
+
if (nameBytes > COMMENT_DISPLAY_NAME_LIMIT_BYTES) {
|
|
1369
|
+
throw new PublishError("local_comment_name_too_long", `the display name is ${nameBytes} bytes of UTF-8 and the limit is ` + `${COMMENT_DISPLAY_NAME_LIMIT_BYTES}.`, { name_bytes: nameBytes, limit_bytes: COMMENT_DISPLAY_NAME_LIMIT_BYTES });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const commentKey = await deriveCommentKey(decodeKey(state.key));
|
|
1373
|
+
const ciphertext = await encryptComment(commentKey, {
|
|
1374
|
+
body: input.body,
|
|
1375
|
+
display_name: displayName
|
|
1376
|
+
});
|
|
1377
|
+
const posted = await postJson(deps, `${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`, { publish_token: state.publish_token, ciphertext });
|
|
1378
|
+
return {
|
|
1379
|
+
relic_id: input.relic_id,
|
|
1380
|
+
comment_id: String(posted["comment_id"]),
|
|
1381
|
+
author: String(posted["author"]),
|
|
1382
|
+
created_at: String(posted["created_at"])
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
async function localState(relicId) {
|
|
1386
|
+
if (!isValidRelicId(relicId)) {
|
|
1387
|
+
throw new PublishError("no_local_publish_state", `"${relicId}" is not a relic id. These tools take the 26-character id ` + "the original publish returned, never the share URL: the URL carries " + "the key in its fragment, and passing it would put the key in this " + "transcript for nothing.");
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const loaded = await loadPublishState(relicId);
|
|
1391
|
+
if (loaded === undefined) {
|
|
1392
|
+
throw new PublishError("no_local_publish_state", `relic ${relicId} was published from another machine, so its ` + "comments can be neither read nor written here. The key that " + "decrypts a comment and the publish token that authorizes one live " + "only on the machine that made the first publish, and neither can " + "be reconstructed from the link or from the service. Open the " + "relic's own page to read its comments, or ask whoever published it.");
|
|
1393
|
+
}
|
|
1394
|
+
return loaded;
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
if (error instanceof PublishError)
|
|
1397
|
+
throw error;
|
|
1398
|
+
throw new PublishError("local_state_unreadable", error.message);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1107
1402
|
// src/republish.ts
|
|
1108
1403
|
async function republish(input, deps) {
|
|
1109
1404
|
if (!isValidRelicId(input.relic_id)) {
|
|
@@ -1162,11 +1457,15 @@ var TOOL_NAME = "relic_publish";
|
|
|
1162
1457
|
var DESCRIBE_TOOL_NAME = "relic_describe_client";
|
|
1163
1458
|
var REPUBLISH_TOOL_NAME = "relic_republish";
|
|
1164
1459
|
var LOOKUP_TOOL_NAME = "relic_lookup_source";
|
|
1460
|
+
var READ_COMMENTS_TOOL_NAME = "relic_read_comments";
|
|
1461
|
+
var COMMENT_TOOL_NAME = "relic_comment";
|
|
1462
|
+
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.";
|
|
1165
1463
|
var MAX_TTL_DAYS = 3650;
|
|
1464
|
+
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.";
|
|
1166
1465
|
var TOOL_DEFINITION = {
|
|
1167
1466
|
name: TOOL_NAME,
|
|
1168
1467
|
title: "Publish a relic",
|
|
1169
|
-
description: "Encrypt a file on this machine and publish it as a new relic, returning " + "a shareable URL. Publishing an update this way costs a second URL that " + "nobody holding the first one will ever see; use relic_republish instead " + "so the existing URL keeps working. The encryption key is generated
|
|
1468
|
+
description: "Encrypt a file on this machine and publish it as a new relic, returning " + "a shareable URL. Publishing an update this way costs a second URL that " + "nobody holding the first one will ever see; use relic_republish instead " + "so the existing URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " The encryption key is generated locally and never sent to the service. " + "Takes a filesystem path, never " + "inline content.",
|
|
1170
1469
|
inputSchema: {
|
|
1171
1470
|
type: "object",
|
|
1172
1471
|
properties: {
|
|
@@ -1227,7 +1526,7 @@ var TOOL_DEFINITION = {
|
|
|
1227
1526
|
var REPUBLISH_TOOL_DEFINITION = {
|
|
1228
1527
|
name: REPUBLISH_TOOL_NAME,
|
|
1229
1528
|
title: "Republish a relic",
|
|
1230
|
-
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.",
|
|
1529
|
+
description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
|
|
1231
1530
|
inputSchema: {
|
|
1232
1531
|
type: "object",
|
|
1233
1532
|
properties: {
|
|
@@ -1336,6 +1635,99 @@ var LOOKUP_TOOL_DEFINITION = {
|
|
|
1336
1635
|
additionalProperties: false
|
|
1337
1636
|
}
|
|
1338
1637
|
};
|
|
1638
|
+
var READ_COMMENTS_TOOL_DEFINITION = {
|
|
1639
|
+
name: READ_COMMENTS_TOOL_NAME,
|
|
1640
|
+
title: "Read a relic's comments",
|
|
1641
|
+
description: "Read the comments people have left on a relic, oldest first, decrypted " + "on this machine. Use it before changing content somebody was asked to " + "review, and after sharing a link, because a comment is the only way a " + "reader can answer back. " + COMMENT_MACHINE_BOUNDARY + " Takes the relic id, never the share URL: the URL carries the key in " + "its fragment. A comment that will not decrypt is returned marked " + "unreadable rather than dropped, so a shortened list never reads as " + "agreement.",
|
|
1642
|
+
inputSchema: {
|
|
1643
|
+
type: "object",
|
|
1644
|
+
properties: {
|
|
1645
|
+
relic_id: {
|
|
1646
|
+
type: "string",
|
|
1647
|
+
description: "The 26-character relic id the original publish returned."
|
|
1648
|
+
}
|
|
1649
|
+
},
|
|
1650
|
+
required: ["relic_id"],
|
|
1651
|
+
additionalProperties: false
|
|
1652
|
+
},
|
|
1653
|
+
outputSchema: {
|
|
1654
|
+
type: "object",
|
|
1655
|
+
properties: {
|
|
1656
|
+
relic_id: { type: "string" },
|
|
1657
|
+
count: { type: "integer", minimum: 0 },
|
|
1658
|
+
unreadable_count: {
|
|
1659
|
+
type: "integer",
|
|
1660
|
+
minimum: 0,
|
|
1661
|
+
description: "How many of `count` did not decrypt. Above zero means part of the " + "conversation is unread, not absent."
|
|
1662
|
+
},
|
|
1663
|
+
comments: {
|
|
1664
|
+
type: "array",
|
|
1665
|
+
items: {
|
|
1666
|
+
type: "object",
|
|
1667
|
+
properties: {
|
|
1668
|
+
comment_id: { type: "string" },
|
|
1669
|
+
author: {
|
|
1670
|
+
type: "string",
|
|
1671
|
+
description: 'The commenter’s verified email address, or "publisher" ' + "for a comment written with a publish token."
|
|
1672
|
+
},
|
|
1673
|
+
created_at: { type: "string" },
|
|
1674
|
+
display_name: { type: ["string", "null"] },
|
|
1675
|
+
body: { type: ["string", "null"] },
|
|
1676
|
+
readable: { type: "boolean" },
|
|
1677
|
+
unreadable_reason: { type: ["string", "null"] }
|
|
1678
|
+
},
|
|
1679
|
+
required: [
|
|
1680
|
+
"comment_id",
|
|
1681
|
+
"author",
|
|
1682
|
+
"created_at",
|
|
1683
|
+
"display_name",
|
|
1684
|
+
"body",
|
|
1685
|
+
"readable",
|
|
1686
|
+
"unreadable_reason"
|
|
1687
|
+
],
|
|
1688
|
+
additionalProperties: false
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
},
|
|
1692
|
+
required: ["relic_id", "count", "unreadable_count", "comments"],
|
|
1693
|
+
additionalProperties: false
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
var COMMENT_TOOL_DEFINITION = {
|
|
1697
|
+
name: COMMENT_TOOL_NAME,
|
|
1698
|
+
title: "Comment on a relic",
|
|
1699
|
+
description: "Leave a comment on a relic this machine published, encrypted here so " + "the service stores ciphertext it cannot read. Everyone holding the " + "link sees it. Attribution is the publish token, so the comment is " + 'attributed to "publisher" rather than to an email address: an agent has ' + "no mailbox and cannot verify one. That is attribution and not " + "authorization. " + COMMENT_MACHINE_BOUNDARY + " Takes the relic id, never the share URL.",
|
|
1700
|
+
inputSchema: {
|
|
1701
|
+
type: "object",
|
|
1702
|
+
properties: {
|
|
1703
|
+
relic_id: {
|
|
1704
|
+
type: "string",
|
|
1705
|
+
description: "The 26-character relic id the original publish returned."
|
|
1706
|
+
},
|
|
1707
|
+
body: {
|
|
1708
|
+
type: "string",
|
|
1709
|
+
description: "The comment text, up to 4096 bytes of UTF-8. It is encrypted " + "before it leaves this machine."
|
|
1710
|
+
},
|
|
1711
|
+
display_name: {
|
|
1712
|
+
type: "string",
|
|
1713
|
+
description: "Optional. A name shown beside the comment, up to 64 bytes of " + "UTF-8. It aliases the attribution for presentation and never " + "replaces it."
|
|
1714
|
+
}
|
|
1715
|
+
},
|
|
1716
|
+
required: ["relic_id", "body"],
|
|
1717
|
+
additionalProperties: false
|
|
1718
|
+
},
|
|
1719
|
+
outputSchema: {
|
|
1720
|
+
type: "object",
|
|
1721
|
+
properties: {
|
|
1722
|
+
relic_id: { type: "string" },
|
|
1723
|
+
comment_id: { type: "string" },
|
|
1724
|
+
author: { type: "string" },
|
|
1725
|
+
created_at: { type: "string" }
|
|
1726
|
+
},
|
|
1727
|
+
required: ["relic_id", "comment_id", "author", "created_at"],
|
|
1728
|
+
additionalProperties: false
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1339
1731
|
var DESCRIBE_TOOL_DEFINITION = {
|
|
1340
1732
|
name: DESCRIBE_TOOL_NAME,
|
|
1341
1733
|
title: "Describe the Relic client",
|
|
@@ -1346,7 +1738,7 @@ var DESCRIBE_TOOL_DEFINITION = {
|
|
|
1346
1738
|
additionalProperties: false
|
|
1347
1739
|
}
|
|
1348
1740
|
};
|
|
1349
|
-
var SERVER_VERSION = "0.
|
|
1741
|
+
var SERVER_VERSION = "0.4.0";
|
|
1350
1742
|
var SERVER_INFO = {
|
|
1351
1743
|
name: "relic",
|
|
1352
1744
|
title: "Relic",
|
|
@@ -1355,13 +1747,14 @@ var SERVER_INFO = {
|
|
|
1355
1747
|
var CAPABILITIES = { tools: {} };
|
|
1356
1748
|
var INSTRUCTIONS = `Relic encrypts a file on this machine and uploads only ciphertext. The key lives in the URL fragment, which browsers never send to a server.
|
|
1357
1749
|
|
|
1358
|
-
|
|
1750
|
+
Six things that change how you should act:
|
|
1359
1751
|
|
|
1360
1752
|
1. The link is the credential. Anyone holding it, fragment included, can read the file. Do not paste it into a tracker, a log, or a public channel.
|
|
1361
1753
|
2. Publishing puts the key in this transcript. That is structural rather than a defect, and worth saying plainly when you hand the link over.
|
|
1362
|
-
3.
|
|
1754
|
+
3. Check existing sources with relic_lookup_source. Use relic_republish when found; relic_publish otherwise costs a second URL. ${VERSION_HISTORY_DISCLOSURE}
|
|
1363
1755
|
4. A relic can be republished only from the machine that published it, which is where its key and publish token are stored. Anywhere else it refuses, and no retry changes that.
|
|
1364
|
-
5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file
|
|
1756
|
+
5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file.
|
|
1757
|
+
6. People can comment on a relic. Read them with relic_read_comments before you change reviewed content, and answer with relic_comment. Both take the relic id, work only on the machine that published, and attribute you as the publisher.`;
|
|
1365
1758
|
async function handleMessage(message, deps) {
|
|
1366
1759
|
if (message.id === undefined)
|
|
1367
1760
|
return;
|
|
@@ -1407,6 +1800,8 @@ async function handleMessage(message, deps) {
|
|
|
1407
1800
|
TOOL_DEFINITION,
|
|
1408
1801
|
LOOKUP_TOOL_DEFINITION,
|
|
1409
1802
|
REPUBLISH_TOOL_DEFINITION,
|
|
1803
|
+
READ_COMMENTS_TOOL_DEFINITION,
|
|
1804
|
+
COMMENT_TOOL_DEFINITION,
|
|
1410
1805
|
DESCRIBE_TOOL_DEFINITION
|
|
1411
1806
|
]
|
|
1412
1807
|
}
|
|
@@ -1437,6 +1832,8 @@ async function callTool(id2, params, deps) {
|
|
|
1437
1832
|
plaintext_transmitted_to_service: false,
|
|
1438
1833
|
ciphertext_destination: "object storage, via a signed URL",
|
|
1439
1834
|
local_publish_state: "relic id, source identity, key, and publish token per relic, " + "written 0600 under the user config directory; key and token " + "are never printed or sent",
|
|
1835
|
+
comment_encryption: "AES-128-GCM under a key derived from the relic key with a " + "distinct HKDF label, so comment bodies reach the service as " + "ciphertext and the URL fragment is unchanged",
|
|
1836
|
+
comment_attribution: 'the publish token, reported by the service as "publisher"; the ' + "operator learns which identity commented on which relic and when",
|
|
1440
1837
|
service_origin: deps.serviceOrigin
|
|
1441
1838
|
},
|
|
1442
1839
|
isError: false
|
|
@@ -1449,6 +1846,12 @@ async function callTool(id2, params, deps) {
|
|
|
1449
1846
|
if (params["name"] === REPUBLISH_TOOL_NAME) {
|
|
1450
1847
|
return callRepublish(id2, params, deps);
|
|
1451
1848
|
}
|
|
1849
|
+
if (params["name"] === READ_COMMENTS_TOOL_NAME) {
|
|
1850
|
+
return callReadComments(id2, params, deps);
|
|
1851
|
+
}
|
|
1852
|
+
if (params["name"] === COMMENT_TOOL_NAME) {
|
|
1853
|
+
return callComment(id2, params, deps);
|
|
1854
|
+
}
|
|
1452
1855
|
if (params["name"] !== TOOL_NAME) {
|
|
1453
1856
|
return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
|
|
1454
1857
|
}
|
|
@@ -1486,7 +1889,8 @@ async function callTool(id2, params, deps) {
|
|
|
1486
1889
|
|
|
1487
1890
|
` + (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.
|
|
1488
1891
|
` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
|
|
1489
|
-
}
|
|
1892
|
+
},
|
|
1893
|
+
{ type: "text", text: VERSION_HISTORY_DISCLOSURE }
|
|
1490
1894
|
],
|
|
1491
1895
|
structuredContent: result,
|
|
1492
1896
|
isError: false
|
|
@@ -1564,6 +1968,63 @@ async function callRepublish(id2, params, deps) {
|
|
|
1564
1968
|
|
|
1565
1969
|
` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
|
|
1566
1970
|
` + `What Relic knows: ${result.disclosure_url}`
|
|
1971
|
+
},
|
|
1972
|
+
{ type: "text", text: VERSION_HISTORY_DISCLOSURE }
|
|
1973
|
+
],
|
|
1974
|
+
structuredContent: result,
|
|
1975
|
+
isError: false
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
async function callReadComments(id2, params, deps) {
|
|
1983
|
+
const args = params["arguments"] ?? {};
|
|
1984
|
+
const relicId = args["relic_id"];
|
|
1985
|
+
if (typeof relicId !== "string" || relicId.length === 0) {
|
|
1986
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
|
|
1987
|
+
}
|
|
1988
|
+
try {
|
|
1989
|
+
const result = await readComments(relicId, deps);
|
|
1990
|
+
return {
|
|
1991
|
+
jsonrpc: "2.0",
|
|
1992
|
+
id: id2,
|
|
1993
|
+
result: {
|
|
1994
|
+
content: [{ type: "text", text: commentTranscript(result) }],
|
|
1995
|
+
structuredContent: result,
|
|
1996
|
+
isError: false
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
async function callComment(id2, params, deps) {
|
|
2004
|
+
const args = params["arguments"] ?? {};
|
|
2005
|
+
const relicId = args["relic_id"];
|
|
2006
|
+
if (typeof relicId !== "string" || relicId.length === 0) {
|
|
2007
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
|
|
2008
|
+
}
|
|
2009
|
+
const body = args["body"];
|
|
2010
|
+
if (typeof body !== "string") {
|
|
2011
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`body` is required and must be a string");
|
|
2012
|
+
}
|
|
2013
|
+
const displayName = args["display_name"];
|
|
2014
|
+
if (displayName !== undefined && typeof displayName !== "string") {
|
|
2015
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`display_name` must be a string or omitted");
|
|
2016
|
+
}
|
|
2017
|
+
try {
|
|
2018
|
+
const result = await postComment({ relic_id: relicId, body, display_name: displayName }, deps);
|
|
2019
|
+
return {
|
|
2020
|
+
jsonrpc: "2.0",
|
|
2021
|
+
id: id2,
|
|
2022
|
+
result: {
|
|
2023
|
+
content: [
|
|
2024
|
+
{
|
|
2025
|
+
type: "text",
|
|
2026
|
+
text: `Commented on relic ${result.relic_id} as ${result.author}.
|
|
2027
|
+
` + "Everyone holding the link sees it. The service stored " + "ciphertext it cannot read, and it knows that this address " + "commented on this relic at this time."
|
|
1567
2028
|
}
|
|
1568
2029
|
],
|
|
1569
2030
|
structuredContent: result,
|
|
@@ -1574,6 +2035,23 @@ async function callRepublish(id2, params, deps) {
|
|
|
1574
2035
|
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1575
2036
|
}
|
|
1576
2037
|
}
|
|
2038
|
+
function commentTranscript(result) {
|
|
2039
|
+
if (result.count === 0) {
|
|
2040
|
+
return `No comments on relic ${result.relic_id} yet.`;
|
|
2041
|
+
}
|
|
2042
|
+
const lines = result.comments.map((comment) => {
|
|
2043
|
+
const who = comment.display_name === null ? comment.author : `${comment.display_name} (${comment.author})`;
|
|
2044
|
+
return comment.readable ? `${comment.created_at} ${who}:
|
|
2045
|
+
${comment.body}` : `${comment.created_at} ${who}:
|
|
2046
|
+
[unreadable: ${comment.unreadable_reason}]`;
|
|
2047
|
+
});
|
|
2048
|
+
const header = result.unreadable_count === 0 ? `${result.count} comment(s) on relic ${result.relic_id}, oldest first.` : `${result.count} comment(s) on relic ${result.relic_id}, oldest ` + `first. ${result.unreadable_count} did not decrypt and are shown as ` + "unreadable rather than dropped, so treat this conversation as " + "partially unread.";
|
|
2049
|
+
return `${header}
|
|
2050
|
+
|
|
2051
|
+
${lines.join(`
|
|
2052
|
+
|
|
2053
|
+
`)}`;
|
|
2054
|
+
}
|
|
1577
2055
|
function parseTtlDays(raw) {
|
|
1578
2056
|
if (raw === undefined || raw === null)
|
|
1579
2057
|
return { ok: true, days: undefined };
|
|
@@ -1584,7 +2062,8 @@ function parseTtlDays(raw) {
|
|
|
1584
2062
|
}
|
|
1585
2063
|
var REFUSAL_GUIDANCE = {
|
|
1586
2064
|
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.",
|
|
1587
|
-
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."
|
|
2065
|
+
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.",
|
|
2066
|
+
comment_rate_limited: "The service is rate limiting comments on that relic. The refusal " + "carries retry_after_seconds; wait it out rather than retrying in a " + "loop, which only extends the limit."
|
|
1588
2067
|
};
|
|
1589
2068
|
function toolError(error) {
|
|
1590
2069
|
if (error instanceof PublishError) {
|
|
@@ -1609,7 +2088,7 @@ ${guidance}`)
|
|
|
1609
2088
|
};
|
|
1610
2089
|
}
|
|
1611
2090
|
return {
|
|
1612
|
-
content: [{ type: "text", text: `
|
|
2091
|
+
content: [{ type: "text", text: `the call failed: ${String(error)}` }],
|
|
1613
2092
|
structuredContent: { code: "unknown" },
|
|
1614
2093
|
isError: true
|
|
1615
2094
|
};
|
|
@@ -1647,6 +2126,17 @@ service ever holds, and neither secret is ever printed or logged. Deleting the
|
|
|
1647
2126
|
file changes nothing for existing links; it only ends this machine's ability
|
|
1648
2127
|
to update those relics.
|
|
1649
2128
|
|
|
2129
|
+
What happens with comments: a comment body is encrypted here too, under a key
|
|
2130
|
+
derived from that relic's key with a distinct HKDF label, so the service
|
|
2131
|
+
stores comment ciphertext it cannot read and the URL fragment does not change.
|
|
2132
|
+
Reading comments needs that key, and writing one is authorized by the publish
|
|
2133
|
+
token, so both work only on the machine that published. A comment this client
|
|
2134
|
+
writes is attributed to the publisher rather than an email address, because
|
|
2135
|
+
an agent has no mailbox to verify. What the operator does learn is who
|
|
2136
|
+
commented on which relic and when: for a person that is a verified email
|
|
2137
|
+
address, and that association is a real cost the content's encryption does
|
|
2138
|
+
not cover.
|
|
2139
|
+
|
|
1650
2140
|
What this does NOT protect against: the key is returned to your agent in the
|
|
1651
2141
|
URL, so it enters the model's context and your session transcript. That is
|
|
1652
2142
|
structural, not a defect. Anyone who can read this conversation can open the
|