@jxsuite/server 2.0.0 → 2.2.1
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/package.json +19 -16
- package/src/ai-api.ts +30 -13
- package/src/code-api.ts +2 -1
- package/src/collab.ts +35 -4
- package/src/data-api.ts +8 -1
- package/src/import-api.ts +5 -4
- package/src/jx-mounts.ts +11 -0
- package/src/net-guard.ts +188 -28
- package/src/oauth-loopback.ts +373 -0
- package/src/packages.ts +13 -2
- package/src/problem.ts +89 -0
- package/src/project-server.ts +124 -11
- package/src/refactor/apply.ts +3 -57
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/index.ts +7 -0
- package/src/refactor/refs.ts +103 -63
- package/src/refactor/scan.ts +70 -0
- package/src/refactor/watcher.ts +4 -0
- package/src/resolve.ts +9 -10
- package/src/server.ts +36 -6
- package/src/site-output.ts +111 -0
- package/src/site-preview.ts +106 -0
- package/src/studio-api.ts +198 -89
- package/src/watch.ts +54 -3
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 for native apps: the loopback redirect (RFC 8252) with PKCE (RFC 7636).
|
|
3
|
+
*
|
|
4
|
+
* **Why not the device flow.** The desktop app's one sign-in uses GitHub's device flow, which RFC
|
|
5
|
+
* 8628 designed for devices that cannot show a browser or take typed input — a TV, a CLI on a
|
|
6
|
+
* headless box. A desktop app has both, and paying the device flow's price anyway means the user
|
|
7
|
+
* copies a code between two windows and the app polls a token endpoint on a timer. RFC 8252 §7.3
|
|
8
|
+
* names the loopback redirect as the answer for exactly this shape of client, and the host it needs
|
|
9
|
+
* already exists: `project-server.ts` runs a loopback `Bun.serve` on an ephemeral port.
|
|
10
|
+
*
|
|
11
|
+
* Three requirements are load-bearing, and each one is a real attack if dropped:
|
|
12
|
+
*
|
|
13
|
+
* - **The redirect host is the literal `127.0.0.1`, never `localhost`** (§8.3). `localhost` is a
|
|
14
|
+
* name, and a name resolves — a `hosts` entry, a resolver, or a DNS search domain can point it
|
|
15
|
+
* somewhere else, and the authorization code would be delivered there. The literal address cannot
|
|
16
|
+
* be redirected. It also sidesteps the quieter failure: a resolver that answers `localhost` with
|
|
17
|
+
* `::1` while the server bound IPv4, so the browser reaches nothing at all.
|
|
18
|
+
* - **PKCE with `S256`, never `plain`** (RFC 7636 §4.2, RFC 8252 §8.1). A public client keeps no
|
|
19
|
+
* secret, so the authorization code is the whole credential; another local process that observes
|
|
20
|
+
* the redirect can replay it. The verifier is what binds the code to the client that asked for
|
|
21
|
+
* it, and `plain` sends the verifier itself in the authorization request, which defeats the
|
|
22
|
+
* point.
|
|
23
|
+
* - **`state` is single-use, short-lived, and compared in constant time** (RFC 6749 §10.12). It is
|
|
24
|
+
* the CSRF defense: without it, any local page can hand this server a callback for an
|
|
25
|
+
* authorization _it_ started, and the app adopts an attacker's account.
|
|
26
|
+
*
|
|
27
|
+
* The authorization code never reaches a rendered page: the callback answers HTML with no query
|
|
28
|
+
* string of its own and `Referrer-Policy: no-referrer`, so nothing carries it onward.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { secretsMatch } from "./net-guard.ts";
|
|
32
|
+
|
|
33
|
+
/** The loopback redirect path this server hosts. */
|
|
34
|
+
export const OAUTH_CALLBACK_PATH = "/__jx_oauth__/callback";
|
|
35
|
+
|
|
36
|
+
/** RFC 8252 §8.3: the literal address, because a name can be resolved elsewhere. */
|
|
37
|
+
export const LOOPBACK_ADDRESS = "127.0.0.1";
|
|
38
|
+
|
|
39
|
+
/** How long an authorization may stay outstanding before its `state` is forgotten. */
|
|
40
|
+
export const STATE_TTL_MS = 5 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/** Base64url of raw bytes, unpadded (RFC 7636 §A). */
|
|
43
|
+
function base64url(bytes: Uint8Array): string {
|
|
44
|
+
return btoa(String.fromCodePoint(...bytes))
|
|
45
|
+
.replaceAll("+", "-")
|
|
46
|
+
.replaceAll("/", "_")
|
|
47
|
+
.replaceAll("=", "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A cryptographically random base64url string of `bytes` bytes of entropy. */
|
|
51
|
+
function randomToken(bytes: number): string {
|
|
52
|
+
return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A PKCE verifier and its `S256` challenge. */
|
|
56
|
+
export interface PkcePair {
|
|
57
|
+
verifier: string;
|
|
58
|
+
challenge: string;
|
|
59
|
+
method: "S256";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Mint a PKCE verifier and its challenge.
|
|
64
|
+
*
|
|
65
|
+
* 32 random bytes is 43 base64url characters — the minimum RFC 7636 §4.1 allows, and the length
|
|
66
|
+
* every reference implementation uses.
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<PkcePair>}
|
|
69
|
+
*/
|
|
70
|
+
export async function createPkce(): Promise<PkcePair> {
|
|
71
|
+
const verifier = randomToken(32);
|
|
72
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
73
|
+
return { challenge: base64url(new Uint8Array(digest)), method: "S256", verifier };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What a caller must know to start an authorization. */
|
|
77
|
+
export interface AuthorizationRequest {
|
|
78
|
+
/** The provider's authorization endpoint, e.g. `https://github.com/login/oauth/authorize`. */
|
|
79
|
+
authorizationEndpoint: string;
|
|
80
|
+
clientId: string;
|
|
81
|
+
scope?: string;
|
|
82
|
+
/** Extra authorization parameters the provider defines (`prompt`, `login`, …). */
|
|
83
|
+
extraParams?: Record<string, string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One outstanding authorization. */
|
|
87
|
+
export interface PendingAuthorization {
|
|
88
|
+
/** The URL to open in the user's own browser (RFC 8252 §7.3 — never an embedded webview). */
|
|
89
|
+
authorizationUrl: string;
|
|
90
|
+
/** The exact `redirect_uri` sent, which the token exchange must repeat verbatim. */
|
|
91
|
+
redirectUri: string;
|
|
92
|
+
state: string;
|
|
93
|
+
verifier: string;
|
|
94
|
+
/** Resolves with the authorization code once the provider redirects back; rejects on refusal. */
|
|
95
|
+
code: Promise<string>;
|
|
96
|
+
/** Abandon this authorization (the user closed the dialog). Its `state` stops being accepted. */
|
|
97
|
+
cancel: () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Outstanding {
|
|
101
|
+
verifier: string;
|
|
102
|
+
expiresAt: number;
|
|
103
|
+
resolve: (code: string) => void;
|
|
104
|
+
reject: (error: Error) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface LoopbackAuthorizer {
|
|
108
|
+
/** Start an authorization against a server listening on `port`. */
|
|
109
|
+
begin: (port: number, request: AuthorizationRequest) => Promise<PendingAuthorization>;
|
|
110
|
+
/** Answer the loopback redirect. Returns null when the path is not the callback. */
|
|
111
|
+
handleCallback: (url: URL) => Response | null;
|
|
112
|
+
/** Fail every outstanding authorization (server shutdown). */
|
|
113
|
+
stop: () => void;
|
|
114
|
+
/** How many authorizations are outstanding (tests, and the shutdown assertion). */
|
|
115
|
+
pending: () => number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The redirect URI for a server bound to `port`.
|
|
120
|
+
*
|
|
121
|
+
* @param {number} port
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function loopbackRedirectUri(port: number): string {
|
|
125
|
+
return `http://${LOOPBACK_ADDRESS}:${port}${OAUTH_CALLBACK_PATH}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The page the browser lands on. Deliberately inert: no script, no query, no referrer. */
|
|
129
|
+
function callbackPage(title: string, message: string): Response {
|
|
130
|
+
return new Response(
|
|
131
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
|
|
132
|
+
`<title>${title}</title></head><body style="font:16px/1.5 system-ui;margin:4rem auto;max-width:32rem">` +
|
|
133
|
+
`<h1 style="font-size:1.25rem">${title}</h1><p>${message}</p></body></html>`,
|
|
134
|
+
{
|
|
135
|
+
headers: {
|
|
136
|
+
"Cache-Control": "no-store",
|
|
137
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
138
|
+
/*
|
|
139
|
+
* The authorization code arrives in this request's query string. Without this header the
|
|
140
|
+
* browser would put that URL in the `Referer` of anything the page loaded — and the code is
|
|
141
|
+
* the credential until it is exchanged.
|
|
142
|
+
*/
|
|
143
|
+
"Referrer-Policy": "no-referrer",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create the authorizer that owns outstanding authorizations for one server.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} [options]
|
|
153
|
+
* @param {number} [options.ttlMs] Override the state lifetime (tests).
|
|
154
|
+
* @returns {LoopbackAuthorizer}
|
|
155
|
+
*/
|
|
156
|
+
export function createLoopbackAuthorizer(options: { ttlMs?: number } = {}): LoopbackAuthorizer {
|
|
157
|
+
const ttlMs = options.ttlMs ?? STATE_TTL_MS;
|
|
158
|
+
const outstanding = new Map<string, Outstanding>();
|
|
159
|
+
|
|
160
|
+
/** Drop every authorization whose state has aged out. */
|
|
161
|
+
const sweep = (): void => {
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
for (const [state, entry] of outstanding) {
|
|
164
|
+
if (entry.expiresAt <= now) {
|
|
165
|
+
outstanding.delete(state);
|
|
166
|
+
entry.reject(new Error("The sign-in took too long and was abandoned."));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Find an outstanding authorization by state, comparing in constant time.
|
|
173
|
+
*
|
|
174
|
+
* A `Map.get` would return on the first differing character, which is a timing oracle on a value
|
|
175
|
+
* whose whole job is to be unguessable by a local process. The scan is over at most a handful of
|
|
176
|
+
* entries, so the cost is nothing.
|
|
177
|
+
*/
|
|
178
|
+
const take = (state: string): { key: string; entry: Outstanding } | null => {
|
|
179
|
+
for (const [key, entry] of outstanding) {
|
|
180
|
+
if (secretsMatch(key, state)) {
|
|
181
|
+
return { entry, key };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
async begin(port, request) {
|
|
189
|
+
sweep();
|
|
190
|
+
const { challenge, method, verifier } = await createPkce();
|
|
191
|
+
const state = randomToken(32);
|
|
192
|
+
const redirectUri = loopbackRedirectUri(port);
|
|
193
|
+
|
|
194
|
+
const authorizeUrl = new URL(request.authorizationEndpoint);
|
|
195
|
+
authorizeUrl.searchParams.set("client_id", request.clientId);
|
|
196
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
197
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
198
|
+
authorizeUrl.searchParams.set("state", state);
|
|
199
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
200
|
+
authorizeUrl.searchParams.set("code_challenge_method", method);
|
|
201
|
+
if (request.scope) {
|
|
202
|
+
authorizeUrl.searchParams.set("scope", request.scope);
|
|
203
|
+
}
|
|
204
|
+
for (const [key, value] of Object.entries(request.extraParams ?? {})) {
|
|
205
|
+
authorizeUrl.searchParams.set(key, value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let resolve!: (code: string) => void;
|
|
209
|
+
let reject!: (error: Error) => void;
|
|
210
|
+
const code = new Promise<string>((res, rej) => {
|
|
211
|
+
resolve = res;
|
|
212
|
+
reject = rej;
|
|
213
|
+
});
|
|
214
|
+
// Nothing awaits this promise until the caller does; an unobserved rejection is not a crash.
|
|
215
|
+
code.catch(() => {});
|
|
216
|
+
outstanding.set(state, { expiresAt: Date.now() + ttlMs, reject, resolve, verifier });
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
authorizationUrl: authorizeUrl.href,
|
|
220
|
+
cancel: () => {
|
|
221
|
+
const found = take(state);
|
|
222
|
+
if (found) {
|
|
223
|
+
outstanding.delete(found.key);
|
|
224
|
+
found.entry.reject(new Error("The sign-in was cancelled."));
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
code,
|
|
228
|
+
redirectUri,
|
|
229
|
+
state,
|
|
230
|
+
verifier,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
handleCallback(url) {
|
|
235
|
+
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
sweep();
|
|
239
|
+
const state = url.searchParams.get("state");
|
|
240
|
+
const found = state === null ? null : take(state);
|
|
241
|
+
if (!found) {
|
|
242
|
+
/*
|
|
243
|
+
* No matching state: either this callback belongs to an authorization that expired, or it
|
|
244
|
+
* belongs to none at all — which is the CSRF this parameter exists to catch. Both answer
|
|
245
|
+
* the same way, because telling them apart tells an attacker whether they guessed.
|
|
246
|
+
*/
|
|
247
|
+
return callbackPage(
|
|
248
|
+
"This sign-in could not be completed",
|
|
249
|
+
"The request did not match a sign-in started by this app. Start again from Jx Studio.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
// Single-use: a replayed callback finds nothing, whatever its outcome was.
|
|
253
|
+
outstanding.delete(found.key);
|
|
254
|
+
|
|
255
|
+
const error = url.searchParams.get("error");
|
|
256
|
+
if (error !== null) {
|
|
257
|
+
found.entry.reject(
|
|
258
|
+
new Error(url.searchParams.get("error_description") ?? `Authorization failed: ${error}`),
|
|
259
|
+
);
|
|
260
|
+
return callbackPage(
|
|
261
|
+
"Sign-in was not completed",
|
|
262
|
+
"You can close this tab and try again from Jx Studio.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const code = url.searchParams.get("code");
|
|
267
|
+
if (code === null || code === "") {
|
|
268
|
+
found.entry.reject(
|
|
269
|
+
new Error("The provider redirected back without an authorization code."),
|
|
270
|
+
);
|
|
271
|
+
return callbackPage(
|
|
272
|
+
"This sign-in could not be completed",
|
|
273
|
+
"The provider sent no authorization code. Start again from Jx Studio.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
found.entry.resolve(code);
|
|
278
|
+
return callbackPage("You're signed in", "You can close this tab and return to Jx Studio.");
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
pending: () => outstanding.size,
|
|
282
|
+
|
|
283
|
+
stop() {
|
|
284
|
+
for (const [, entry] of outstanding) {
|
|
285
|
+
entry.reject(new Error("The server stopped before the sign-in completed."));
|
|
286
|
+
}
|
|
287
|
+
outstanding.clear();
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The one call `exchangeCode` makes, named so a test can stand in for it without implementing the
|
|
294
|
+
* whole of `fetch` (which carries Bun's `preconnect`, among other things).
|
|
295
|
+
*/
|
|
296
|
+
export type TokenFetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
297
|
+
|
|
298
|
+
/** A token response, in the shape RFC 6749 §5.1 defines. */
|
|
299
|
+
export interface TokenResult {
|
|
300
|
+
accessToken: string;
|
|
301
|
+
tokenType: string;
|
|
302
|
+
scope?: string;
|
|
303
|
+
refreshToken?: string;
|
|
304
|
+
expiresIn?: number;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Exchange an authorization code for a token (RFC 6749 §4.1.3), binding the PKCE verifier.
|
|
309
|
+
*
|
|
310
|
+
* `redirect_uri` is repeated verbatim because §4.1.3 requires it to match the one the authorization
|
|
311
|
+
* request carried — the provider uses it as part of the code's identity, so a normalized or
|
|
312
|
+
* reconstructed value is rejected.
|
|
313
|
+
*
|
|
314
|
+
* No client secret is sent. A desktop app cannot keep one (RFC 8252 §8.5): shipping it puts it in
|
|
315
|
+
* every copy of the binary, where it is a secret in name only. The verifier is what authenticates
|
|
316
|
+
* this exchange.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} params
|
|
319
|
+
* @param {string} params.tokenEndpoint
|
|
320
|
+
* @param {string} params.clientId
|
|
321
|
+
* @param {string} params.code
|
|
322
|
+
* @param {string} params.verifier
|
|
323
|
+
* @param {string} params.redirectUri
|
|
324
|
+
* @param {TokenFetch} [params.fetchImpl] Injectable for tests.
|
|
325
|
+
* @returns {Promise<TokenResult>}
|
|
326
|
+
*/
|
|
327
|
+
export async function exchangeCode(params: {
|
|
328
|
+
tokenEndpoint: string;
|
|
329
|
+
clientId: string;
|
|
330
|
+
code: string;
|
|
331
|
+
verifier: string;
|
|
332
|
+
redirectUri: string;
|
|
333
|
+
fetchImpl?: TokenFetch;
|
|
334
|
+
}): Promise<TokenResult> {
|
|
335
|
+
const body = new URLSearchParams({
|
|
336
|
+
client_id: params.clientId,
|
|
337
|
+
code: params.code,
|
|
338
|
+
code_verifier: params.verifier,
|
|
339
|
+
grant_type: "authorization_code",
|
|
340
|
+
redirect_uri: params.redirectUri,
|
|
341
|
+
});
|
|
342
|
+
const doFetch = params.fetchImpl ?? fetch;
|
|
343
|
+
const response = await doFetch(params.tokenEndpoint, {
|
|
344
|
+
body,
|
|
345
|
+
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
346
|
+
method: "POST",
|
|
347
|
+
});
|
|
348
|
+
const payload = (await response.json().catch(() => null)) as {
|
|
349
|
+
access_token?: unknown;
|
|
350
|
+
error?: unknown;
|
|
351
|
+
error_description?: unknown;
|
|
352
|
+
expires_in?: unknown;
|
|
353
|
+
refresh_token?: unknown;
|
|
354
|
+
scope?: unknown;
|
|
355
|
+
token_type?: unknown;
|
|
356
|
+
} | null;
|
|
357
|
+
|
|
358
|
+
if (payload && typeof payload.error === "string") {
|
|
359
|
+
const description =
|
|
360
|
+
typeof payload.error_description === "string" ? payload.error_description : payload.error;
|
|
361
|
+
throw new Error(`The provider refused the sign-in: ${description}`);
|
|
362
|
+
}
|
|
363
|
+
if (!response.ok || !payload || typeof payload.access_token !== "string") {
|
|
364
|
+
throw new Error(`The token exchange failed (HTTP ${response.status}).`);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
accessToken: payload.access_token,
|
|
368
|
+
...(typeof payload.expires_in === "number" ? { expiresIn: payload.expires_in } : {}),
|
|
369
|
+
...(typeof payload.refresh_token === "string" ? { refreshToken: payload.refresh_token } : {}),
|
|
370
|
+
...(typeof payload.scope === "string" ? { scope: payload.scope } : {}),
|
|
371
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : "bearer",
|
|
372
|
+
};
|
|
373
|
+
}
|
package/src/packages.ts
CHANGED
|
@@ -134,14 +134,25 @@ export async function removePackage(root: string, name: string): Promise<Package
|
|
|
134
134
|
return runBun(["remove", name], root);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Fetch the newest published version for a package from the npm registry, or null on any failure.
|
|
139
|
+
*
|
|
140
|
+
* **`application/json`, not the abbreviated `install-v1` format.** That format is defined for the
|
|
141
|
+
* PACKUMENT endpoint (`/<name>`), and asking for it on the version endpoint (`/<name>/latest`)
|
|
142
|
+
* returns an EMPTY BODY for most packages — verified against `@jxsuite/schema`, `@jxsuite/parser`
|
|
143
|
+
* and `lit`, while `wrangler` happens to answer normally. An empty body fails `res.json()`, the
|
|
144
|
+
* catch below turns that into `null`, and a null reads as "nothing newer" — so this silently
|
|
145
|
+
* under-reported outdated dependencies for everything it was asked about, and looked like a quiet
|
|
146
|
+
* registry rather than a broken request. The injected-fetch tests could not see it: they return a
|
|
147
|
+
* body regardless of the header.
|
|
148
|
+
*/
|
|
138
149
|
export async function fetchLatestVersion(
|
|
139
150
|
name: string,
|
|
140
151
|
fetchImpl: FetchLike = fetch,
|
|
141
152
|
): Promise<string | null> {
|
|
142
153
|
try {
|
|
143
154
|
const res = await fetchImpl(`https://registry.npmjs.org/${name}/latest`, {
|
|
144
|
-
headers: { accept: "application/
|
|
155
|
+
headers: { accept: "application/json" },
|
|
145
156
|
});
|
|
146
157
|
if (!res.ok) {
|
|
147
158
|
return null;
|
package/src/problem.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way this server says a request failed: RFC 9457 `application/problem+json`.
|
|
3
|
+
*
|
|
4
|
+
* There were four ways before — `Response.json({error}, {status})`, a bare-text body, a 200 with an
|
|
5
|
+
* `upstreamError` field, and a thrown string that became a 500 with no body — and the Studio client
|
|
6
|
+
* carried a separate reader for each. A failure could therefore surface with no detail at all,
|
|
7
|
+
* because the reader that ran was not the one for the shape that arrived.
|
|
8
|
+
*
|
|
9
|
+
* `packages/server/scripts/check-error-shapes.ts` keeps the old shapes from regrowing.
|
|
10
|
+
*
|
|
11
|
+
* @docs extending/embedding/backend-protocol
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { PROBLEM_MEDIA_TYPE, problemDetails } from "@jxsuite/protocol";
|
|
15
|
+
import type { ProblemTypeName } from "@jxsuite/protocol";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A failure response for one occurrence of a declared problem type.
|
|
19
|
+
*
|
|
20
|
+
* The status comes from the type rather than from the call site: a type that could be answered with
|
|
21
|
+
* two different statuses is two types, and letting a caller choose is how a registry stops being
|
|
22
|
+
* one. Pass `extensions` for the members the type documents.
|
|
23
|
+
*
|
|
24
|
+
* @param {ProblemTypeName} name - A declared type from `PROBLEM_TYPES`
|
|
25
|
+
* @param {string} [detail] - What happened this time; the line a human reads
|
|
26
|
+
* @param {Record<string, unknown>} [extensions] - Extension members this type documents
|
|
27
|
+
* @returns {Response}
|
|
28
|
+
*/
|
|
29
|
+
export function problem(
|
|
30
|
+
name: ProblemTypeName,
|
|
31
|
+
detail?: string,
|
|
32
|
+
extensions?: Record<string, unknown>,
|
|
33
|
+
): Response {
|
|
34
|
+
const body = problemDetails(name, detail, extensions);
|
|
35
|
+
// The explicit Content-Type is the point: `Response.json` would send `application/json`, and
|
|
36
|
+
// RFC 9457 §3 is what tells a client this body is a problem rather than a result.
|
|
37
|
+
return Response.json(body, {
|
|
38
|
+
headers: { "Content-Type": PROBLEM_MEDIA_TYPE },
|
|
39
|
+
status: body.status,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The declared type for a status a caller already computed.
|
|
45
|
+
*
|
|
46
|
+
* Two files need this and no more should: `data-api.ts`, whose `ApiError` predates the registry and
|
|
47
|
+
* carries only a status, and `ai-api.ts`, which forwards an upstream provider's status because
|
|
48
|
+
* collapsing it to the type's own would discard information. Everywhere else the type is named at
|
|
49
|
+
* the call site, which is what keeps the registry meaningful — a status is not a failure kind.
|
|
50
|
+
*
|
|
51
|
+
* @param {number} status
|
|
52
|
+
* @returns {ProblemTypeName}
|
|
53
|
+
*/
|
|
54
|
+
export function problemTypeForStatus(status: number): ProblemTypeName {
|
|
55
|
+
switch (status) {
|
|
56
|
+
case 400: {
|
|
57
|
+
return "invalidRequest";
|
|
58
|
+
}
|
|
59
|
+
case 401: {
|
|
60
|
+
return "unauthorized";
|
|
61
|
+
}
|
|
62
|
+
case 403: {
|
|
63
|
+
return "forbidden";
|
|
64
|
+
}
|
|
65
|
+
case 404: {
|
|
66
|
+
return "notFound";
|
|
67
|
+
}
|
|
68
|
+
case 405: {
|
|
69
|
+
return "methodNotAllowed";
|
|
70
|
+
}
|
|
71
|
+
case 409: {
|
|
72
|
+
return "conflict";
|
|
73
|
+
}
|
|
74
|
+
case 413: {
|
|
75
|
+
return "payloadTooLarge";
|
|
76
|
+
}
|
|
77
|
+
case 501: {
|
|
78
|
+
return "capabilityUnavailable";
|
|
79
|
+
}
|
|
80
|
+
case 502:
|
|
81
|
+
case 503:
|
|
82
|
+
case 504: {
|
|
83
|
+
return "upstreamFailure";
|
|
84
|
+
}
|
|
85
|
+
default: {
|
|
86
|
+
return status >= 500 ? "internalError" : "invalidRequest";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|