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/mcp-servers.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"relic": {
|
|
3
3
|
"type": "stdio",
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["-y", "relic-mcp@0.
|
|
5
|
+
"args": ["-y", "relic-mcp@0.2.0"],
|
|
6
6
|
"env": {
|
|
7
|
-
"RELIC_SERVICE_ORIGIN": "https://
|
|
8
|
-
"RELIC_CLIENT_NAME": "relic-plugin/0.
|
|
7
|
+
"RELIC_SERVICE_ORIGIN": "https://relik.link",
|
|
8
|
+
"RELIC_CLIENT_NAME": "relic-plugin/0.2.0"
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
}
|
package/package.json
CHANGED
package/skills/relic/SKILL.md
CHANGED
|
@@ -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 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, what the recipient sees, how long a link lives, and what the service can and cannot read.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Relic
|
|
@@ -27,8 +27,41 @@ context and pass its text; pass where it lives.
|
|
|
27
27
|
Optional arguments worth knowing:
|
|
28
28
|
|
|
29
29
|
- `filename` overrides the display name shown to the recipient.
|
|
30
|
-
- `ttl_days`
|
|
31
|
-
|
|
30
|
+
- `ttl_days` gives the link a lifetime in days, 1 to 3650. A relic is kept
|
|
31
|
+
until it is deleted unless you set one. Shorter is better for anything
|
|
32
|
+
sensitive: when the content should stop being available, say when.
|
|
33
|
+
|
|
34
|
+
The result reports the relic as version 1 and its id. Keep the id if a later
|
|
35
|
+
version of the same content will replace this one; that is what
|
|
36
|
+
`relic_republish` is for.
|
|
37
|
+
|
|
38
|
+
## Republishing
|
|
39
|
+
|
|
40
|
+
Call `relic_republish` with the relic id and a new file:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
relic_republish(relic_id: "0a2c...", path: "/Users/me/Downloads/report-v2.html")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The new file becomes version 2, then 3, and so on, encrypted under the same
|
|
47
|
+
key as version 1. **The share URL does not change**: everyone holding the
|
|
48
|
+
existing link now sees the new content, and there is no new link to hand out.
|
|
49
|
+
`relic_id` is the 26-character id the original publish returned, not the URL.
|
|
50
|
+
Optional `filename` overrides the display name in the new version.
|
|
51
|
+
|
|
52
|
+
Two things to know before promising an update:
|
|
53
|
+
|
|
54
|
+
- **Republishing works only on the machine that published.** The key and the
|
|
55
|
+
publish token are recorded locally when the first publish happens, in a
|
|
56
|
+
0600 file under the user's config directory. On any other machine the tool
|
|
57
|
+
refuses: the relic was published from another machine and cannot be
|
|
58
|
+
republished here. Neither secret is ever printed or logged.
|
|
59
|
+
- **A takedown is terminal.** If the relic was removed, republishing cannot
|
|
60
|
+
revive it, ever, whatever token is presented. The tool says so plainly;
|
|
61
|
+
publish the content as a new relic instead.
|
|
62
|
+
|
|
63
|
+
A relic's lifetime is set at its first publish and carries across versions
|
|
64
|
+
unchanged.
|
|
32
65
|
|
|
33
66
|
## Say this when you hand over the link
|
|
34
67
|
|
|
@@ -44,7 +77,8 @@ all, it should not go through an agent.
|
|
|
44
77
|
|
|
45
78
|
Also worth one line, unprompted, the first time in a session:
|
|
46
79
|
|
|
47
|
-
-
|
|
80
|
+
- a relic is kept until it is deleted, unless it was published with a
|
|
81
|
+
`ttl_days` lifetime, in which case the tool returns the exact date
|
|
48
82
|
- opens are capped, and the tool's mint response reports how many remain
|
|
49
83
|
- anyone with the link can read it; there are no per-recipient permissions
|
|
50
84
|
|
|
@@ -61,11 +95,11 @@ fails: chat clients and ticket systems sometimes cut fragments.
|
|
|
61
95
|
|
|
62
96
|
## When not to use it
|
|
63
97
|
|
|
64
|
-
- **Something that belongs in the repo.** Commit it. A relic
|
|
65
|
-
|
|
66
|
-
- **A client deliverable.** Those have a durable home, and a link
|
|
67
|
-
|
|
68
|
-
now, never instead.
|
|
98
|
+
- **Something that belongs in the repo.** Commit it. A relic is a link you
|
|
99
|
+
hand someone, not a place work lives; the repo is.
|
|
100
|
+
- **A client deliverable.** Those have a durable home, and a share link is
|
|
101
|
+
not it, even one with no expiry. Publish a relic in addition if someone
|
|
102
|
+
needs to look at it now, never instead.
|
|
69
103
|
- **Credentials, keys, or tokens.** Encrypted in transit and at rest still ends
|
|
70
104
|
with a secret sitting in a URL in a transcript.
|
|
71
105
|
|
package/src/http.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Streamable HTTP transport, revision `2026-07-28`.
|
|
3
3
|
*
|
|
4
4
|
* Stateless by construction: a single POST endpoint, one HTTP request per
|
|
5
|
-
* JSON-RPC message, no session, and no `Mcp-Session-Id`.
|
|
6
|
-
* between calls, so a load balancer can round-robin across
|
|
7
|
-
* sticky routing and no shared session store.
|
|
5
|
+
* JSON-RPC message, no session, and no `Mcp-Session-Id`. No protocol state
|
|
6
|
+
* is retained between calls, so a load balancer can round-robin across
|
|
7
|
+
* processes with no sticky routing and no shared session store. The one
|
|
8
|
+
* thing that does outlive a call is the publish state in state.ts, and it
|
|
9
|
+
* is per-machine disk, not transport state: processes sharing a machine
|
|
10
|
+
* share it, and machines that do not share it cannot republish each
|
|
11
|
+
* other's relics.
|
|
8
12
|
*
|
|
9
13
|
* **This does not make Relic hostable.** The transport says how bytes reach
|
|
10
14
|
* the server, not where the server runs. The publishing client must sit next
|
package/src/install.ts
CHANGED
|
@@ -82,6 +82,19 @@ export interface ServerSpec {
|
|
|
82
82
|
readonly env: Readonly<Record<string, string>>;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* What `relic-mcp install` writes when the caller supplies no command of
|
|
87
|
+
* their own. The spec carries a version: `npx -y relic-mcp` bare resolves to
|
|
88
|
+
* "command not found" on current npx, so a spec without an `@` reproduces a
|
|
89
|
+
* broken install on every machine that trusts this default.
|
|
90
|
+
*/
|
|
91
|
+
export const DEFAULT_SERVER_SPEC: ServerSpec = {
|
|
92
|
+
name: 'relic',
|
|
93
|
+
command: 'npx',
|
|
94
|
+
args: ['-y', 'relic-mcp@latest'],
|
|
95
|
+
env: {},
|
|
96
|
+
};
|
|
97
|
+
|
|
85
98
|
export interface Plan {
|
|
86
99
|
readonly harness: Harness;
|
|
87
100
|
/** The file to write, absent for harnesses driven by their own CLI. */
|
package/src/installer.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { homedir } from 'node:os';
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import {
|
|
16
|
+
DEFAULT_SERVER_SPEC,
|
|
16
17
|
ExistingEntryError,
|
|
17
18
|
HARNESSES,
|
|
18
19
|
planFor,
|
|
@@ -99,12 +100,11 @@ export async function runInstall(argv: readonly string[]): Promise<void> {
|
|
|
99
100
|
process.exit(2);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
};
|
|
103
|
+
// The command comes from the shared default so the versioned npx spec
|
|
104
|
+
// lives in exactly one place; the name stays the caller's, and the origin
|
|
105
|
+
// is resolved only on paths that write or print, so a bare `install` can
|
|
106
|
+
// still just list what it detected.
|
|
107
|
+
const spec: ServerSpec = { ...DEFAULT_SERVER_SPEC, name: options.name };
|
|
108
108
|
|
|
109
109
|
if (options.print && options.client === undefined) {
|
|
110
110
|
process.stdout.write(snippetFor(withOrigin(spec, options)));
|
|
@@ -230,7 +230,7 @@ function withOrigin(spec: ServerSpec, options: Options): ServerSpec {
|
|
|
230
230
|
origin = requiredOrigin('--origin', raw);
|
|
231
231
|
} catch (error) {
|
|
232
232
|
process.stderr.write(
|
|
233
|
-
`${(error as Error).message}\n\nPass --origin https://
|
|
233
|
+
`${(error as Error).message}\n\nPass --origin https://relik.link\n`
|
|
234
234
|
);
|
|
235
235
|
process.exit(2);
|
|
236
236
|
}
|
package/src/publish.ts
CHANGED
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
deriveRendererClass,
|
|
17
|
+
encodeKey,
|
|
17
18
|
encryptRelic,
|
|
18
19
|
generateKey,
|
|
19
20
|
generateRelicId,
|
|
20
21
|
type RendererClass,
|
|
21
22
|
relicUrl,
|
|
22
23
|
} from '@relic/format';
|
|
24
|
+
import { savePublishState } from './state.ts';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Codes for the legs the app server is not in.
|
|
@@ -27,7 +29,8 @@ import {
|
|
|
27
29
|
* `spec/service.md` 1.1 owns app-server-originated failures. A purely local
|
|
28
30
|
* file error and a failure on the client-to-storage upload leg have no
|
|
29
31
|
* app-server status, so they get codes here, and these never collide with the
|
|
30
|
-
* server's.
|
|
32
|
+
* server's. The state codes join them for the same reason: the publish state
|
|
33
|
+
* is this machine's alone, so its failures are this machine's to report.
|
|
31
34
|
*/
|
|
32
35
|
export type ClientCode =
|
|
33
36
|
| 'source_not_found'
|
|
@@ -36,7 +39,11 @@ export type ClientCode =
|
|
|
36
39
|
| 'source_unreadable'
|
|
37
40
|
| 'local_size_precheck_failed'
|
|
38
41
|
| 'upload_failed'
|
|
39
|
-
| 'service_unreachable'
|
|
42
|
+
| 'service_unreachable'
|
|
43
|
+
| 'grant_missing_publish_token'
|
|
44
|
+
| 'no_local_publish_state'
|
|
45
|
+
| 'local_state_unreadable'
|
|
46
|
+
| 'local_state_write_failed';
|
|
40
47
|
|
|
41
48
|
export class PublishError extends Error {
|
|
42
49
|
override readonly name = 'PublishError';
|
|
@@ -82,12 +89,25 @@ export interface FileReader {
|
|
|
82
89
|
export interface PublishInput {
|
|
83
90
|
readonly path: string;
|
|
84
91
|
readonly filename?: string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* A publisher-chosen lifetime in days. Undefined means the relic is kept
|
|
94
|
+
* until someone deletes it. The field is omitted from the grant entirely
|
|
95
|
+
* when unset, so the server's default decides, never a zero that happens
|
|
96
|
+
* to survive the wire.
|
|
97
|
+
*/
|
|
98
|
+
readonly ttl_days?: number | undefined;
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
export interface PublishResult {
|
|
88
102
|
readonly url: string;
|
|
89
103
|
readonly relic_id: string;
|
|
90
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Always 1 here: a fresh id is a fresh relic, and the client knows it
|
|
106
|
+
* without asking. Republish counts upward from the local state file.
|
|
107
|
+
*/
|
|
108
|
+
readonly version: number;
|
|
109
|
+
/** Null when the relic has no lifetime, which is now the default. */
|
|
110
|
+
readonly relic_expires_at: string | null;
|
|
91
111
|
readonly renderer_class: RendererClass;
|
|
92
112
|
readonly filename: string;
|
|
93
113
|
readonly resolved_path: string;
|
|
@@ -113,8 +133,9 @@ export async function publish(
|
|
|
113
133
|
|
|
114
134
|
// 1. Challenge. This returns the cap before a grant is requested, so the
|
|
115
135
|
// precheck below uses a number that came from the server moments ago
|
|
116
|
-
// rather than a compiled-in constant that goes stale.
|
|
117
|
-
//
|
|
136
|
+
// rather than a compiled-in constant that goes stale. Policy is never
|
|
137
|
+
// cached locally; the one thing publish now keeps on disk is state, in
|
|
138
|
+
// state.ts, and it is secrets, not policy.
|
|
118
139
|
const challenge = await postJson(
|
|
119
140
|
deps,
|
|
120
141
|
`${deps.serviceOrigin}/api/challenge`,
|
|
@@ -174,6 +195,10 @@ export async function publish(
|
|
|
174
195
|
publishing_client: deps.clientName,
|
|
175
196
|
declared_size_bytes: source.bytes.length,
|
|
176
197
|
declared_ciphertext_bytes: container.length,
|
|
198
|
+
// Omitted rather than sent as anything, so an unset lifetime is the
|
|
199
|
+
// server's default (kept until deleted) and never a value this
|
|
200
|
+
// client guessed on the publisher's behalf.
|
|
201
|
+
...(input.ttl_days === undefined ? {} : { ttl_days: input.ttl_days }),
|
|
177
202
|
});
|
|
178
203
|
} catch (error) {
|
|
179
204
|
if (
|
|
@@ -187,40 +212,62 @@ export async function publish(
|
|
|
187
212
|
}
|
|
188
213
|
throw error;
|
|
189
214
|
}
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
headers: { 'content-length': String(container.length) },
|
|
197
|
-
body: container as unknown as BodyInit,
|
|
198
|
-
});
|
|
199
|
-
if (!upload.ok) {
|
|
215
|
+
// The grant hands the publish token over exactly once, here. Without it
|
|
216
|
+
// this machine can never issue another version of the relic, so a grant
|
|
217
|
+
// missing it is refused before any bytes move, rather than after the
|
|
218
|
+
// object exists and the omission is somebody's support ticket.
|
|
219
|
+
const publishToken = grant['publish_token'];
|
|
220
|
+
if (typeof publishToken !== 'string' || publishToken.length === 0) {
|
|
200
221
|
throw new PublishError(
|
|
201
|
-
'
|
|
202
|
-
|
|
203
|
-
|
|
222
|
+
'grant_missing_publish_token',
|
|
223
|
+
'the grant carried no publish_token, so the relic would never be ' +
|
|
224
|
+
'republishable from this machine',
|
|
225
|
+
{ relic_id: relicId }
|
|
204
226
|
);
|
|
205
227
|
}
|
|
206
228
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
229
|
+
// 3. Straight to storage, then report completion. Both legs are shared
|
|
230
|
+
// with republish, because a version-2 object earns the same handling
|
|
231
|
+
// a version-1 one does.
|
|
232
|
+
await putContainer(deps, relicId, String(grant['upload_url']), container);
|
|
233
|
+
await reportComplete(deps, relicId);
|
|
234
|
+
|
|
235
|
+
const url = relicUrl(deps.relicOrigin, relicId, key);
|
|
236
|
+
|
|
237
|
+
// 4. Record what a republish needs, before success is reported. State a
|
|
238
|
+
// human cannot rebuild by hand only exists once it is on disk; a
|
|
239
|
+
// publish that returns without this step is a link that can never be
|
|
240
|
+
// updated from here, silently.
|
|
210
241
|
try {
|
|
211
|
-
await
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
242
|
+
await savePublishState(relicId, {
|
|
243
|
+
key: encodeKey(key),
|
|
244
|
+
publish_token: publishToken,
|
|
245
|
+
version: 1,
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// The relic itself is live and the URL works, so both are handed over
|
|
249
|
+
// in the failure rather than lost to the error path. What failed is
|
|
250
|
+
// only the local record, but that failure is permanent for this
|
|
251
|
+
// machine, so it is reported as a failure.
|
|
252
|
+
throw new PublishError(
|
|
253
|
+
'local_state_write_failed',
|
|
254
|
+
'the relic is published and the link works, but recording it for ' +
|
|
255
|
+
`later republishing failed, so it cannot be republished from this ` +
|
|
256
|
+
`machine: ${(error as Error).message}`,
|
|
257
|
+
{ relic_id: relicId, url }
|
|
215
258
|
);
|
|
216
|
-
} catch {
|
|
217
|
-
// Deliberately swallowed. The relic is uploaded and the URL is valid.
|
|
218
259
|
}
|
|
219
260
|
|
|
220
261
|
return {
|
|
221
|
-
url
|
|
262
|
+
url,
|
|
222
263
|
relic_id: relicId,
|
|
223
|
-
|
|
264
|
+
version: 1,
|
|
265
|
+
// Null is a real state now, the default one; String(null) would hand
|
|
266
|
+
// the caller the four characters "null" where a date used to be.
|
|
267
|
+
relic_expires_at:
|
|
268
|
+
grant['relic_expires_at'] == null
|
|
269
|
+
? null
|
|
270
|
+
: String(grant['relic_expires_at']),
|
|
224
271
|
renderer_class: rendererClass,
|
|
225
272
|
filename,
|
|
226
273
|
resolved_path: source.resolvedPath,
|
|
@@ -235,7 +282,7 @@ export async function publish(
|
|
|
235
282
|
);
|
|
236
283
|
}
|
|
237
284
|
|
|
238
|
-
async function readSource(
|
|
285
|
+
export async function readSource(
|
|
239
286
|
path: string,
|
|
240
287
|
files: FileReader
|
|
241
288
|
): Promise<SourceFile> {
|
|
@@ -277,7 +324,7 @@ async function readSource(
|
|
|
277
324
|
return { bytes, basename: files.basename(resolvedPath), resolvedPath };
|
|
278
325
|
}
|
|
279
326
|
|
|
280
|
-
async function postJson(
|
|
327
|
+
export async function postJson(
|
|
281
328
|
deps: PublishDeps,
|
|
282
329
|
url: string,
|
|
283
330
|
body: unknown
|
|
@@ -314,6 +361,60 @@ async function postJson(
|
|
|
314
361
|
return parsed;
|
|
315
362
|
}
|
|
316
363
|
|
|
364
|
+
/**
|
|
365
|
+
* PUT the ciphertext to storage under a signed URL.
|
|
366
|
+
*
|
|
367
|
+
* Shared by publish and republish because the leg is identical and its
|
|
368
|
+
* details are load-bearing: the grant signed this exact length, so it is
|
|
369
|
+
* sent explicitly rather than left to whatever the runtime infers from the
|
|
370
|
+
* body.
|
|
371
|
+
*/
|
|
372
|
+
export async function putContainer(
|
|
373
|
+
deps: PublishDeps,
|
|
374
|
+
relicId: string,
|
|
375
|
+
uploadUrl: string,
|
|
376
|
+
container: Uint8Array
|
|
377
|
+
): Promise<void> {
|
|
378
|
+
const upload = await deps.fetch(uploadUrl, {
|
|
379
|
+
method: 'PUT',
|
|
380
|
+
headers: { 'content-length': String(container.length) },
|
|
381
|
+
body: container as unknown as BodyInit,
|
|
382
|
+
});
|
|
383
|
+
if (!upload.ok) {
|
|
384
|
+
throw new PublishError(
|
|
385
|
+
'upload_failed',
|
|
386
|
+
`upload returned ${upload.status}`,
|
|
387
|
+
{
|
|
388
|
+
relic_id: relicId,
|
|
389
|
+
status: upload.status,
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Report a finished upload, so the server has a true publish timestamp.
|
|
397
|
+
*
|
|
398
|
+
* An optimization, never a requirement: the confirmation is the message
|
|
399
|
+
* that gets lost, which is why the client owns the ID and the key before
|
|
400
|
+
* anything leaves the machine. The first mint anchors the timestamp if
|
|
401
|
+
* this call disappears.
|
|
402
|
+
*/
|
|
403
|
+
export async function reportComplete(
|
|
404
|
+
deps: PublishDeps,
|
|
405
|
+
relicId: string
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
try {
|
|
408
|
+
await postJson(
|
|
409
|
+
deps,
|
|
410
|
+
`${deps.serviceOrigin}/api/relics/${relicId}/complete`,
|
|
411
|
+
{}
|
|
412
|
+
);
|
|
413
|
+
} catch {
|
|
414
|
+
// Deliberately swallowed. The object is uploaded and the link is valid.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
317
418
|
const MIMETYPES: Readonly<Record<string, string>> = {
|
|
318
419
|
md: 'text/markdown',
|
|
319
420
|
markdown: 'text/markdown',
|
|
@@ -337,12 +438,19 @@ const MIMETYPES: Readonly<Record<string, string>> = {
|
|
|
337
438
|
zip: 'application/zip',
|
|
338
439
|
gz: 'application/gzip',
|
|
339
440
|
pdf: 'application/pdf',
|
|
441
|
+
// Component source declares what it is. text/plain or text/javascript
|
|
442
|
+
// would both read as `code` on the far side and the relic would render
|
|
443
|
+
// as escaped source, silently, which is the one outcome a component
|
|
444
|
+
// author would never guess had happened.
|
|
445
|
+
jsx: 'text/jsx',
|
|
446
|
+
tsx: 'text/tsx',
|
|
340
447
|
};
|
|
341
448
|
|
|
342
449
|
const CLASS_FALLBACK: Readonly<Record<RendererClass, string>> = {
|
|
343
450
|
markdown: 'text/markdown',
|
|
344
451
|
code: 'text/plain',
|
|
345
452
|
html: 'text/html',
|
|
453
|
+
jsx: 'text/jsx',
|
|
346
454
|
image: 'application/octet-stream',
|
|
347
455
|
media: 'application/octet-stream',
|
|
348
456
|
archive: 'application/octet-stream',
|
package/src/republish.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The republish flow: a new version of an existing relic.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of a version is that the share URL keeps working, and the
|
|
5
|
+
* URL's fragment is the key, so a new version MUST be encrypted under the
|
|
6
|
+
* key the first publish used. That key exists in exactly two places: the
|
|
7
|
+
* URL, and this machine's publish state. The service holds only a hash of
|
|
8
|
+
* the publish token and none of the key, so it cannot help. Republishing is
|
|
9
|
+
* therefore possible only from the machine that published, which is the
|
|
10
|
+
* boundary the state file draws and this flow enforces before any network.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
decodeKey,
|
|
15
|
+
deriveRendererClass,
|
|
16
|
+
encryptRelic,
|
|
17
|
+
isValidRelicId,
|
|
18
|
+
type RendererClass,
|
|
19
|
+
} from '@relic/format';
|
|
20
|
+
import {
|
|
21
|
+
guessMimetype,
|
|
22
|
+
type PublishDeps,
|
|
23
|
+
PublishError,
|
|
24
|
+
postJson,
|
|
25
|
+
putContainer,
|
|
26
|
+
readSource,
|
|
27
|
+
reportComplete,
|
|
28
|
+
} from './publish.ts';
|
|
29
|
+
import {
|
|
30
|
+
loadPublishState,
|
|
31
|
+
type PublishState,
|
|
32
|
+
savePublishState,
|
|
33
|
+
} from './state.ts';
|
|
34
|
+
|
|
35
|
+
export interface RepublishInput {
|
|
36
|
+
readonly relic_id: string;
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly filename?: string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Forwarded on the republish body when set and omitted when unset, the
|
|
41
|
+
* grant's own convention. A relic's lifetime is fixed at its first grant,
|
|
42
|
+
* so the service does not read it here today; carrying it keeps the
|
|
43
|
+
* request honest about what the publisher asked for and needs no client
|
|
44
|
+
* change if that ever shifts.
|
|
45
|
+
*/
|
|
46
|
+
readonly ttl_days?: number | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RepublishResult {
|
|
50
|
+
readonly relic_id: string;
|
|
51
|
+
/**
|
|
52
|
+
* The version just published, counted from the local state file: the
|
|
53
|
+
* service does not return one, and a publisher only needs to know how
|
|
54
|
+
* many times this relic has changed.
|
|
55
|
+
*/
|
|
56
|
+
readonly version: number;
|
|
57
|
+
/** Null when the relic has no lifetime, which is now the default. */
|
|
58
|
+
readonly relic_expires_at: string | null;
|
|
59
|
+
readonly renderer_class: RendererClass;
|
|
60
|
+
readonly filename: string;
|
|
61
|
+
readonly resolved_path: string;
|
|
62
|
+
readonly report_url: string;
|
|
63
|
+
readonly disclosure_url: string;
|
|
64
|
+
/**
|
|
65
|
+
* Deliberately no `url` member. The share URL is unchanged by a new
|
|
66
|
+
* version, and reprinting it would reprint the key for no new benefit;
|
|
67
|
+
* the first publish disclosed it once and that is the disclosure that
|
|
68
|
+
* counts.
|
|
69
|
+
*/
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function republish(
|
|
73
|
+
input: RepublishInput,
|
|
74
|
+
deps: PublishDeps
|
|
75
|
+
): Promise<RepublishResult> {
|
|
76
|
+
// The gate comes first, before the file is read and before anything can
|
|
77
|
+
// touch the network: without local state there is nothing to encrypt or
|
|
78
|
+
// authorize with, and a round trip would only produce a refusal this
|
|
79
|
+
// machine already knows the reason for.
|
|
80
|
+
if (!isValidRelicId(input.relic_id)) {
|
|
81
|
+
throw new PublishError(
|
|
82
|
+
'no_local_publish_state',
|
|
83
|
+
`"${input.relic_id}" is not a relic id. Republish takes the ` +
|
|
84
|
+
'26-character id the original publish returned.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let state: PublishState;
|
|
89
|
+
try {
|
|
90
|
+
const loaded = await loadPublishState(input.relic_id);
|
|
91
|
+
if (loaded === undefined) {
|
|
92
|
+
throw new PublishError(
|
|
93
|
+
'no_local_publish_state',
|
|
94
|
+
`relic ${input.relic_id} was published from another machine and ` +
|
|
95
|
+
'cannot be republished here. The key and the publish token live ' +
|
|
96
|
+
'only on the machine that made the first publish, and neither can ' +
|
|
97
|
+
'be reconstructed from the link or from the service. Publish the ' +
|
|
98
|
+
'content as a new relic instead.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
state = loaded;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// State-file damage is not "published elsewhere"; naming it as that
|
|
104
|
+
// would send someone hunting the wrong machine.
|
|
105
|
+
if (error instanceof PublishError) throw error;
|
|
106
|
+
throw new PublishError('local_state_unreadable', (error as Error).message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const source = await readSource(input.path, deps.files);
|
|
110
|
+
const filename = input.filename ?? source.basename;
|
|
111
|
+
|
|
112
|
+
// Same rule as a first publish: the class comes from bytes in hand, never
|
|
113
|
+
// from a tool input, so the taxonomy stays machine-attested.
|
|
114
|
+
const rendererClass = deriveRendererClass(source.bytes, filename);
|
|
115
|
+
|
|
116
|
+
// The stored key, not a fresh one. This is the line that keeps the
|
|
117
|
+
// existing share URL decrypting the new version; a fresh key here would
|
|
118
|
+
// silently cut every recipient off from content they were told this link
|
|
119
|
+
// carries.
|
|
120
|
+
const container = await encryptRelic({
|
|
121
|
+
content: source.bytes,
|
|
122
|
+
filename,
|
|
123
|
+
mimetype: guessMimetype(filename, rendererClass),
|
|
124
|
+
key: decodeKey(state.key),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// 1. The republish grant: same shape as a first grant, authorized by the
|
|
128
|
+
// publish token instead of a challenge nonce, aiming the upload at the
|
|
129
|
+
// next version's object.
|
|
130
|
+
const grant = await postJson(
|
|
131
|
+
deps,
|
|
132
|
+
`${deps.serviceOrigin}/api/relics/${input.relic_id}/republish`,
|
|
133
|
+
{
|
|
134
|
+
publish_token: state.publish_token,
|
|
135
|
+
renderer_class: rendererClass,
|
|
136
|
+
declared_size_bytes: source.bytes.length,
|
|
137
|
+
declared_ciphertext_bytes: container.length,
|
|
138
|
+
...(input.ttl_days === undefined ? {} : { ttl_days: input.ttl_days }),
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// 2. Straight to storage, exactly like a first publish.
|
|
143
|
+
await putContainer(
|
|
144
|
+
deps,
|
|
145
|
+
input.relic_id,
|
|
146
|
+
String(grant['upload_url']),
|
|
147
|
+
container
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// 3. Completion, with the same survivability as a first publish.
|
|
151
|
+
await reportComplete(deps, input.relic_id);
|
|
152
|
+
|
|
153
|
+
// 4. Count the version locally before reporting it. The service does not
|
|
154
|
+
// return one, so this file is the only ledger; if the count drifts, the
|
|
155
|
+
// number reported drifts with it, and nothing else does.
|
|
156
|
+
const version = state.version + 1;
|
|
157
|
+
try {
|
|
158
|
+
await savePublishState(input.relic_id, { ...state, version });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw new PublishError(
|
|
161
|
+
'local_state_write_failed',
|
|
162
|
+
'the new version is live at the existing link, but updating the ' +
|
|
163
|
+
'local record failed, so the next republish from this machine may ' +
|
|
164
|
+
`report a stale version number: ${(error as Error).message}`,
|
|
165
|
+
{ relic_id: input.relic_id, version }
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
relic_id: input.relic_id,
|
|
171
|
+
version,
|
|
172
|
+
relic_expires_at:
|
|
173
|
+
grant['relic_expires_at'] == null
|
|
174
|
+
? null
|
|
175
|
+
: String(grant['relic_expires_at']),
|
|
176
|
+
renderer_class: rendererClass,
|
|
177
|
+
filename,
|
|
178
|
+
resolved_path: source.resolvedPath,
|
|
179
|
+
report_url: String(grant['report_url']),
|
|
180
|
+
disclosure_url: String(grant['disclosure_url']),
|
|
181
|
+
};
|
|
182
|
+
}
|