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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: relic
3
- description: Publish a local file as an encrypted, shareable link when someone outside this session needs to see it. Use when the user says "share this", "send this to X", "publish this", "give me a link for this", "make this shareable", or has just been handed a generated report, HTML page, deck, image, or export and needs it somewhere a person can open. Also covers republishing a new version of an existing relic, what the recipient sees, how long a link lives, and what the service can and cannot read.
3
+ description: Publish a local file as an encrypted, shareable link when someone outside this session needs to see it. Use when the user says "share this", "send this to X", "publish this", "give me a link for this", "make this shareable", or has just been handed a generated report, HTML page, deck, image, or export and needs it somewhere a person can open. Also covers republishing a new version of an existing relic, reading the comments people leave on one and answering them, what the recipient sees, how long a link lives, and what the service can and cannot read.
4
4
  ---
5
5
 
6
6
  # Relic
@@ -39,6 +39,11 @@ Do not publish an update as a new relic. That costs a second URL that nobody
39
39
  holding the first one will ever see. `relic_publish` enforces this: when local
40
40
  state matches the source, it refuses and points to `relic_republish`.
41
41
 
42
+ Anyone holding a relic's link can fetch every version it has ever held, so
43
+ republishing does not withdraw earlier content. Republishing moves the artifact
44
+ forward without retracting what came before. Deleting the relic still removes
45
+ every version.
46
+
42
47
  Optional arguments worth knowing:
43
48
 
44
49
  - `filename` overrides the display name shown to the recipient.
@@ -81,6 +86,50 @@ Two things to know before promising an update:
81
86
  A relic's lifetime is set at its first publish and carries across versions
82
87
  unchanged.
83
88
 
89
+ ## Comments
90
+
91
+ People can comment on a relic, and that is the only way a reader can answer
92
+ back: there is no reply-to, no dashboard, and no notification. So read them.
93
+
94
+ ```
95
+ relic_read_comments(relic_id: "0a2c...")
96
+ ```
97
+
98
+ Read them **before** you change content somebody was asked to review, and read
99
+ them again after you hand a link over and come back to the task. A comment
100
+ that nobody read is the same as a comment nobody left, except somebody spent
101
+ the effort.
102
+
103
+ Answer with:
104
+
105
+ ```
106
+ relic_comment(relic_id: "0a2c...", body: "Fixed the chart, republished as version 3.")
107
+ ```
108
+
109
+ Four things to know:
110
+
111
+ - **Both work only on the machine that published the relic.** The comment key
112
+ is derived from that relic's key, which lives in the same local 0600 file as
113
+ the publish token. On any other machine they refuse, for the same reason
114
+ `relic_republish` does, and no retry changes it.
115
+ - **Pass the relic id, never the URL.** The URL carries the key in its
116
+ fragment. Passing it would put the key in the transcript again for nothing.
117
+ The tools refuse a URL and say so.
118
+ - **Your comment is attributed to the publisher, not to a person.** A human
119
+ commenter verifies an email address through a magic link and that address is
120
+ their identity. You have no mailbox, so the publish token stands in and the
121
+ comment reads as `publisher`. Optional `display_name` puts a label beside it;
122
+ it decorates the attribution and never replaces it.
123
+ - **A comment that will not decrypt comes back marked unreadable**, with a
124
+ count. That is not noise to filter out: it means part of the conversation is
125
+ unread. Say so rather than acting as though the readable ones are all of it.
126
+
127
+ Comment bodies are encrypted on this machine, so the service stores ciphertext
128
+ it cannot read. What it does learn is who commented on which relic and when,
129
+ which for a human commenter is a verified email address. Worth saying plainly
130
+ if somebody asks what commenting costs them: the content stays private and the
131
+ participation does not.
132
+
84
133
  ## Say this when you hand over the link
85
134
 
86
135
  **The key is in the URL, and the URL is now in the transcript.** Anyone with
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Comments, from an agent's seat.
3
+ *
4
+ * The feature exists so a person can leave a comment and an agent can read it
5
+ * back and act on it. The agent's half has two constraints the person's does
6
+ * not, and both are structural rather than stylistic.
7
+ *
8
+ * **An agent cannot receive email.** `docs/frame.md` makes a verified email
9
+ * address the commenter identity, and verification runs through a magic link,
10
+ * which needs a mailbox. There is no mailbox here. The publish token already
11
+ * proves this machine published the relic, so it stands as the agent's
12
+ * identity and the service attributes the comment to `publisher`. That is
13
+ * attribution, never authorization: the same non-goal entry says verified
14
+ * email buys attribution and not entitlement, and a bearer token buys less.
15
+ *
16
+ * **The share URL is never an argument.** The fragment is the key, so a tool
17
+ * accepting the URL would put the key in the transcript on every read, which
18
+ * is the one disclosure `spec/publish.md` section 5 spends deliberately and
19
+ * exactly once, at publish. These tools take the relic id and read the key
20
+ * from local publish state instead, which draws the same machine boundary
21
+ * republish already draws: only the machine that published can comment on a
22
+ * relic or read its comments here.
23
+ *
24
+ * The comment key is derived from the fragment's key bytes under a distinct
25
+ * HKDF `info`, so it is independent of the container key by construction, and
26
+ * the fragment does not change. `spec/format.md` 2.1 fixes the fragment at
27
+ * the marker and the key, and a third field would cost a version bump.
28
+ */
29
+
30
+ import {
31
+ COMMENT_BODY_LIMIT_BYTES,
32
+ COMMENT_DISPLAY_NAME_LIMIT_BYTES,
33
+ decodeKey,
34
+ decryptComment,
35
+ deriveCommentKey,
36
+ encryptComment,
37
+ isValidRelicId,
38
+ } from '@relic/format';
39
+ import {
40
+ getJson,
41
+ type PublishDeps,
42
+ PublishError,
43
+ postJson,
44
+ } from './publish.ts';
45
+ import { loadPublishState, type PublishState } from './state.ts';
46
+
47
+ /** One comment as an agent reads it. */
48
+ export interface CommentRecord {
49
+ readonly comment_id: string;
50
+ /**
51
+ * The verified email address of a human commenter, or the literal
52
+ * `publisher` for a comment authorized by a publish token. Returned as the
53
+ * service gave it: `docs/frame.md` makes the address the identity, so a
54
+ * display name aliases it and never replaces it.
55
+ */
56
+ readonly author: string;
57
+ readonly created_at: string;
58
+ /** The commenter's chosen alias, when they set one. Decoration, not identity. */
59
+ readonly display_name: string | null;
60
+ /** Null exactly when `readable` is false. */
61
+ readonly body: string | null;
62
+ readonly readable: boolean;
63
+ /** Null exactly when `readable` is true. */
64
+ readonly unreadable_reason: string | null;
65
+ }
66
+
67
+ export interface ReadCommentsResult {
68
+ readonly relic_id: string;
69
+ readonly count: number;
70
+ /**
71
+ * Comments the comment key did not open. Reported rather than dropped: a
72
+ * silently shortened list reads as agreement, and an agent acting on
73
+ * "nobody objected" when somebody did is the failure this member prevents.
74
+ */
75
+ readonly unreadable_count: number;
76
+ readonly comments: readonly CommentRecord[];
77
+ }
78
+
79
+ export interface CommentResult {
80
+ readonly relic_id: string;
81
+ readonly comment_id: string;
82
+ readonly author: string;
83
+ readonly created_at: string;
84
+ }
85
+
86
+ export interface CommentInput {
87
+ readonly relic_id: string;
88
+ readonly body: string;
89
+ readonly display_name?: string | undefined;
90
+ }
91
+
92
+ export async function readComments(
93
+ relicId: string,
94
+ deps: PublishDeps
95
+ ): Promise<ReadCommentsResult> {
96
+ const state = await localState(relicId);
97
+
98
+ // No credential on this read. Anyone holding the link can already fetch the
99
+ // ciphertext, and the bodies are ciphertext the service cannot open, so
100
+ // authorizing it would gate nothing and cost the token an exposure.
101
+ const listed = await getJson(
102
+ deps,
103
+ `${deps.serviceOrigin}/api/relics/${relicId}/comments`
104
+ );
105
+ if (!Array.isArray(listed)) {
106
+ throw new PublishError(
107
+ 'app_response_unusable',
108
+ 'the comment list did not come back as a JSON array, so there is no ' +
109
+ 'way to tell an empty conversation from an unreadable response',
110
+ { relic_id: relicId, leg: 'comments' }
111
+ );
112
+ }
113
+
114
+ const commentKey = await deriveCommentKey(decodeKey(state.key));
115
+ const comments: CommentRecord[] = [];
116
+ let unreadable = 0;
117
+
118
+ for (const [index, entry] of listed.entries()) {
119
+ const row =
120
+ typeof entry === 'object' && entry !== null && !Array.isArray(entry)
121
+ ? (entry as Record<string, unknown>)
122
+ : {};
123
+ const commentId =
124
+ typeof row['comment_id'] === 'string'
125
+ ? row['comment_id']
126
+ : `unidentified-${index}`;
127
+ const author =
128
+ typeof row['author'] === 'string' ? row['author'] : 'unknown';
129
+ const createdAt =
130
+ typeof row['created_at'] === 'string' ? row['created_at'] : 'unknown';
131
+ const ciphertext = row['ciphertext'];
132
+
133
+ if (typeof ciphertext !== 'string') {
134
+ unreadable += 1;
135
+ comments.push({
136
+ comment_id: commentId,
137
+ author,
138
+ created_at: createdAt,
139
+ display_name: null,
140
+ body: null,
141
+ readable: false,
142
+ unreadable_reason: 'the row carried no ciphertext',
143
+ });
144
+ continue;
145
+ }
146
+
147
+ try {
148
+ const plaintext = await decryptComment(commentKey, ciphertext);
149
+ comments.push({
150
+ comment_id: commentId,
151
+ author,
152
+ created_at: createdAt,
153
+ display_name: plaintext.display_name,
154
+ body: plaintext.body,
155
+ readable: true,
156
+ unreadable_reason: null,
157
+ });
158
+ } catch (error) {
159
+ // One comment that will not open must not hide the ones that will, and
160
+ // it must not vanish either. It comes back named, with the reason.
161
+ unreadable += 1;
162
+ comments.push({
163
+ comment_id: commentId,
164
+ author,
165
+ created_at: createdAt,
166
+ display_name: null,
167
+ body: null,
168
+ readable: false,
169
+ unreadable_reason: `it did not decrypt under this relic's comment key: ${
170
+ (error as Error).message
171
+ }`,
172
+ });
173
+ }
174
+ }
175
+
176
+ return {
177
+ relic_id: relicId,
178
+ count: comments.length,
179
+ unreadable_count: unreadable,
180
+ comments,
181
+ };
182
+ }
183
+
184
+ export async function postComment(
185
+ input: CommentInput,
186
+ deps: PublishDeps
187
+ ): Promise<CommentResult> {
188
+ const state = await localState(input.relic_id);
189
+
190
+ // The caps belong to the envelope, so they are enforced against its numbers
191
+ // rather than a second copy of them, and enforced before encryption so the
192
+ // refusal names the limit instead of a cipher failure.
193
+ const bodyBytes = new TextEncoder().encode(input.body).length;
194
+ if (input.body.trim().length === 0) {
195
+ throw new PublishError(
196
+ 'local_comment_body_empty',
197
+ 'a comment needs a body. An empty one is attributable noise nobody can ' +
198
+ 'answer.'
199
+ );
200
+ }
201
+ if (bodyBytes > COMMENT_BODY_LIMIT_BYTES) {
202
+ throw new PublishError(
203
+ 'local_comment_body_too_long',
204
+ `the comment body is ${bodyBytes} bytes of UTF-8 and the limit is ` +
205
+ `${COMMENT_BODY_LIMIT_BYTES}. Shorten it; a truncated comment would ` +
206
+ 'change what it says.',
207
+ { body_bytes: bodyBytes, limit_bytes: COMMENT_BODY_LIMIT_BYTES }
208
+ );
209
+ }
210
+
211
+ const displayName = input.display_name ?? null;
212
+ if (displayName !== null) {
213
+ const nameBytes = new TextEncoder().encode(displayName).length;
214
+ if (nameBytes > COMMENT_DISPLAY_NAME_LIMIT_BYTES) {
215
+ throw new PublishError(
216
+ 'local_comment_name_too_long',
217
+ `the display name is ${nameBytes} bytes of UTF-8 and the limit is ` +
218
+ `${COMMENT_DISPLAY_NAME_LIMIT_BYTES}.`,
219
+ { name_bytes: nameBytes, limit_bytes: COMMENT_DISPLAY_NAME_LIMIT_BYTES }
220
+ );
221
+ }
222
+ }
223
+
224
+ const commentKey = await deriveCommentKey(decodeKey(state.key));
225
+ const ciphertext = await encryptComment(commentKey, {
226
+ body: input.body,
227
+ display_name: displayName,
228
+ });
229
+
230
+ // The token travels in the body, where the republish grant already puts it,
231
+ // so the two write paths authorize the same way and neither invents a
232
+ // header the service has to learn.
233
+ const posted = await postJson(
234
+ deps,
235
+ `${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`,
236
+ { publish_token: state.publish_token, ciphertext }
237
+ );
238
+
239
+ return {
240
+ relic_id: input.relic_id,
241
+ comment_id: String(posted['comment_id']),
242
+ author: String(posted['author']),
243
+ created_at: String(posted['created_at']),
244
+ };
245
+ }
246
+
247
+ /**
248
+ * The machine boundary, checked before anything touches the network.
249
+ *
250
+ * Both tools need the key, and the write needs the publish token too. Neither
251
+ * can be reconstructed from the link or from the service, so a relic this
252
+ * machine never published is refused here rather than after a round trip that
253
+ * could only end the same way.
254
+ */
255
+ async function localState(relicId: string): Promise<PublishState> {
256
+ if (!isValidRelicId(relicId)) {
257
+ throw new PublishError(
258
+ 'no_local_publish_state',
259
+ `"${relicId}" is not a relic id. These tools take the 26-character id ` +
260
+ 'the original publish returned, never the share URL: the URL carries ' +
261
+ 'the key in its fragment, and passing it would put the key in this ' +
262
+ 'transcript for nothing.'
263
+ );
264
+ }
265
+
266
+ try {
267
+ const loaded = await loadPublishState(relicId);
268
+ if (loaded === undefined) {
269
+ throw new PublishError(
270
+ 'no_local_publish_state',
271
+ `relic ${relicId} was published from another machine, so its ` +
272
+ 'comments can be neither read nor written here. The key that ' +
273
+ 'decrypts a comment and the publish token that authorizes one live ' +
274
+ 'only on the machine that made the first publish, and neither can ' +
275
+ 'be reconstructed from the link or from the service. Open the ' +
276
+ "relic's own page to read its comments, or ask whoever published it."
277
+ );
278
+ }
279
+ return loaded;
280
+ } catch (error) {
281
+ // State-file damage is not "published elsewhere"; naming it as that would
282
+ // send someone hunting the wrong machine.
283
+ if (error instanceof PublishError) throw error;
284
+ throw new PublishError('local_state_unreadable', (error as Error).message);
285
+ }
286
+ }
package/src/publish.ts CHANGED
@@ -36,6 +36,11 @@ import {
36
36
  * app-server status, so they get codes here, and these never collide with the
37
37
  * server's. The state codes join them for the same reason: the publish state
38
38
  * is this machine's alone, so its failures are this machine's to report.
39
+ *
40
+ * The `local_comment_*` codes take the `local_` prefix `spec/publish.md` 2.2
41
+ * reserves for a client-side refusal with no leg, rather than a bare
42
+ * `comment_` one. The service owns its own comment refusals, and an
43
+ * unprefixed name here would eventually collide with one of them.
39
44
  */
40
45
  export type ClientCode =
41
46
  | 'source_not_found'
@@ -49,7 +54,11 @@ export type ClientCode =
49
54
  | 'grant_missing_publish_token'
50
55
  | 'no_local_publish_state'
51
56
  | 'local_state_unreadable'
52
- | 'local_state_write_failed';
57
+ | 'local_state_write_failed'
58
+ | 'local_comment_body_empty'
59
+ | 'local_comment_body_too_long'
60
+ | 'local_comment_name_too_long'
61
+ | 'app_response_unusable';
53
62
 
54
63
  export class PublishError extends Error {
55
64
  override readonly name = 'PublishError';
@@ -457,18 +466,60 @@ export async function postJson(
457
466
  );
458
467
  }
459
468
 
460
- const parsed = (await response.json().catch(() => ({}))) as Record<
461
- string,
462
- unknown
463
- >;
469
+ const parsed = await readJson(response);
470
+ // Every publish leg answers with an object. A body that is not one is read
471
+ // as empty rather than crashing the caller on its first member read.
472
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
473
+ ? (parsed as Record<string, unknown>)
474
+ : {};
475
+ }
476
+
477
+ /**
478
+ * GET a JSON document from the app server.
479
+ *
480
+ * Separate from `postJson` only because the payload may legitimately be an
481
+ * array: the comment list is one. The refusal handling is the same leg and
482
+ * the same rules, so it is shared rather than restated.
483
+ */
484
+ export async function getJson(
485
+ deps: PublishDeps,
486
+ url: string
487
+ ): Promise<unknown> {
488
+ let response: Response;
489
+ try {
490
+ response = await deps.fetch(url);
491
+ } catch (error) {
492
+ throw new PublishError(
493
+ 'service_unreachable',
494
+ `could not reach ${url}: ${(error as Error).message}`
495
+ );
496
+ }
497
+ return readJson(response);
498
+ }
499
+
500
+ /**
501
+ * Throw the app server's refusal, or hand back whatever it answered with.
502
+ *
503
+ * The shape is deliberately `unknown`: the grant legs answer with an object
504
+ * and the comment list answers with an array, so narrowing belongs to the
505
+ * caller that knows which it asked for.
506
+ */
507
+ async function readJson(response: Response): Promise<unknown> {
508
+ const parsed = await response.json().catch(() => undefined);
464
509
 
465
510
  if (!response.ok) {
466
511
  // Clients key on `code`, never on prose. RFC 9457 says so about its own
467
- // `detail` member: consumers should not parse it for information.
512
+ // `detail` member: consumers should not parse it for information. A body
513
+ // that is not a problem document still has to produce a refusal, so an
514
+ // unusable one degrades to `unknown` rather than throwing here.
515
+ const problem =
516
+ typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
517
+ ? (parsed as Record<string, unknown>)
518
+ : {};
468
519
  throw new ServerRefusal(
469
- String(parsed['code'] ?? 'unknown'),
520
+ String(problem['code'] ?? 'unknown'),
470
521
  response.status,
471
- parsed
522
+ problem
472
523
  );
473
524
  }
474
525