@jxsuite/server 2.0.0 → 2.1.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/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 +84 -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
|
+
}
|
package/src/project-server.ts
CHANGED
|
@@ -35,9 +35,13 @@ import type { ImportApiOptions } from "./import-api.ts";
|
|
|
35
35
|
import {
|
|
36
36
|
decodeAndNormalizePath,
|
|
37
37
|
loopbackGate,
|
|
38
|
+
originHostGate,
|
|
38
39
|
serveContained,
|
|
39
40
|
serveProjectFile,
|
|
40
41
|
} from "./net-guard.ts";
|
|
42
|
+
import { problem } from "./problem.ts";
|
|
43
|
+
import { createLoopbackAuthorizer, OAUTH_CALLBACK_PATH } from "./oauth-loopback.ts";
|
|
44
|
+
import type { LoopbackAuthorizer } from "./oauth-loopback.ts";
|
|
41
45
|
|
|
42
46
|
/** A resolved per-window session: its project root plus its RPC handler map. */
|
|
43
47
|
export interface ProjectServerSession {
|
|
@@ -73,6 +77,11 @@ export interface ProjectServerHandle {
|
|
|
73
77
|
wsUrl: string;
|
|
74
78
|
rpcToken: string;
|
|
75
79
|
canvasUrl: string;
|
|
80
|
+
/**
|
|
81
|
+
* The RFC 8252 loopback authorization host. The desktop launcher drives it: `begin()` for the URL
|
|
82
|
+
* to open in the user's browser, then `await pending.code` and {@link exchangeCode}.
|
|
83
|
+
*/
|
|
84
|
+
authorizer: LoopbackAuthorizer;
|
|
76
85
|
stop: () => void;
|
|
77
86
|
}
|
|
78
87
|
|
|
@@ -94,6 +103,9 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
94
103
|
// Bundle cache for npm bare specifiers (mirrors server.ts).
|
|
95
104
|
const bundleCache = new Map<string, string>();
|
|
96
105
|
|
|
106
|
+
/* Outstanding OAuth authorizations (RFC 8252). Per server, so a stopped server abandons them. */
|
|
107
|
+
const authorizer: LoopbackAuthorizer = createLoopbackAuthorizer();
|
|
108
|
+
|
|
97
109
|
const server = Bun.serve<{ winId: string | null }>({
|
|
98
110
|
hostname,
|
|
99
111
|
port,
|
|
@@ -111,6 +123,30 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
111
123
|
|
|
112
124
|
const winId = url.searchParams.get("win");
|
|
113
125
|
|
|
126
|
+
/*
|
|
127
|
+
* 0. The OAuth loopback redirect (RFC 8252 §7.3).
|
|
128
|
+
*
|
|
129
|
+
* **Exempt from the token, and only from the token.** The provider redirects the user's
|
|
130
|
+
* own browser here; that navigation carries whatever the provider's `redirect_uri` said,
|
|
131
|
+
* and a page cannot append a secret to a URL it does not compose. Gating it on the token
|
|
132
|
+
* would make the flow impossible rather than safe.
|
|
133
|
+
*
|
|
134
|
+
* What replaces the token is the `state` parameter, which is unguessable, single-use and
|
|
135
|
+
* compared in constant time — plus the Host and Fetch Metadata checks, which still apply.
|
|
136
|
+
* An IdP redirect is exactly the one cross-site shape the strict policy admits: a
|
|
137
|
+
* top-level GET document navigation, a person following a link.
|
|
138
|
+
*/
|
|
139
|
+
if (normPath === OAUTH_CALLBACK_PATH) {
|
|
140
|
+
const gate = originHostGate(req, "strict");
|
|
141
|
+
if (gate) {
|
|
142
|
+
return gate;
|
|
143
|
+
}
|
|
144
|
+
const callback = authorizer.handleCallback(url);
|
|
145
|
+
if (callback) {
|
|
146
|
+
return callback;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
114
150
|
// 1. WebSocket upgrade — token + loopback Origin/Host are the hard gate.
|
|
115
151
|
const upgrade = req.headers.get("upgrade");
|
|
116
152
|
if (upgrade && upgrade.toLowerCase() === "websocket") {
|
|
@@ -121,11 +157,20 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
121
157
|
if (srv.upgrade(req, { data: { winId } })) {
|
|
122
158
|
return;
|
|
123
159
|
}
|
|
124
|
-
return
|
|
160
|
+
return problem("invalidRequest", "Upgrade failed");
|
|
125
161
|
}
|
|
126
162
|
|
|
127
|
-
|
|
163
|
+
/*
|
|
164
|
+
* 2. AI proxy. Gated with the token, like every other surface that spends something: this
|
|
165
|
+
* route forwards to a provider on the user's key, so an ungated one is an open relay for
|
|
166
|
+
* any local process — and it was dispatched ahead of every gate. The token is what the
|
|
167
|
+
* desktop shell appends to this URL; a request without one is refused.
|
|
168
|
+
*/
|
|
128
169
|
if (normPath.startsWith("/__studio__/ai/")) {
|
|
170
|
+
const gate = loopbackGate(req, url, rpcToken);
|
|
171
|
+
if (gate) {
|
|
172
|
+
return gate;
|
|
173
|
+
}
|
|
129
174
|
const aiUrl = new URL(req.url);
|
|
130
175
|
aiUrl.pathname = normPath.replace("/__studio__/ai/", "/__studio/ai/");
|
|
131
176
|
const aiResponse = await handleAiApi(req, aiUrl);
|
|
@@ -139,7 +184,7 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
139
184
|
if (normPath === "/__studio__/import-site" && req.method === "POST") {
|
|
140
185
|
const { importApi } = options;
|
|
141
186
|
if (!importApi) {
|
|
142
|
-
return
|
|
187
|
+
return problem("notFound", "Not found");
|
|
143
188
|
}
|
|
144
189
|
const gate = loopbackGate(req, url, rpcToken);
|
|
145
190
|
if (gate) {
|
|
@@ -168,22 +213,34 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
168
213
|
headers.set("Referrer-Policy", "same-origin");
|
|
169
214
|
return new Response(res.body, { headers, status: res.status });
|
|
170
215
|
}
|
|
171
|
-
return
|
|
216
|
+
return problem("notFound", "Not found");
|
|
172
217
|
}
|
|
173
218
|
|
|
174
219
|
// 4. Resolve the session (fail-closed).
|
|
175
220
|
const session = resolveSession(winId);
|
|
176
221
|
if (!session) {
|
|
177
|
-
return
|
|
222
|
+
return problem("notFound", "Unknown window");
|
|
178
223
|
}
|
|
179
224
|
const root = session.projectRoot;
|
|
180
225
|
if (!root) {
|
|
181
|
-
return
|
|
226
|
+
return problem("notFound", "No project");
|
|
182
227
|
}
|
|
183
228
|
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
/*
|
|
230
|
+
* 4b. Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as the
|
|
231
|
+
* generated site worker and the dev server (specs/extensions.md §11).
|
|
232
|
+
*
|
|
233
|
+
* Gated on **Origin/Host and Fetch Metadata, not the token**, and the distinction is the
|
|
234
|
+
* point: these are fetched by the canvas iframe's own page, whose requests carry no
|
|
235
|
+
* `?token=` — a page cannot rewrite the URLs its own content asks for. So the token is
|
|
236
|
+
* the wrong instrument here and the origin check is the right one. The policy is
|
|
237
|
+
* `embeddable` because that iframe is cross-origin by construction.
|
|
238
|
+
*/
|
|
186
239
|
if (normPath.startsWith("/_jx/")) {
|
|
240
|
+
const gate = originHostGate(req, "embeddable");
|
|
241
|
+
if (gate) {
|
|
242
|
+
return gate;
|
|
243
|
+
}
|
|
187
244
|
const mountRes = await handleJxMounts(req, url, root);
|
|
188
245
|
if (mountRes) {
|
|
189
246
|
return mountRes;
|
|
@@ -206,8 +263,17 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
206
263
|
return handleServerFunction(req, root);
|
|
207
264
|
}
|
|
208
265
|
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
/*
|
|
267
|
+
* 6. Project files at natural URLs, including extension asset mounts (§8.5) — that is what
|
|
268
|
+
* lets a canvas preview show an image a content entry references relative to itself.
|
|
269
|
+
*
|
|
270
|
+
* `embeddable`, for the same reason as the mounts: these ARE the canvas iframe's
|
|
271
|
+
* subresources, and a cross-origin iframe's images arrive `cross-site`.
|
|
272
|
+
*/
|
|
273
|
+
const staticGate = originHostGate(req, "embeddable");
|
|
274
|
+
if (staticGate) {
|
|
275
|
+
return staticGate;
|
|
276
|
+
}
|
|
211
277
|
const fileRes = await serveProjectFile(normPath, root, await projectAssetMounts(root));
|
|
212
278
|
if (fileRes) {
|
|
213
279
|
return fileRes;
|
|
@@ -238,7 +304,11 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
238
304
|
}
|
|
239
305
|
}
|
|
240
306
|
|
|
241
|
-
|
|
307
|
+
/* 8. The project's BUILT SITE is not served here — see the same note in `server.ts`. This
|
|
308
|
+
server's paths mean the project's SOURCES, and a built page means its own output by the
|
|
309
|
+
very same paths; `site-preview.ts` gives that output its own origin, which is what
|
|
310
|
+
`View: Open in Browser` opens. */
|
|
311
|
+
return problem("notFound", "Not found");
|
|
242
312
|
},
|
|
243
313
|
|
|
244
314
|
websocket: {
|
|
@@ -291,7 +361,10 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
291
361
|
wsUrl,
|
|
292
362
|
rpcToken,
|
|
293
363
|
canvasUrl,
|
|
364
|
+
authorizer,
|
|
294
365
|
stop: () => {
|
|
366
|
+
// Abandon outstanding sign-ins first: their callbacks can no longer arrive.
|
|
367
|
+
authorizer.stop();
|
|
295
368
|
void server.stop(true);
|
|
296
369
|
},
|
|
297
370
|
};
|