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/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.1.1"],
5
+ "args": ["-y", "relic-mcp@0.2.0"],
6
6
  "env": {
7
- "RELIC_SERVICE_ORIGIN": "https://relic-wh2jw5fg2q-uc.a.run.app",
8
- "RELIC_CLIENT_NAME": "relic-plugin/0.1.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Publish a file as an encrypted relic. The key is generated on your machine and never sent to the service.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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` shortens the life of the link. Shorter is better for anything
31
- sensitive; the default is the service maximum.
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
- - links expire (the tool returns the exact date)
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 expires; a commit
65
- does not.
66
- - **A client deliverable.** Those have a durable home, and a link that dies in
67
- a week is not it. Publish a relic in addition if someone needs to look at it
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`. Nothing is retained
6
- * between calls, so a load balancer can round-robin across processes with no
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
- const spec: ServerSpec = {
103
- name: options.name,
104
- command: 'npx',
105
- args: ['-y', 'relic-mcp'],
106
- env: {},
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://your-relic-service\n`
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
- readonly relic_expires_at: string;
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. Nothing is kept on
117
- // disk between invocations, so there is no stale local policy.
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
- // 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) {
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
- 'upload_failed',
202
- `upload returned ${upload.status}`,
203
- { relic_id: relicId, status: upload.status }
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
- // 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.
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 postJson(
212
- deps,
213
- `${deps.serviceOrigin}/api/relics/${relicId}/complete`,
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: relicUrl(deps.relicOrigin, relicId, key),
262
+ url,
222
263
  relic_id: relicId,
223
- relic_expires_at: String(grant['relic_expires_at']),
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',
@@ -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
+ }