relic-mcp 0.3.2 → 0.4.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 +42 -0
- package/dist/relic-mcp.js +506 -16
- package/package.json +1 -1
- package/skills/relic/SKILL.md +50 -1
- package/src/comments.ts +286 -0
- package/src/publish.ts +59 -8
- package/src/server.ts +343 -17
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 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.
|
|
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, reading the comments people leave on one and answering them, what the recipient sees, how long a link lives, and what the service can and cannot read.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Relic
|
|
@@ -39,6 +39,11 @@ Do not publish an update as a new relic. That costs a second URL that nobody
|
|
|
39
39
|
holding the first one will ever see. `relic_publish` enforces this: when local
|
|
40
40
|
state matches the source, it refuses and points to `relic_republish`.
|
|
41
41
|
|
|
42
|
+
Anyone holding a relic's link can fetch every version it has ever held, so
|
|
43
|
+
republishing does not withdraw earlier content. Republishing moves the artifact
|
|
44
|
+
forward without retracting what came before. Deleting the relic still removes
|
|
45
|
+
every version.
|
|
46
|
+
|
|
42
47
|
Optional arguments worth knowing:
|
|
43
48
|
|
|
44
49
|
- `filename` overrides the display name shown to the recipient.
|
|
@@ -81,6 +86,50 @@ Two things to know before promising an update:
|
|
|
81
86
|
A relic's lifetime is set at its first publish and carries across versions
|
|
82
87
|
unchanged.
|
|
83
88
|
|
|
89
|
+
## Comments
|
|
90
|
+
|
|
91
|
+
People can comment on a relic, and that is the only way a reader can answer
|
|
92
|
+
back: there is no reply-to, no dashboard, and no notification. So read them.
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
relic_read_comments(relic_id: "0a2c...")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Read them **before** you change content somebody was asked to review, and read
|
|
99
|
+
them again after you hand a link over and come back to the task. A comment
|
|
100
|
+
that nobody read is the same as a comment nobody left, except somebody spent
|
|
101
|
+
the effort.
|
|
102
|
+
|
|
103
|
+
Answer with:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
relic_comment(relic_id: "0a2c...", body: "Fixed the chart, republished as version 3.")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Four things to know:
|
|
110
|
+
|
|
111
|
+
- **Both work only on the machine that published the relic.** The comment key
|
|
112
|
+
is derived from that relic's key, which lives in the same local 0600 file as
|
|
113
|
+
the publish token. On any other machine they refuse, for the same reason
|
|
114
|
+
`relic_republish` does, and no retry changes it.
|
|
115
|
+
- **Pass the relic id, never the URL.** The URL carries the key in its
|
|
116
|
+
fragment. Passing it would put the key in the transcript again for nothing.
|
|
117
|
+
The tools refuse a URL and say so.
|
|
118
|
+
- **Your comment is attributed to the publisher, not to a person.** A human
|
|
119
|
+
commenter verifies an email address through a magic link and that address is
|
|
120
|
+
their identity. You have no mailbox, so the publish token stands in and the
|
|
121
|
+
comment reads as `publisher`. Optional `display_name` puts a label beside it;
|
|
122
|
+
it decorates the attribution and never replaces it.
|
|
123
|
+
- **A comment that will not decrypt comes back marked unreadable**, with a
|
|
124
|
+
count. That is not noise to filter out: it means part of the conversation is
|
|
125
|
+
unread. Say so rather than acting as though the readable ones are all of it.
|
|
126
|
+
|
|
127
|
+
Comment bodies are encrypted on this machine, so the service stores ciphertext
|
|
128
|
+
it cannot read. What it does learn is who commented on which relic and when,
|
|
129
|
+
which for a human commenter is a verified email address. Worth saying plainly
|
|
130
|
+
if somebody asks what commenting costs them: the content stays private and the
|
|
131
|
+
participation does not.
|
|
132
|
+
|
|
84
133
|
## Say this when you hand over the link
|
|
85
134
|
|
|
86
135
|
**The key is in the URL, and the URL is now in the transcript.** Anyone with
|
package/src/comments.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comments, from an agent's seat.
|
|
3
|
+
*
|
|
4
|
+
* The feature exists so a person can leave a comment and an agent can read it
|
|
5
|
+
* back and act on it. The agent's half has two constraints the person's does
|
|
6
|
+
* not, and both are structural rather than stylistic.
|
|
7
|
+
*
|
|
8
|
+
* **An agent cannot receive email.** `docs/frame.md` makes a verified email
|
|
9
|
+
* address the commenter identity, and verification runs through a magic link,
|
|
10
|
+
* which needs a mailbox. There is no mailbox here. The publish token already
|
|
11
|
+
* proves this machine published the relic, so it stands as the agent's
|
|
12
|
+
* identity and the service attributes the comment to `publisher`. That is
|
|
13
|
+
* attribution, never authorization: the same non-goal entry says verified
|
|
14
|
+
* email buys attribution and not entitlement, and a bearer token buys less.
|
|
15
|
+
*
|
|
16
|
+
* **The share URL is never an argument.** The fragment is the key, so a tool
|
|
17
|
+
* accepting the URL would put the key in the transcript on every read, which
|
|
18
|
+
* is the one disclosure `spec/publish.md` section 5 spends deliberately and
|
|
19
|
+
* exactly once, at publish. These tools take the relic id and read the key
|
|
20
|
+
* from local publish state instead, which draws the same machine boundary
|
|
21
|
+
* republish already draws: only the machine that published can comment on a
|
|
22
|
+
* relic or read its comments here.
|
|
23
|
+
*
|
|
24
|
+
* The comment key is derived from the fragment's key bytes under a distinct
|
|
25
|
+
* HKDF `info`, so it is independent of the container key by construction, and
|
|
26
|
+
* the fragment does not change. `spec/format.md` 2.1 fixes the fragment at
|
|
27
|
+
* the marker and the key, and a third field would cost a version bump.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
COMMENT_BODY_LIMIT_BYTES,
|
|
32
|
+
COMMENT_DISPLAY_NAME_LIMIT_BYTES,
|
|
33
|
+
decodeKey,
|
|
34
|
+
decryptComment,
|
|
35
|
+
deriveCommentKey,
|
|
36
|
+
encryptComment,
|
|
37
|
+
isValidRelicId,
|
|
38
|
+
} from '@relic/format';
|
|
39
|
+
import {
|
|
40
|
+
getJson,
|
|
41
|
+
type PublishDeps,
|
|
42
|
+
PublishError,
|
|
43
|
+
postJson,
|
|
44
|
+
} from './publish.ts';
|
|
45
|
+
import { loadPublishState, type PublishState } from './state.ts';
|
|
46
|
+
|
|
47
|
+
/** One comment as an agent reads it. */
|
|
48
|
+
export interface CommentRecord {
|
|
49
|
+
readonly comment_id: string;
|
|
50
|
+
/**
|
|
51
|
+
* The verified email address of a human commenter, or the literal
|
|
52
|
+
* `publisher` for a comment authorized by a publish token. Returned as the
|
|
53
|
+
* service gave it: `docs/frame.md` makes the address the identity, so a
|
|
54
|
+
* display name aliases it and never replaces it.
|
|
55
|
+
*/
|
|
56
|
+
readonly author: string;
|
|
57
|
+
readonly created_at: string;
|
|
58
|
+
/** The commenter's chosen alias, when they set one. Decoration, not identity. */
|
|
59
|
+
readonly display_name: string | null;
|
|
60
|
+
/** Null exactly when `readable` is false. */
|
|
61
|
+
readonly body: string | null;
|
|
62
|
+
readonly readable: boolean;
|
|
63
|
+
/** Null exactly when `readable` is true. */
|
|
64
|
+
readonly unreadable_reason: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ReadCommentsResult {
|
|
68
|
+
readonly relic_id: string;
|
|
69
|
+
readonly count: number;
|
|
70
|
+
/**
|
|
71
|
+
* Comments the comment key did not open. Reported rather than dropped: a
|
|
72
|
+
* silently shortened list reads as agreement, and an agent acting on
|
|
73
|
+
* "nobody objected" when somebody did is the failure this member prevents.
|
|
74
|
+
*/
|
|
75
|
+
readonly unreadable_count: number;
|
|
76
|
+
readonly comments: readonly CommentRecord[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CommentResult {
|
|
80
|
+
readonly relic_id: string;
|
|
81
|
+
readonly comment_id: string;
|
|
82
|
+
readonly author: string;
|
|
83
|
+
readonly created_at: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CommentInput {
|
|
87
|
+
readonly relic_id: string;
|
|
88
|
+
readonly body: string;
|
|
89
|
+
readonly display_name?: string | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function readComments(
|
|
93
|
+
relicId: string,
|
|
94
|
+
deps: PublishDeps
|
|
95
|
+
): Promise<ReadCommentsResult> {
|
|
96
|
+
const state = await localState(relicId);
|
|
97
|
+
|
|
98
|
+
// No credential on this read. Anyone holding the link can already fetch the
|
|
99
|
+
// ciphertext, and the bodies are ciphertext the service cannot open, so
|
|
100
|
+
// authorizing it would gate nothing and cost the token an exposure.
|
|
101
|
+
const listed = await getJson(
|
|
102
|
+
deps,
|
|
103
|
+
`${deps.serviceOrigin}/api/relics/${relicId}/comments`
|
|
104
|
+
);
|
|
105
|
+
if (!Array.isArray(listed)) {
|
|
106
|
+
throw new PublishError(
|
|
107
|
+
'app_response_unusable',
|
|
108
|
+
'the comment list did not come back as a JSON array, so there is no ' +
|
|
109
|
+
'way to tell an empty conversation from an unreadable response',
|
|
110
|
+
{ relic_id: relicId, leg: 'comments' }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const commentKey = await deriveCommentKey(decodeKey(state.key));
|
|
115
|
+
const comments: CommentRecord[] = [];
|
|
116
|
+
let unreadable = 0;
|
|
117
|
+
|
|
118
|
+
for (const [index, entry] of listed.entries()) {
|
|
119
|
+
const row =
|
|
120
|
+
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
121
|
+
? (entry as Record<string, unknown>)
|
|
122
|
+
: {};
|
|
123
|
+
const commentId =
|
|
124
|
+
typeof row['comment_id'] === 'string'
|
|
125
|
+
? row['comment_id']
|
|
126
|
+
: `unidentified-${index}`;
|
|
127
|
+
const author =
|
|
128
|
+
typeof row['author'] === 'string' ? row['author'] : 'unknown';
|
|
129
|
+
const createdAt =
|
|
130
|
+
typeof row['created_at'] === 'string' ? row['created_at'] : 'unknown';
|
|
131
|
+
const ciphertext = row['ciphertext'];
|
|
132
|
+
|
|
133
|
+
if (typeof ciphertext !== 'string') {
|
|
134
|
+
unreadable += 1;
|
|
135
|
+
comments.push({
|
|
136
|
+
comment_id: commentId,
|
|
137
|
+
author,
|
|
138
|
+
created_at: createdAt,
|
|
139
|
+
display_name: null,
|
|
140
|
+
body: null,
|
|
141
|
+
readable: false,
|
|
142
|
+
unreadable_reason: 'the row carried no ciphertext',
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const plaintext = await decryptComment(commentKey, ciphertext);
|
|
149
|
+
comments.push({
|
|
150
|
+
comment_id: commentId,
|
|
151
|
+
author,
|
|
152
|
+
created_at: createdAt,
|
|
153
|
+
display_name: plaintext.display_name,
|
|
154
|
+
body: plaintext.body,
|
|
155
|
+
readable: true,
|
|
156
|
+
unreadable_reason: null,
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// One comment that will not open must not hide the ones that will, and
|
|
160
|
+
// it must not vanish either. It comes back named, with the reason.
|
|
161
|
+
unreadable += 1;
|
|
162
|
+
comments.push({
|
|
163
|
+
comment_id: commentId,
|
|
164
|
+
author,
|
|
165
|
+
created_at: createdAt,
|
|
166
|
+
display_name: null,
|
|
167
|
+
body: null,
|
|
168
|
+
readable: false,
|
|
169
|
+
unreadable_reason: `it did not decrypt under this relic's comment key: ${
|
|
170
|
+
(error as Error).message
|
|
171
|
+
}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
relic_id: relicId,
|
|
178
|
+
count: comments.length,
|
|
179
|
+
unreadable_count: unreadable,
|
|
180
|
+
comments,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function postComment(
|
|
185
|
+
input: CommentInput,
|
|
186
|
+
deps: PublishDeps
|
|
187
|
+
): Promise<CommentResult> {
|
|
188
|
+
const state = await localState(input.relic_id);
|
|
189
|
+
|
|
190
|
+
// The caps belong to the envelope, so they are enforced against its numbers
|
|
191
|
+
// rather than a second copy of them, and enforced before encryption so the
|
|
192
|
+
// refusal names the limit instead of a cipher failure.
|
|
193
|
+
const bodyBytes = new TextEncoder().encode(input.body).length;
|
|
194
|
+
if (input.body.trim().length === 0) {
|
|
195
|
+
throw new PublishError(
|
|
196
|
+
'local_comment_body_empty',
|
|
197
|
+
'a comment needs a body. An empty one is attributable noise nobody can ' +
|
|
198
|
+
'answer.'
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (bodyBytes > COMMENT_BODY_LIMIT_BYTES) {
|
|
202
|
+
throw new PublishError(
|
|
203
|
+
'local_comment_body_too_long',
|
|
204
|
+
`the comment body is ${bodyBytes} bytes of UTF-8 and the limit is ` +
|
|
205
|
+
`${COMMENT_BODY_LIMIT_BYTES}. Shorten it; a truncated comment would ` +
|
|
206
|
+
'change what it says.',
|
|
207
|
+
{ body_bytes: bodyBytes, limit_bytes: COMMENT_BODY_LIMIT_BYTES }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const displayName = input.display_name ?? null;
|
|
212
|
+
if (displayName !== null) {
|
|
213
|
+
const nameBytes = new TextEncoder().encode(displayName).length;
|
|
214
|
+
if (nameBytes > COMMENT_DISPLAY_NAME_LIMIT_BYTES) {
|
|
215
|
+
throw new PublishError(
|
|
216
|
+
'local_comment_name_too_long',
|
|
217
|
+
`the display name is ${nameBytes} bytes of UTF-8 and the limit is ` +
|
|
218
|
+
`${COMMENT_DISPLAY_NAME_LIMIT_BYTES}.`,
|
|
219
|
+
{ name_bytes: nameBytes, limit_bytes: COMMENT_DISPLAY_NAME_LIMIT_BYTES }
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const commentKey = await deriveCommentKey(decodeKey(state.key));
|
|
225
|
+
const ciphertext = await encryptComment(commentKey, {
|
|
226
|
+
body: input.body,
|
|
227
|
+
display_name: displayName,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The token travels in the body, where the republish grant already puts it,
|
|
231
|
+
// so the two write paths authorize the same way and neither invents a
|
|
232
|
+
// header the service has to learn.
|
|
233
|
+
const posted = await postJson(
|
|
234
|
+
deps,
|
|
235
|
+
`${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`,
|
|
236
|
+
{ publish_token: state.publish_token, ciphertext }
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
relic_id: input.relic_id,
|
|
241
|
+
comment_id: String(posted['comment_id']),
|
|
242
|
+
author: String(posted['author']),
|
|
243
|
+
created_at: String(posted['created_at']),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The machine boundary, checked before anything touches the network.
|
|
249
|
+
*
|
|
250
|
+
* Both tools need the key, and the write needs the publish token too. Neither
|
|
251
|
+
* can be reconstructed from the link or from the service, so a relic this
|
|
252
|
+
* machine never published is refused here rather than after a round trip that
|
|
253
|
+
* could only end the same way.
|
|
254
|
+
*/
|
|
255
|
+
async function localState(relicId: string): Promise<PublishState> {
|
|
256
|
+
if (!isValidRelicId(relicId)) {
|
|
257
|
+
throw new PublishError(
|
|
258
|
+
'no_local_publish_state',
|
|
259
|
+
`"${relicId}" is not a relic id. These tools take the 26-character id ` +
|
|
260
|
+
'the original publish returned, never the share URL: the URL carries ' +
|
|
261
|
+
'the key in its fragment, and passing it would put the key in this ' +
|
|
262
|
+
'transcript for nothing.'
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const loaded = await loadPublishState(relicId);
|
|
268
|
+
if (loaded === undefined) {
|
|
269
|
+
throw new PublishError(
|
|
270
|
+
'no_local_publish_state',
|
|
271
|
+
`relic ${relicId} was published from another machine, so its ` +
|
|
272
|
+
'comments can be neither read nor written here. The key that ' +
|
|
273
|
+
'decrypts a comment and the publish token that authorizes one live ' +
|
|
274
|
+
'only on the machine that made the first publish, and neither can ' +
|
|
275
|
+
'be reconstructed from the link or from the service. Open the ' +
|
|
276
|
+
"relic's own page to read its comments, or ask whoever published it."
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return loaded;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// State-file damage is not "published elsewhere"; naming it as that would
|
|
282
|
+
// send someone hunting the wrong machine.
|
|
283
|
+
if (error instanceof PublishError) throw error;
|
|
284
|
+
throw new PublishError('local_state_unreadable', (error as Error).message);
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/publish.ts
CHANGED
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
* app-server status, so they get codes here, and these never collide with the
|
|
37
37
|
* server's. The state codes join them for the same reason: the publish state
|
|
38
38
|
* is this machine's alone, so its failures are this machine's to report.
|
|
39
|
+
*
|
|
40
|
+
* The `local_comment_*` codes take the `local_` prefix `spec/publish.md` 2.2
|
|
41
|
+
* reserves for a client-side refusal with no leg, rather than a bare
|
|
42
|
+
* `comment_` one. The service owns its own comment refusals, and an
|
|
43
|
+
* unprefixed name here would eventually collide with one of them.
|
|
39
44
|
*/
|
|
40
45
|
export type ClientCode =
|
|
41
46
|
| 'source_not_found'
|
|
@@ -49,7 +54,11 @@ export type ClientCode =
|
|
|
49
54
|
| 'grant_missing_publish_token'
|
|
50
55
|
| 'no_local_publish_state'
|
|
51
56
|
| 'local_state_unreadable'
|
|
52
|
-
| 'local_state_write_failed'
|
|
57
|
+
| 'local_state_write_failed'
|
|
58
|
+
| 'local_comment_body_empty'
|
|
59
|
+
| 'local_comment_body_too_long'
|
|
60
|
+
| 'local_comment_name_too_long'
|
|
61
|
+
| 'app_response_unusable';
|
|
53
62
|
|
|
54
63
|
export class PublishError extends Error {
|
|
55
64
|
override readonly name = 'PublishError';
|
|
@@ -457,18 +466,60 @@ export async function postJson(
|
|
|
457
466
|
);
|
|
458
467
|
}
|
|
459
468
|
|
|
460
|
-
const parsed =
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
469
|
+
const parsed = await readJson(response);
|
|
470
|
+
// Every publish leg answers with an object. A body that is not one is read
|
|
471
|
+
// as empty rather than crashing the caller on its first member read.
|
|
472
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
473
|
+
? (parsed as Record<string, unknown>)
|
|
474
|
+
: {};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* GET a JSON document from the app server.
|
|
479
|
+
*
|
|
480
|
+
* Separate from `postJson` only because the payload may legitimately be an
|
|
481
|
+
* array: the comment list is one. The refusal handling is the same leg and
|
|
482
|
+
* the same rules, so it is shared rather than restated.
|
|
483
|
+
*/
|
|
484
|
+
export async function getJson(
|
|
485
|
+
deps: PublishDeps,
|
|
486
|
+
url: string
|
|
487
|
+
): Promise<unknown> {
|
|
488
|
+
let response: Response;
|
|
489
|
+
try {
|
|
490
|
+
response = await deps.fetch(url);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
throw new PublishError(
|
|
493
|
+
'service_unreachable',
|
|
494
|
+
`could not reach ${url}: ${(error as Error).message}`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return readJson(response);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Throw the app server's refusal, or hand back whatever it answered with.
|
|
502
|
+
*
|
|
503
|
+
* The shape is deliberately `unknown`: the grant legs answer with an object
|
|
504
|
+
* and the comment list answers with an array, so narrowing belongs to the
|
|
505
|
+
* caller that knows which it asked for.
|
|
506
|
+
*/
|
|
507
|
+
async function readJson(response: Response): Promise<unknown> {
|
|
508
|
+
const parsed = await response.json().catch(() => undefined);
|
|
464
509
|
|
|
465
510
|
if (!response.ok) {
|
|
466
511
|
// Clients key on `code`, never on prose. RFC 9457 says so about its own
|
|
467
|
-
// `detail` member: consumers should not parse it for information.
|
|
512
|
+
// `detail` member: consumers should not parse it for information. A body
|
|
513
|
+
// that is not a problem document still has to produce a refusal, so an
|
|
514
|
+
// unusable one degrades to `unknown` rather than throwing here.
|
|
515
|
+
const problem =
|
|
516
|
+
typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
517
|
+
? (parsed as Record<string, unknown>)
|
|
518
|
+
: {};
|
|
468
519
|
throw new ServerRefusal(
|
|
469
|
-
String(
|
|
520
|
+
String(problem['code'] ?? 'unknown'),
|
|
470
521
|
response.status,
|
|
471
|
-
|
|
522
|
+
problem
|
|
472
523
|
);
|
|
473
524
|
}
|
|
474
525
|
|