@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.
@@ -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
- /** Fetch the newest published version for a package from the npm registry, or null on any failure. */
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/vnd.npm.install-v1+json" },
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
+ }