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/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
- relic_expires_at: { type: 'string' },
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: '0.1.0',
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: { tools: [TOOL_DEFINITION, DESCRIBE_TOOL_DEFINITION] },
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({ path, filename }, deps);
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${result.url}\n\n` +
284
- `Expires ${result.relic_expires_at}. Anyone with this link, ` +
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.\n' +
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: [{ type: 'text', text: `${error.code}: ${error.message}` }],
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
+ }