relic-mcp 0.2.0 → 0.3.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 +59 -8
- package/dist/relic-mcp.js +444 -49
- package/mcp-servers.json +3 -3
- package/package.json +1 -1
- package/skills/relic/SKILL.md +43 -9
- package/src/http.ts +7 -3
- package/src/install.ts +13 -0
- package/src/installer.ts +7 -7
- package/src/publish.ts +139 -31
- package/src/republish.ts +182 -0
- package/src/server.ts +315 -8
- package/src/state.ts +185 -0
package/src/server.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
publish,
|
|
39
39
|
ServerRefusal,
|
|
40
40
|
} from './publish.ts';
|
|
41
|
+
import { republish } from './republish.ts';
|
|
41
42
|
|
|
42
43
|
export {
|
|
43
44
|
LEGACY_PROTOCOL_VERSIONS,
|
|
@@ -73,6 +74,26 @@ export const TOOL_NAME = 'relic_publish';
|
|
|
73
74
|
*/
|
|
74
75
|
export const DESCRIBE_TOOL_NAME = 'relic_describe_client';
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The republish tool: a new version of a relic this machine published.
|
|
79
|
+
*
|
|
80
|
+
* A separate tool rather than an argument on publish, because the two calls
|
|
81
|
+
* hold different secrets and different failure modes. Publish mints an id
|
|
82
|
+
* and a key; republish consumes ones recorded locally, and refusing when
|
|
83
|
+
* they are absent is the machine boundary made visible. Folding it into
|
|
84
|
+
* publish would turn "published from another machine" into a retry loop
|
|
85
|
+
* that can never succeed.
|
|
86
|
+
*/
|
|
87
|
+
export const REPUBLISH_TOOL_NAME = 'relic_republish';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The ceiling on a publisher-supplied lifetime, matching the grant
|
|
91
|
+
* contract's `maxTtlDays`. Refusing here keeps a typo like 36500 from
|
|
92
|
+
* encrypting the file and round-tripping a grant only to be turned down
|
|
93
|
+
* after the work is done.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_TTL_DAYS = 3650;
|
|
96
|
+
|
|
76
97
|
export const TOOL_DEFINITION = {
|
|
77
98
|
name: TOOL_NAME,
|
|
78
99
|
title: 'Publish a relic',
|
|
@@ -93,6 +114,15 @@ export const TOOL_DEFINITION = {
|
|
|
93
114
|
'Optional. Overrides the name written into the encrypted envelope ' +
|
|
94
115
|
'header. Defaults to the basename of `path`.',
|
|
95
116
|
},
|
|
117
|
+
ttl_days: {
|
|
118
|
+
type: 'integer',
|
|
119
|
+
minimum: 1,
|
|
120
|
+
maximum: MAX_TTL_DAYS,
|
|
121
|
+
description:
|
|
122
|
+
'Optional. Gives the relic a lifetime in days. Omit it and the ' +
|
|
123
|
+
'relic is kept until it is deleted. Shorter is better for ' +
|
|
124
|
+
'sensitive content.',
|
|
125
|
+
},
|
|
96
126
|
},
|
|
97
127
|
required: ['path'],
|
|
98
128
|
additionalProperties: false,
|
|
@@ -102,7 +132,12 @@ export const TOOL_DEFINITION = {
|
|
|
102
132
|
properties: {
|
|
103
133
|
url: { type: 'string' },
|
|
104
134
|
relic_id: { type: 'string' },
|
|
105
|
-
|
|
135
|
+
version: {
|
|
136
|
+
type: 'integer',
|
|
137
|
+
minimum: 1,
|
|
138
|
+
description: 'Always 1 from this tool; republish counts upward.',
|
|
139
|
+
},
|
|
140
|
+
relic_expires_at: { type: ['string', 'null'] },
|
|
106
141
|
renderer_class: { type: 'string' },
|
|
107
142
|
filename: { type: 'string' },
|
|
108
143
|
resolved_path: { type: 'string' },
|
|
@@ -112,6 +147,76 @@ export const TOOL_DEFINITION = {
|
|
|
112
147
|
required: [
|
|
113
148
|
'url',
|
|
114
149
|
'relic_id',
|
|
150
|
+
'version',
|
|
151
|
+
'relic_expires_at',
|
|
152
|
+
'renderer_class',
|
|
153
|
+
'filename',
|
|
154
|
+
'resolved_path',
|
|
155
|
+
'report_url',
|
|
156
|
+
'disclosure_url',
|
|
157
|
+
],
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
},
|
|
160
|
+
} as const;
|
|
161
|
+
|
|
162
|
+
export const REPUBLISH_TOOL_DEFINITION = {
|
|
163
|
+
name: REPUBLISH_TOOL_NAME,
|
|
164
|
+
title: 'Republish a relic',
|
|
165
|
+
description:
|
|
166
|
+
'Publish a new version of a relic this machine originally published, ' +
|
|
167
|
+
'encrypting under the same key so the existing share URL keeps working. ' +
|
|
168
|
+
"Only possible from the machine that holds the relic's key and publish " +
|
|
169
|
+
'token; a relic that was taken down can never be revived.',
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
relic_id: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
description: 'The 26-character relic id the original publish returned.',
|
|
176
|
+
},
|
|
177
|
+
path: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description:
|
|
180
|
+
'Filesystem path to the file that becomes the new version.',
|
|
181
|
+
},
|
|
182
|
+
filename: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description:
|
|
185
|
+
'Optional. Overrides the name written into the encrypted envelope ' +
|
|
186
|
+
'header of the new version. Defaults to the basename of `path`.',
|
|
187
|
+
},
|
|
188
|
+
ttl_days: {
|
|
189
|
+
type: 'integer',
|
|
190
|
+
minimum: 1,
|
|
191
|
+
maximum: MAX_TTL_DAYS,
|
|
192
|
+
description:
|
|
193
|
+
'Optional. A lifetime in days, forwarded on the republish ' +
|
|
194
|
+
"request. The service fixes a relic's lifetime at its first " +
|
|
195
|
+
'publish, so treat this as reserved.',
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
required: ['relic_id', 'path'],
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
},
|
|
201
|
+
outputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
relic_id: { type: 'string' },
|
|
205
|
+
version: {
|
|
206
|
+
type: 'integer',
|
|
207
|
+
minimum: 2,
|
|
208
|
+
description: 'The version just published.',
|
|
209
|
+
},
|
|
210
|
+
relic_expires_at: { type: ['string', 'null'] },
|
|
211
|
+
renderer_class: { type: 'string' },
|
|
212
|
+
filename: { type: 'string' },
|
|
213
|
+
resolved_path: { type: 'string' },
|
|
214
|
+
report_url: { type: 'string' },
|
|
215
|
+
disclosure_url: { type: 'string' },
|
|
216
|
+
},
|
|
217
|
+
required: [
|
|
218
|
+
'relic_id',
|
|
219
|
+
'version',
|
|
115
220
|
'relic_expires_at',
|
|
116
221
|
'renderer_class',
|
|
117
222
|
'filename',
|
|
@@ -137,10 +242,22 @@ export const DESCRIBE_TOOL_DEFINITION = {
|
|
|
137
242
|
},
|
|
138
243
|
} as const;
|
|
139
244
|
|
|
245
|
+
/**
|
|
246
|
+
* The build stamps the published version in, from package.json, which the
|
|
247
|
+
* release workflow rewrites before it builds. It used to be a literal, and it
|
|
248
|
+
* went stale immediately: the tarball published as 0.2.0 introduced itself to
|
|
249
|
+
* every client as 0.1.0.
|
|
250
|
+
*
|
|
251
|
+
* An unbuilt run says so rather than guessing at a release number, because a
|
|
252
|
+
* dev process claiming a version somebody could look up is worse than one
|
|
253
|
+
* admitting it has none.
|
|
254
|
+
*/
|
|
255
|
+
export const SERVER_VERSION = process.env.RELIC_MCP_VERSION ?? '0.0.0-dev';
|
|
256
|
+
|
|
140
257
|
export const SERVER_INFO = {
|
|
141
258
|
name: 'relic',
|
|
142
259
|
title: 'Relic',
|
|
143
|
-
version:
|
|
260
|
+
version: SERVER_VERSION,
|
|
144
261
|
} as const;
|
|
145
262
|
|
|
146
263
|
export const CAPABILITIES = { tools: {} } as const;
|
|
@@ -206,7 +323,13 @@ export async function handleMessage(
|
|
|
206
323
|
return {
|
|
207
324
|
jsonrpc: '2.0',
|
|
208
325
|
id,
|
|
209
|
-
result: {
|
|
326
|
+
result: {
|
|
327
|
+
tools: [
|
|
328
|
+
TOOL_DEFINITION,
|
|
329
|
+
REPUBLISH_TOOL_DEFINITION,
|
|
330
|
+
DESCRIBE_TOOL_DEFINITION,
|
|
331
|
+
],
|
|
332
|
+
},
|
|
210
333
|
};
|
|
211
334
|
|
|
212
335
|
case 'tools/call':
|
|
@@ -221,6 +344,26 @@ export async function handleMessage(
|
|
|
221
344
|
}
|
|
222
345
|
}
|
|
223
346
|
|
|
347
|
+
/**
|
|
348
|
+
* What a publisher of executable content needs to know, and nobody else can
|
|
349
|
+
* use.
|
|
350
|
+
*
|
|
351
|
+
* `html` and `jsx` are the two classes that render author-written code, and
|
|
352
|
+
* that code now runs in a frame served a policy with no remote source, so a
|
|
353
|
+
* page built against a CDN comes out bare. The recipient cannot fix that; the
|
|
354
|
+
* person publishing can, by inlining what the page needs.
|
|
355
|
+
*
|
|
356
|
+
* Every other class is inert markup or bytes, so the note would be noise.
|
|
357
|
+
*/
|
|
358
|
+
function isolationNote(rendererClass: string): string {
|
|
359
|
+
if (rendererClass !== 'html' && rendererClass !== 'jsx') return '';
|
|
360
|
+
return (
|
|
361
|
+
'It renders in an isolated frame with no network access, so external ' +
|
|
362
|
+
'images, fonts, scripts, and fetches will not load. Inline whatever the ' +
|
|
363
|
+
'page needs.\n'
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
224
367
|
async function callTool(
|
|
225
368
|
id: string | number | null,
|
|
226
369
|
params: Record<string, unknown>,
|
|
@@ -238,6 +381,9 @@ async function callTool(
|
|
|
238
381
|
key_transmitted_to_service: false,
|
|
239
382
|
plaintext_transmitted_to_service: false,
|
|
240
383
|
ciphertext_destination: 'object storage, via a signed URL',
|
|
384
|
+
local_publish_state:
|
|
385
|
+
'relic id, key, and publish token per relic, written 0600 under ' +
|
|
386
|
+
'the user config directory; never printed, never sent',
|
|
241
387
|
service_origin: deps.serviceOrigin,
|
|
242
388
|
},
|
|
243
389
|
isError: false,
|
|
@@ -245,6 +391,10 @@ async function callTool(
|
|
|
245
391
|
};
|
|
246
392
|
}
|
|
247
393
|
|
|
394
|
+
if (params['name'] === REPUBLISH_TOOL_NAME) {
|
|
395
|
+
return callRepublish(id, params, deps);
|
|
396
|
+
}
|
|
397
|
+
|
|
248
398
|
if (params['name'] !== TOOL_NAME) {
|
|
249
399
|
return errorResponse(
|
|
250
400
|
id,
|
|
@@ -266,8 +416,21 @@ async function callTool(
|
|
|
266
416
|
const filename =
|
|
267
417
|
typeof args['filename'] === 'string' ? args['filename'] : undefined;
|
|
268
418
|
|
|
419
|
+
const ttlDays = parseTtlDays(args['ttl_days']);
|
|
420
|
+
if (!ttlDays.ok) {
|
|
421
|
+
return errorResponse(
|
|
422
|
+
id,
|
|
423
|
+
ERROR_CODES.invalidParams,
|
|
424
|
+
`\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` +
|
|
425
|
+
'omitted to keep the relic until it is deleted'
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
269
429
|
try {
|
|
270
|
-
const result = await publish(
|
|
430
|
+
const result = await publish(
|
|
431
|
+
{ path, filename, ttl_days: ttlDays.days },
|
|
432
|
+
deps
|
|
433
|
+
);
|
|
271
434
|
return {
|
|
272
435
|
jsonrpc: '2.0',
|
|
273
436
|
id,
|
|
@@ -280,10 +443,22 @@ async function callTool(
|
|
|
280
443
|
{
|
|
281
444
|
type: 'text',
|
|
282
445
|
text:
|
|
283
|
-
`Published ${result.filename} as a relic.\n\n
|
|
284
|
-
|
|
446
|
+
`Published ${result.filename} as version 1 of a new relic.\n\n` +
|
|
447
|
+
`${result.url}\n\n` +
|
|
448
|
+
// No lifetime is the default, so the agent relaying this needs
|
|
449
|
+
// a sentence that says so, not a date-shaped hole.
|
|
450
|
+
(result.relic_expires_at === null
|
|
451
|
+
? 'It does not expire; it is kept until it is deleted. '
|
|
452
|
+
: `Expires ${result.relic_expires_at}. `) +
|
|
453
|
+
'Anyone with this link, ' +
|
|
285
454
|
'including its fragment, can read the file. The key is in the ' +
|
|
286
|
-
'fragment and it is now in this transcript
|
|
455
|
+
'fragment and it is now in this transcript. This machine can ' +
|
|
456
|
+
'republish it later; the link will not change.\n' +
|
|
457
|
+
// The publisher is the only party who can act on this, and the
|
|
458
|
+
// publish call is the only moment they are looking. The relic
|
|
459
|
+
// page used to carry it to the recipient, who cannot do
|
|
460
|
+
// anything about a font that will not load.
|
|
461
|
+
isolationNote(result.renderer_class) +
|
|
287
462
|
`What Relic knows: ${result.disclosure_url}`,
|
|
288
463
|
},
|
|
289
464
|
],
|
|
@@ -296,6 +471,122 @@ async function callTool(
|
|
|
296
471
|
}
|
|
297
472
|
}
|
|
298
473
|
|
|
474
|
+
async function callRepublish(
|
|
475
|
+
id: string | number | null,
|
|
476
|
+
params: Record<string, unknown>,
|
|
477
|
+
deps: PublishDeps
|
|
478
|
+
): Promise<JsonRpcResponse> {
|
|
479
|
+
const args = (params['arguments'] ?? {}) as Record<string, unknown>;
|
|
480
|
+
const relicId = args['relic_id'];
|
|
481
|
+
if (typeof relicId !== 'string' || relicId.length === 0) {
|
|
482
|
+
return errorResponse(
|
|
483
|
+
id,
|
|
484
|
+
ERROR_CODES.invalidParams,
|
|
485
|
+
'`relic_id` is required and must be a string'
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const path = args['path'];
|
|
490
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
491
|
+
return errorResponse(
|
|
492
|
+
id,
|
|
493
|
+
ERROR_CODES.invalidParams,
|
|
494
|
+
'`path` is required and must be a string'
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const filename =
|
|
499
|
+
typeof args['filename'] === 'string' ? args['filename'] : undefined;
|
|
500
|
+
|
|
501
|
+
const ttlDays = parseTtlDays(args['ttl_days']);
|
|
502
|
+
if (!ttlDays.ok) {
|
|
503
|
+
return errorResponse(
|
|
504
|
+
id,
|
|
505
|
+
ERROR_CODES.invalidParams,
|
|
506
|
+
`\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` +
|
|
507
|
+
'omitted to leave the lifetime as the first publish set it'
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const result = await republish(
|
|
513
|
+
{ relic_id: relicId, path, filename, ttl_days: ttlDays.days },
|
|
514
|
+
deps
|
|
515
|
+
);
|
|
516
|
+
return {
|
|
517
|
+
jsonrpc: '2.0',
|
|
518
|
+
id,
|
|
519
|
+
result: {
|
|
520
|
+
// No URL is printed here, on purpose. It has not changed, and
|
|
521
|
+
// reprinting it would reprint the key for no new reader; the first
|
|
522
|
+
// publish already made that disclosure once.
|
|
523
|
+
content: [
|
|
524
|
+
{
|
|
525
|
+
type: 'text',
|
|
526
|
+
text:
|
|
527
|
+
`Republished ${result.filename} as version ${result.version} ` +
|
|
528
|
+
`of relic ${result.relic_id}.\n\n` +
|
|
529
|
+
'The share URL is unchanged: everyone holding the existing ' +
|
|
530
|
+
'link, including its fragment, now sees this content. There ' +
|
|
531
|
+
'is no new link to hand out.\n\n' +
|
|
532
|
+
(result.relic_expires_at === null
|
|
533
|
+
? 'The relic does not expire; it is kept until it is deleted.'
|
|
534
|
+
: `Expires ${result.relic_expires_at}.`) +
|
|
535
|
+
'\n' +
|
|
536
|
+
`What Relic knows: ${result.disclosure_url}`,
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
structuredContent: result,
|
|
540
|
+
isError: false,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
} catch (error) {
|
|
544
|
+
return { jsonrpc: '2.0', id, result: toolError(error) };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A lifetime is opt-in: absent or null means no change from the default. A
|
|
550
|
+
* value that fails the contract is refused rather than dropped, because
|
|
551
|
+
* silently dropping it does the opposite of what was asked: a relic meant
|
|
552
|
+
* to die in days lives forever.
|
|
553
|
+
*/
|
|
554
|
+
function parseTtlDays(
|
|
555
|
+
raw: unknown
|
|
556
|
+
): { ok: true; days: number | undefined } | { ok: false } {
|
|
557
|
+
if (raw === undefined || raw === null) return { ok: true, days: undefined };
|
|
558
|
+
if (
|
|
559
|
+
typeof raw !== 'number' ||
|
|
560
|
+
!Number.isSafeInteger(raw) ||
|
|
561
|
+
raw < 1 ||
|
|
562
|
+
raw > MAX_TTL_DAYS
|
|
563
|
+
) {
|
|
564
|
+
return { ok: false };
|
|
565
|
+
}
|
|
566
|
+
return { ok: true, days: raw };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Republish refusals a publisher must understand on their own terms.
|
|
571
|
+
*
|
|
572
|
+
* The server's problem document carries the code; these sentences carry
|
|
573
|
+
* what the publisher can still do, because "403" and "410" answer nothing
|
|
574
|
+
* a human would ask. The two must stay distinct: one means this machine
|
|
575
|
+
* lost its standing, the other means nobody has any, ever again.
|
|
576
|
+
*/
|
|
577
|
+
const REFUSAL_GUIDANCE: Readonly<Record<string, string>> = {
|
|
578
|
+
invalid_publish_token:
|
|
579
|
+
'The publish token this machine holds for that relic was rejected. It ' +
|
|
580
|
+
'is issued once, at first publish, and never changes, so a rejection ' +
|
|
581
|
+
"means the local record no longer matches the service's. The relic can " +
|
|
582
|
+
'still be read at its existing link, but it cannot be republished from ' +
|
|
583
|
+
'here.',
|
|
584
|
+
relic_removed:
|
|
585
|
+
'That relic was taken down. A takedown is permanent: republishing ' +
|
|
586
|
+
'cannot revive it, whatever token is presented. Publish the content as ' +
|
|
587
|
+
'a new relic instead.',
|
|
588
|
+
};
|
|
589
|
+
|
|
299
590
|
/**
|
|
300
591
|
* A failed publish is a tool error, not a protocol error.
|
|
301
592
|
*
|
|
@@ -313,8 +604,16 @@ function toolError(error: unknown): Record<string, unknown> {
|
|
|
313
604
|
};
|
|
314
605
|
}
|
|
315
606
|
if (error instanceof ServerRefusal) {
|
|
607
|
+
const guidance = REFUSAL_GUIDANCE[error.code];
|
|
316
608
|
return {
|
|
317
|
-
content: [
|
|
609
|
+
content: [
|
|
610
|
+
{
|
|
611
|
+
type: 'text',
|
|
612
|
+
text:
|
|
613
|
+
`${error.code}: ${error.message}` +
|
|
614
|
+
(guidance === undefined ? '' : `\n${guidance}`),
|
|
615
|
+
},
|
|
616
|
+
],
|
|
318
617
|
structuredContent: { code: error.code, ...error.problem },
|
|
319
618
|
isError: true,
|
|
320
619
|
};
|
|
@@ -356,6 +655,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
|
|
|
356
655
|
what coarse class it was declared as, the publishing IP, and when it was
|
|
357
656
|
fetched. Never the contents, and never the key.
|
|
358
657
|
|
|
658
|
+
What this keeps on disk: for each relic you publish, its id, its key, and a
|
|
659
|
+
publish token, in a 0600 file under your user config directory. That record
|
|
660
|
+
is what lets a relic be republished later, and it is why republishing works
|
|
661
|
+
only on the machine that published. The token's SHA-256 is the only copy the
|
|
662
|
+
service ever holds, and neither secret is ever printed or logged. Deleting
|
|
663
|
+
the file changes nothing for existing links; it only ends this machine's
|
|
664
|
+
ability to update those relics.
|
|
665
|
+
|
|
359
666
|
What this does NOT protect against: the key is returned to your agent in the
|
|
360
667
|
URL, so it enters the model's context and your session transcript. That is
|
|
361
668
|
structural, not a defect. Anyone who can read this conversation can open the
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable publish state: what a later republish needs.
|
|
3
|
+
*
|
|
4
|
+
* A republish has to encrypt under the SAME key as version 1, or the share
|
|
5
|
+
* URL stops decrypting anything, and it has to present the publish token the
|
|
6
|
+
* first grant handed out, or the service refuses it. Neither exists anywhere
|
|
7
|
+
* but this machine: the service holds a hash of the token and nothing of the
|
|
8
|
+
* key. So losing this file means those relics can never be updated from
|
|
9
|
+
* here, which makes it state a human cannot repair by hand elsewhere. It is
|
|
10
|
+
* written before a publish reports success and read at the point of use,
|
|
11
|
+
* never cached in a process global.
|
|
12
|
+
*
|
|
13
|
+
* The file holds secrets. It is created 0600 inside a 0700 directory under
|
|
14
|
+
* the user's config directory, and nothing in this module ever puts the key
|
|
15
|
+
* or the token into a message, a log line, or an error.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** What this machine remembers about one published relic. */
|
|
23
|
+
export interface PublishState {
|
|
24
|
+
/** The version-1 content key, base64url, exactly as it rides the URL fragment. */
|
|
25
|
+
readonly key: string;
|
|
26
|
+
/**
|
|
27
|
+
* The bearer secret authorizing a republish. The service stores only its
|
|
28
|
+
* SHA-256, so this copy is the only usable one in existence.
|
|
29
|
+
*/
|
|
30
|
+
readonly publish_token: string;
|
|
31
|
+
/** How many versions this machine has published; 1 after the first. */
|
|
32
|
+
readonly version: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface StateFile {
|
|
36
|
+
readonly relics: Record<string, PublishState>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where the state lives.
|
|
41
|
+
*
|
|
42
|
+
* Behind one helper, redirected with `RELIC_PUBLISH_STATE`, so tests point it
|
|
43
|
+
* at a scratch path and a user with an unusual setup can move it. The default
|
|
44
|
+
* is the user's config directory rather than the repo or the cwd, because the
|
|
45
|
+
* publisher's identity here is the machine and user who ran the publish, not
|
|
46
|
+
* whatever directory the server happened to start in.
|
|
47
|
+
*/
|
|
48
|
+
export function publishStatePath(): string {
|
|
49
|
+
const override = process.env['RELIC_PUBLISH_STATE'];
|
|
50
|
+
if (override !== undefined && override.length > 0) return override;
|
|
51
|
+
|
|
52
|
+
const configRoot =
|
|
53
|
+
process.env['XDG_CONFIG_HOME'] !== undefined &&
|
|
54
|
+
process.env['XDG_CONFIG_HOME'].length > 0
|
|
55
|
+
? process.env['XDG_CONFIG_HOME']
|
|
56
|
+
: join(homedir(), '.config');
|
|
57
|
+
return join(configRoot, 'relic-mcp', 'publish-state.json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Writers queue behind one chain, so two publishes finishing close together
|
|
62
|
+
* cannot read-modify-write past each other and silently drop one relic's
|
|
63
|
+
* entry. Losing an entry to a race would orphan that relic with no error
|
|
64
|
+
* anywhere, which is the quietest possible way to lose republish rights.
|
|
65
|
+
*/
|
|
66
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
67
|
+
|
|
68
|
+
/** Load one relic's state, or undefined when this machine never published it. */
|
|
69
|
+
export async function loadPublishState(
|
|
70
|
+
relicId: string
|
|
71
|
+
): Promise<PublishState | undefined> {
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(await readFile(publishStatePath(), 'utf8'));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
77
|
+
if (code === 'ENOENT') return undefined;
|
|
78
|
+
if (error instanceof SyntaxError) {
|
|
79
|
+
// The message names the file and stops. Echoing any of the bytes would
|
|
80
|
+
// put secrets into an error path, and a truncated tail of JSON is where
|
|
81
|
+
// a key or token is most likely to sit in one piece.
|
|
82
|
+
throw new Error(
|
|
83
|
+
`publish state at ${publishStatePath()} is not valid JSON. It cannot ` +
|
|
84
|
+
'be read, so relics recorded in it cannot be republished from this ' +
|
|
85
|
+
'machine until it is fixed or removed.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
throw new Error(
|
|
89
|
+
`could not read publish state at ${publishStatePath()}: ${code}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entry = (parsed as StateFile | null)?.relics?.[relicId];
|
|
94
|
+
if (entry === undefined) return undefined;
|
|
95
|
+
if (
|
|
96
|
+
typeof entry !== 'object' ||
|
|
97
|
+
typeof entry.key !== 'string' ||
|
|
98
|
+
typeof entry.publish_token !== 'string' ||
|
|
99
|
+
typeof entry.version !== 'number' ||
|
|
100
|
+
!Number.isSafeInteger(entry.version) ||
|
|
101
|
+
entry.version < 1
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`publish state at ${publishStatePath()} holds a malformed entry for ` +
|
|
105
|
+
'this relic id.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Record or update one relic's state, preserving every other entry.
|
|
113
|
+
*
|
|
114
|
+
* Called only after the service has accepted the object, so the file never
|
|
115
|
+
* promises a republish the service does not know about.
|
|
116
|
+
*/
|
|
117
|
+
export async function savePublishState(
|
|
118
|
+
relicId: string,
|
|
119
|
+
state: PublishState
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const run = writeChain.then(() => writeEntry(relicId, state));
|
|
122
|
+
// A failed write must not poison the chain for later ones; it is observed
|
|
123
|
+
// here and rethrown for the caller, who has not yet reported success.
|
|
124
|
+
writeChain = run.catch(() => undefined);
|
|
125
|
+
await run;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeEntry(relicId: string, state: PublishState): Promise<void> {
|
|
129
|
+
const path = publishStatePath();
|
|
130
|
+
const dir = dirname(path);
|
|
131
|
+
|
|
132
|
+
// 0700 applies to the directories this creates, not to a config root that
|
|
133
|
+
// already exists: chmod-ing ~/.config itself would break other tools.
|
|
134
|
+
await mkdir(dir, { mode: 0o700, recursive: true });
|
|
135
|
+
|
|
136
|
+
const existing = await readWholeFile(path);
|
|
137
|
+
const relics = { ...existing?.relics, [relicId]: state };
|
|
138
|
+
|
|
139
|
+
// Written to a sibling then renamed over, so a crash mid-write leaves the
|
|
140
|
+
// previous intact file rather than half of the new one. A torn file would
|
|
141
|
+
// take every relic's republish rights with it, not just the one in flight.
|
|
142
|
+
const temp = `${path}.tmp`;
|
|
143
|
+
await writeFile(temp, `${JSON.stringify({ relics }, null, 2)}\n`, {
|
|
144
|
+
mode: 0o600,
|
|
145
|
+
});
|
|
146
|
+
await rename(temp, path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readWholeFile(path: string): Promise<StateFile | undefined> {
|
|
150
|
+
let raw: string;
|
|
151
|
+
try {
|
|
152
|
+
raw = await readFile(path, 'utf8');
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
155
|
+
if (code === 'ENOENT') return undefined;
|
|
156
|
+
throw new Error(`could not read publish state at ${path}: ${code}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(raw) as StateFile | null;
|
|
161
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
162
|
+
throw new Error('not an object');
|
|
163
|
+
}
|
|
164
|
+
return parsed;
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`publish state at ${path} is not valid JSON. It cannot be updated, so ` +
|
|
168
|
+
'this publish cannot be recorded and this relic cannot later be ' +
|
|
169
|
+
'republished from this machine until the file is fixed or removed.'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Exposed for tests asserting the on-disk permissions, nothing else. */
|
|
175
|
+
export async function publishStateModes(): Promise<{
|
|
176
|
+
file: number;
|
|
177
|
+
directory: number;
|
|
178
|
+
}> {
|
|
179
|
+
const path = publishStatePath();
|
|
180
|
+
const [file, directory] = await Promise.all([
|
|
181
|
+
stat(path),
|
|
182
|
+
stat(dirname(path)),
|
|
183
|
+
]);
|
|
184
|
+
return { file: file.mode & 0o777, directory: directory.mode & 0o777 };
|
|
185
|
+
}
|