relic-mcp 0.1.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/README.md +90 -0
- package/dist/relic-mcp.js +1088 -0
- package/package.json +47 -0
- package/src/files.ts +30 -0
- package/src/http.ts +192 -0
- package/src/index.ts +92 -0
- package/src/protocol.ts +148 -0
- package/src/publish.ts +370 -0
- package/src/server.ts +406 -0
package/src/publish.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish flow.
|
|
3
|
+
*
|
|
4
|
+
* This runs on the publisher's machine and it is the only place the plaintext
|
|
5
|
+
* and the key ever exist together. The app server is structurally not in
|
|
6
|
+
* either leg: the client PUTs ciphertext straight to storage under a signed
|
|
7
|
+
* grant.
|
|
8
|
+
*
|
|
9
|
+
* There is no server-returned script anywhere in here, and that is a locked
|
|
10
|
+
* frame constraint rather than a style preference. CVE-2025-6514 earned CVSS
|
|
11
|
+
* 9.6 for the accidental version of exactly the shape a returned script would
|
|
12
|
+
* make deliberate.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
deriveRendererClass,
|
|
17
|
+
encryptRelic,
|
|
18
|
+
generateKey,
|
|
19
|
+
generateRelicId,
|
|
20
|
+
type RendererClass,
|
|
21
|
+
relicUrl,
|
|
22
|
+
} from '@relic/format';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Codes for the legs the app server is not in.
|
|
26
|
+
*
|
|
27
|
+
* `spec/service.md` 1.1 owns app-server-originated failures. A purely local
|
|
28
|
+
* file error and a failure on the client-to-storage upload leg have no
|
|
29
|
+
* app-server status, so they get codes here, and these never collide with the
|
|
30
|
+
* server's.
|
|
31
|
+
*/
|
|
32
|
+
export type ClientCode =
|
|
33
|
+
| 'source_not_found'
|
|
34
|
+
| 'source_is_directory'
|
|
35
|
+
| 'source_not_regular_file'
|
|
36
|
+
| 'source_unreadable'
|
|
37
|
+
| 'local_size_precheck_failed'
|
|
38
|
+
| 'upload_failed'
|
|
39
|
+
| 'service_unreachable';
|
|
40
|
+
|
|
41
|
+
export class PublishError extends Error {
|
|
42
|
+
override readonly name = 'PublishError';
|
|
43
|
+
constructor(
|
|
44
|
+
readonly code: ClientCode,
|
|
45
|
+
message: string,
|
|
46
|
+
readonly details: Readonly<Record<string, unknown>> = {}
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A refusal the app server originated, carrying its problem document. */
|
|
53
|
+
export class ServerRefusal extends Error {
|
|
54
|
+
override readonly name = 'ServerRefusal';
|
|
55
|
+
constructor(
|
|
56
|
+
readonly code: string,
|
|
57
|
+
readonly status: number,
|
|
58
|
+
readonly problem: Readonly<Record<string, unknown>>
|
|
59
|
+
) {
|
|
60
|
+
super(String(problem['detail'] ?? code));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SourceFile {
|
|
65
|
+
readonly bytes: Uint8Array;
|
|
66
|
+
readonly basename: string;
|
|
67
|
+
readonly resolvedPath: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Filesystem access, injected so the flow is testable without a disk. */
|
|
71
|
+
export interface FileReader {
|
|
72
|
+
stat(
|
|
73
|
+
path: string
|
|
74
|
+
): Promise<
|
|
75
|
+
{ kind: 'file'; size: number } | { kind: 'directory' } | { kind: 'other' }
|
|
76
|
+
>;
|
|
77
|
+
read(path: string): Promise<Uint8Array>;
|
|
78
|
+
resolve(path: string): string;
|
|
79
|
+
basename(path: string): string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PublishInput {
|
|
83
|
+
readonly path: string;
|
|
84
|
+
readonly filename?: string | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PublishResult {
|
|
88
|
+
readonly url: string;
|
|
89
|
+
readonly relic_id: string;
|
|
90
|
+
readonly relic_expires_at: string;
|
|
91
|
+
readonly renderer_class: RendererClass;
|
|
92
|
+
readonly filename: string;
|
|
93
|
+
readonly resolved_path: string;
|
|
94
|
+
readonly report_url: string;
|
|
95
|
+
readonly disclosure_url: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PublishDeps {
|
|
99
|
+
readonly serviceOrigin: string;
|
|
100
|
+
readonly relicOrigin: string;
|
|
101
|
+
readonly files: FileReader;
|
|
102
|
+
readonly fetch: typeof globalThis.fetch;
|
|
103
|
+
readonly clientName: string;
|
|
104
|
+
/** Retries on a colliding ID, which format.md 1.4 obliges the client to do. */
|
|
105
|
+
readonly maxCollisionRetries?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function publish(
|
|
109
|
+
input: PublishInput,
|
|
110
|
+
deps: PublishDeps
|
|
111
|
+
): Promise<PublishResult> {
|
|
112
|
+
const source = await readSource(input.path, deps.files);
|
|
113
|
+
|
|
114
|
+
// 1. Challenge. This returns the cap before a grant is requested, so the
|
|
115
|
+
// precheck below uses a number that came from the server moments ago
|
|
116
|
+
// rather than a compiled-in constant that goes stale. Nothing is kept on
|
|
117
|
+
// disk between invocations, so there is no stale local policy.
|
|
118
|
+
const challenge = await postJson(
|
|
119
|
+
deps,
|
|
120
|
+
`${deps.serviceOrigin}/api/challenge`,
|
|
121
|
+
{}
|
|
122
|
+
);
|
|
123
|
+
const sizeLimit = Number(challenge['size_limit_bytes']);
|
|
124
|
+
const sizeBasis = String(challenge['size_basis']);
|
|
125
|
+
|
|
126
|
+
if (source.bytes.length > sizeLimit) {
|
|
127
|
+
throw new PublishError(
|
|
128
|
+
'local_size_precheck_failed',
|
|
129
|
+
`${source.basename} is ${source.bytes.length} bytes, over the ` +
|
|
130
|
+
`${sizeLimit}-byte cap`,
|
|
131
|
+
{
|
|
132
|
+
size_limit_bytes: sizeLimit,
|
|
133
|
+
declared_size_bytes: source.bytes.length,
|
|
134
|
+
size_basis: sizeBasis,
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const filename = input.filename ?? source.basename;
|
|
140
|
+
|
|
141
|
+
// The class is derived from bytes this process already holds, never taken as
|
|
142
|
+
// a tool input. Exposing it as a parameter would make the taxonomy
|
|
143
|
+
// model-attested, and the metric's second clause would then have an
|
|
144
|
+
// unreliable narrator reporting its only input.
|
|
145
|
+
const rendererClass = deriveRendererClass(source.bytes, filename);
|
|
146
|
+
|
|
147
|
+
const retries = deps.maxCollisionRetries ?? 3;
|
|
148
|
+
let lastCollision: ServerRefusal | undefined;
|
|
149
|
+
|
|
150
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
151
|
+
// 2. The client owns the ID and the key before anything leaves the
|
|
152
|
+
// machine, drawn independently from the platform CSPRNG. Neither
|
|
153
|
+
// derives from the other: deriving the key from the ID would put the
|
|
154
|
+
// key in the operator's hands, since the operator has every ID.
|
|
155
|
+
const relicId = generateRelicId();
|
|
156
|
+
const key = generateKey();
|
|
157
|
+
|
|
158
|
+
// Encrypt before requesting the grant, so the grant can pin the object's
|
|
159
|
+
// exact byte length. A fresh salt is drawn per attempt, so this has to sit
|
|
160
|
+
// inside the retry loop alongside the id and the key.
|
|
161
|
+
const container = await encryptRelic({
|
|
162
|
+
content: source.bytes,
|
|
163
|
+
filename,
|
|
164
|
+
mimetype: guessMimetype(filename, rendererClass),
|
|
165
|
+
key,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
let grant: Record<string, unknown>;
|
|
169
|
+
try {
|
|
170
|
+
grant = await postJson(deps, `${deps.serviceOrigin}/api/grant`, {
|
|
171
|
+
challenge_nonce: challenge['challenge_nonce'],
|
|
172
|
+
relic_id: relicId,
|
|
173
|
+
renderer_class: rendererClass,
|
|
174
|
+
publishing_client: deps.clientName,
|
|
175
|
+
declared_size_bytes: source.bytes.length,
|
|
176
|
+
declared_ciphertext_bytes: container.length,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (
|
|
180
|
+
error instanceof ServerRefusal &&
|
|
181
|
+
error.code === 'relic_id_collision'
|
|
182
|
+
) {
|
|
183
|
+
// Astronomical bad luck or a broken RNG. Both should fail loudly if
|
|
184
|
+
// they persist, which is why the retry count is small.
|
|
185
|
+
lastCollision = error;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. Straight to storage. The ciphertext never transits the app server.
|
|
192
|
+
const upload = await deps.fetch(String(grant['upload_url']), {
|
|
193
|
+
method: 'PUT',
|
|
194
|
+
// The grant signed this exact length, so it is sent explicitly rather
|
|
195
|
+
// than left to whatever the runtime infers from the body.
|
|
196
|
+
headers: { 'content-length': String(container.length) },
|
|
197
|
+
body: container as unknown as BodyInit,
|
|
198
|
+
});
|
|
199
|
+
if (!upload.ok) {
|
|
200
|
+
throw new PublishError(
|
|
201
|
+
'upload_failed',
|
|
202
|
+
`upload returned ${upload.status}`,
|
|
203
|
+
{ relic_id: relicId, status: upload.status }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. Report completion, so the server has a true publish timestamp. A lost
|
|
208
|
+
// confirmation is survivable by design: the client already holds the ID
|
|
209
|
+
// and the key, so it can still produce a shareable URL.
|
|
210
|
+
try {
|
|
211
|
+
await postJson(
|
|
212
|
+
deps,
|
|
213
|
+
`${deps.serviceOrigin}/api/relics/${relicId}/complete`,
|
|
214
|
+
{}
|
|
215
|
+
);
|
|
216
|
+
} catch {
|
|
217
|
+
// Deliberately swallowed. The relic is uploaded and the URL is valid.
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
url: relicUrl(deps.relicOrigin, relicId, key),
|
|
222
|
+
relic_id: relicId,
|
|
223
|
+
relic_expires_at: String(grant['relic_expires_at']),
|
|
224
|
+
renderer_class: rendererClass,
|
|
225
|
+
filename,
|
|
226
|
+
resolved_path: source.resolvedPath,
|
|
227
|
+
report_url: String(grant['report_url']),
|
|
228
|
+
disclosure_url: String(grant['disclosure_url']),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
throw (
|
|
233
|
+
lastCollision ??
|
|
234
|
+
new PublishError('service_unreachable', 'could not obtain a grant')
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readSource(
|
|
239
|
+
path: string,
|
|
240
|
+
files: FileReader
|
|
241
|
+
): Promise<SourceFile> {
|
|
242
|
+
const resolvedPath = files.resolve(path);
|
|
243
|
+
const stat = await files.stat(resolvedPath);
|
|
244
|
+
|
|
245
|
+
if (stat.kind === 'directory') {
|
|
246
|
+
// format.md 3.1 fixes entry count at exactly 1 in version 1, so a
|
|
247
|
+
// directory cannot be represented as a multi-entry relic at all.
|
|
248
|
+
// Publishing one would mean silently tarring it into an opaque blob that
|
|
249
|
+
// takes class `archive` and is download-only, producing an unrenderable
|
|
250
|
+
// relic and no error.
|
|
251
|
+
throw new PublishError(
|
|
252
|
+
'source_is_directory',
|
|
253
|
+
`${resolvedPath} is a directory. Create an archive yourself and ` +
|
|
254
|
+
'publish that, so the download-only outcome is yours to choose.'
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (stat.kind === 'other') {
|
|
259
|
+
// A FIFO, socket, or device node is readable and has no stable size,
|
|
260
|
+
// which breaks the declared-size contract before it starts.
|
|
261
|
+
throw new PublishError(
|
|
262
|
+
'source_not_regular_file',
|
|
263
|
+
`${resolvedPath} is not a regular file`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let bytes: Uint8Array;
|
|
268
|
+
try {
|
|
269
|
+
bytes = await files.read(resolvedPath);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
throw new PublishError(
|
|
272
|
+
'source_unreadable',
|
|
273
|
+
`could not read ${resolvedPath}: ${(error as Error).message}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { bytes, basename: files.basename(resolvedPath), resolvedPath };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function postJson(
|
|
281
|
+
deps: PublishDeps,
|
|
282
|
+
url: string,
|
|
283
|
+
body: unknown
|
|
284
|
+
): Promise<Record<string, unknown>> {
|
|
285
|
+
let response: Response;
|
|
286
|
+
try {
|
|
287
|
+
response = await deps.fetch(url, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'content-type': 'application/json' },
|
|
290
|
+
body: JSON.stringify(body),
|
|
291
|
+
});
|
|
292
|
+
} catch (error) {
|
|
293
|
+
throw new PublishError(
|
|
294
|
+
'service_unreachable',
|
|
295
|
+
`could not reach ${url}: ${(error as Error).message}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const parsed = (await response.json().catch(() => ({}))) as Record<
|
|
300
|
+
string,
|
|
301
|
+
unknown
|
|
302
|
+
>;
|
|
303
|
+
|
|
304
|
+
if (!response.ok) {
|
|
305
|
+
// Clients key on `code`, never on prose. RFC 9457 says so about its own
|
|
306
|
+
// `detail` member: consumers should not parse it for information.
|
|
307
|
+
throw new ServerRefusal(
|
|
308
|
+
String(parsed['code'] ?? 'unknown'),
|
|
309
|
+
response.status,
|
|
310
|
+
parsed
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return parsed;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const MIMETYPES: Readonly<Record<string, string>> = {
|
|
318
|
+
md: 'text/markdown',
|
|
319
|
+
markdown: 'text/markdown',
|
|
320
|
+
html: 'text/html',
|
|
321
|
+
htm: 'text/html',
|
|
322
|
+
txt: 'text/plain',
|
|
323
|
+
json: 'application/json',
|
|
324
|
+
csv: 'text/csv',
|
|
325
|
+
svg: 'image/svg+xml',
|
|
326
|
+
png: 'image/png',
|
|
327
|
+
jpg: 'image/jpeg',
|
|
328
|
+
jpeg: 'image/jpeg',
|
|
329
|
+
gif: 'image/gif',
|
|
330
|
+
webp: 'image/webp',
|
|
331
|
+
avif: 'image/avif',
|
|
332
|
+
mp4: 'video/mp4',
|
|
333
|
+
webm: 'video/webm',
|
|
334
|
+
mp3: 'audio/mpeg',
|
|
335
|
+
wav: 'audio/wav',
|
|
336
|
+
ogg: 'audio/ogg',
|
|
337
|
+
zip: 'application/zip',
|
|
338
|
+
gz: 'application/gzip',
|
|
339
|
+
pdf: 'application/pdf',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const CLASS_FALLBACK: Readonly<Record<RendererClass, string>> = {
|
|
343
|
+
markdown: 'text/markdown',
|
|
344
|
+
code: 'text/plain',
|
|
345
|
+
html: 'text/html',
|
|
346
|
+
image: 'application/octet-stream',
|
|
347
|
+
media: 'application/octet-stream',
|
|
348
|
+
archive: 'application/octet-stream',
|
|
349
|
+
binary: 'application/octet-stream',
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The declared mimetype for the envelope header.
|
|
354
|
+
*
|
|
355
|
+
* It is untrusted display text on the far side, and the viewer treats a
|
|
356
|
+
* disagreement between it and the sniffed type by routing to the least
|
|
357
|
+
* privileged path either would allow.
|
|
358
|
+
*/
|
|
359
|
+
export function guessMimetype(
|
|
360
|
+
filename: string,
|
|
361
|
+
rendererClass: RendererClass
|
|
362
|
+
): string {
|
|
363
|
+
const base = filename.slice(filename.lastIndexOf('/') + 1).toLowerCase();
|
|
364
|
+
const dot = base.lastIndexOf('.');
|
|
365
|
+
if (dot > 0 && dot < base.length - 1) {
|
|
366
|
+
const found = MIMETYPES[base.slice(dot + 1)];
|
|
367
|
+
if (found !== undefined) return found;
|
|
368
|
+
}
|
|
369
|
+
return CLASS_FALLBACK[rendererClass];
|
|
370
|
+
}
|