@moloodev/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dev-server.ts CHANGED
@@ -1,82 +1,27 @@
1
+ import { getWorkspaceHostUrl } from "@moloodev/platform";
1
2
  import { existsSync, watch, type FSWatcher } from "node:fs";
2
3
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import esbuild from "esbuild";
4
+ import { join } from "node:path";
6
5
  import {
7
- allowedFrameAncestor,
8
6
  editorOriginHeaders,
9
7
  type EditorOriginHeaderPolicy,
10
8
  } from "@moloodev/editor-sdk/origin-headers";
11
- import { buildEditorScript, readPackageEditorView, styleHeader } from "./editor-views";
9
+ import { buildEditorScript, readPackageEditorView } from "./editor-views";
12
10
 
13
11
  export type StartEditorDevServerOptions = {
14
12
  readonly packageRoot: string;
15
- readonly hostPort?: number;
16
13
  readonly viewPort?: number;
17
14
  readonly watch?: boolean;
18
15
  };
19
16
 
20
17
  export type EditorDevServer = {
21
18
  readonly editorId: string;
22
- readonly hostOrigin: string;
23
19
  readonly viewOrigin: string;
24
- readonly hostPort: number;
25
20
  readonly viewPort: number;
26
21
  rebuild(): Promise<{ build: number }>;
27
22
  close(): Promise<void>;
28
23
  };
29
24
 
30
- let cachedHostScript: string | null = null;
31
-
32
- function packageSourceDir(specifier: string): string {
33
- let dir = dirname(fileURLToPath(import.meta.resolve(specifier)));
34
- while (!existsSync(join(dir, "package.json"))) {
35
- const parent = dirname(dir);
36
- if (parent === dir) throw new Error(`Cannot locate package root for ${specifier}`);
37
- dir = parent;
38
- }
39
- return join(dir, "src");
40
- }
41
-
42
- async function buildHostClientScript(): Promise<string> {
43
- if (cachedHostScript !== null) return cachedHostScript;
44
- const here = dirname(fileURLToPath(import.meta.url));
45
- const css = new Set<string>();
46
- const built = await esbuild.build({
47
- entryPoints: [join(here, "dev-host-entry.tsx")],
48
- bundle: true,
49
- format: "iife",
50
- platform: "browser",
51
- jsx: "automatic",
52
- write: false,
53
- logLevel: "error",
54
- legalComments: "none",
55
- define: { "process.env.NODE_ENV": '"development"' },
56
- alias: { "lib0/logging": join(here, "yjs-log.ts") },
57
- plugins: [
58
- {
59
- name: "collect-host-css",
60
- setup(builder) {
61
- builder.onLoad({ filter: /\.css$/ }, (args) => {
62
- css.add(args.path);
63
- return { contents: "", loader: "js" };
64
- });
65
- },
66
- },
67
- ],
68
- });
69
- const file = built.outputFiles?.[0];
70
- if (!file) throw new Error("Failed to bundle dev-host-entry.tsx");
71
- const header = await styleHeader(join(here, "dev-host.css"), [...css], [
72
- here,
73
- packageSourceDir("@moloodev/ui"),
74
- packageSourceDir("@moloodev/editor-sdk/layout"),
75
- ]);
76
- cachedHostScript = header + file.text;
77
- return cachedHostScript;
78
- }
79
-
80
25
  function viewDocumentCsp(network: readonly string[], ancestor: string | null): string {
81
26
  const connect = ["'self'", ...network];
82
27
  const frameAncestors = ancestor ? [ancestor] : ["'none'"];
@@ -136,8 +81,8 @@ function closeServer(server: Server): Promise<void> {
136
81
  export async function startEditorDevServer(
137
82
  options: StartEditorDevServerOptions,
138
83
  ): Promise<EditorDevServer> {
84
+ const appOrigin = getWorkspaceHostUrl("production");
139
85
  const initialView = readPackageEditorView(options.packageRoot);
140
- const hostScript = await buildHostClientScript();
141
86
  let currentView = initialView;
142
87
  let currentEditorScript = await buildEditorScript(initialView);
143
88
  let buildCount = 1;
@@ -146,11 +91,32 @@ export async function startEditorDevServer(
146
91
  let watcher: FSWatcher | null = null;
147
92
  let watchTimer: ReturnType<typeof setTimeout> | null = null;
148
93
 
149
- let hostOrigin = "";
150
94
  let viewOrigin = "";
151
95
 
152
96
  const viewServer = createServer((req: IncomingMessage, res: ServerResponse) => {
97
+ // Check the literal authority before handling requests to prevent DNS rebinding.
98
+ if (![`127.0.0.1:${boundViewPort}`, `localhost:${boundViewPort}`, `[::1]:${boundViewPort}`].includes(req.headers.host ?? "") ||
99
+ (req.headers.origin && req.headers.origin !== appOrigin)) {
100
+ res.writeHead(403);
101
+ res.end("forbidden");
102
+ return;
103
+ }
104
+ if (req.headers.origin === appOrigin) {
105
+ res.setHeader("access-control-allow-origin", appOrigin);
106
+ res.setHeader("vary", "Origin");
107
+ }
153
108
  const method = req.method ?? "GET";
109
+ if (method === "OPTIONS" && req.headers.origin === appOrigin) {
110
+ res.setHeader("access-control-allow-methods", "GET, HEAD");
111
+ // Manifest fetch has no custom headers; EventSource may resume with Last-Event-ID.
112
+ res.setHeader("access-control-allow-headers", "last-event-id");
113
+ if (req.headers["access-control-request-private-network"] === "true") {
114
+ res.setHeader("access-control-allow-private-network", "true");
115
+ }
116
+ res.writeHead(204, { "cache-control": "no-store" });
117
+ res.end();
118
+ return;
119
+ }
154
120
  const url = new URL(req.url ?? "/", viewOrigin || "http://127.0.0.1");
155
121
  const refused = editorOriginHeaders({ parent: null, verifiedOrigins: [] });
156
122
 
@@ -166,10 +132,10 @@ export async function startEditorDevServer(
166
132
 
167
133
  if (url.pathname === "/") {
168
134
  const parent = singleQueryParam(url, "parent");
169
- const verifiedOrigins = [hostOrigin, `http://localhost:${String(boundHostPort)}`];
170
- const ancestor = allowedFrameAncestor(parent, verifiedOrigins);
135
+ const verifiedOrigins: readonly string[] = [appOrigin];
136
+ const ancestor = parent === appOrigin ? appOrigin : null;
171
137
  const policy: EditorOriginHeaderPolicy = {
172
- ...editorOriginHeaders({ parent, verifiedOrigins }),
138
+ ...editorOriginHeaders({ parent: ancestor, verifiedOrigins }),
173
139
  "content-security-policy": viewDocumentCsp(currentView.network, ancestor),
174
140
  };
175
141
  applyOriginPolicy(res, policy);
@@ -203,62 +169,8 @@ export async function startEditorDevServer(
203
169
  return;
204
170
  }
205
171
 
206
- applyOriginPolicy(res, refused);
207
- res.writeHead(404, {
208
- "content-type": "text/plain; charset=utf-8",
209
- "cache-control": "no-store",
210
- });
211
- res.end(method === "HEAD" ? undefined : "not found");
212
- });
213
-
214
- const hostServer = createServer((req: IncomingMessage, res: ServerResponse) => {
215
- const method = req.method ?? "GET";
216
- const url = new URL(req.url ?? "/", hostOrigin || "http://127.0.0.1");
217
-
218
- if (method !== "GET" && method !== "HEAD") {
219
- res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
220
- res.end("method not allowed");
221
- return;
222
- }
223
-
224
- if (url.pathname === "/") {
225
- const bootstrap = {
226
- editorId: currentView.id,
227
- hostOrigin,
228
- viewOrigin,
229
- manifest: currentView.manifest,
230
- };
231
- const html = [
232
- "<!doctype html>",
233
- '<html lang="ko" class="dark">',
234
- "<head>",
235
- '<meta charset="utf-8">',
236
- `<title>${String(currentView.manifest.title ?? currentView.id)} — moloo dev</title>`,
237
- "</head>",
238
- "<body>",
239
- `<script>window.__MOLOO_DEV_HOST__ = ${JSON.stringify(bootstrap).replace(/</g, "\\u003c")};</script>`,
240
- '<script src="/host.js"></script>',
241
- "</body>",
242
- "</html>",
243
- ].join("\n");
244
- res.writeHead(200, {
245
- "content-type": "text/html; charset=utf-8",
246
- "cache-control": "no-store",
247
- });
248
- res.end(method === "HEAD" ? undefined : html);
249
- return;
250
- }
251
-
252
- if (url.pathname === "/host.js") {
253
- res.writeHead(200, {
254
- "content-type": "text/javascript; charset=utf-8",
255
- "cache-control": "no-store",
256
- });
257
- res.end(method === "HEAD" ? undefined : hostScript);
258
- return;
259
- }
260
-
261
172
  if (url.pathname === "/manifest.json") {
173
+ applyOriginPolicy(res, refused);
262
174
  res.writeHead(200, {
263
175
  "content-type": "application/json; charset=utf-8",
264
176
  "cache-control": "no-store",
@@ -268,11 +180,16 @@ export async function startEditorDevServer(
268
180
  }
269
181
 
270
182
  if (url.pathname === "/events") {
183
+ applyOriginPolicy(res, refused);
271
184
  res.writeHead(200, {
272
185
  "content-type": "text/event-stream; charset=utf-8",
273
186
  "cache-control": "no-store",
274
187
  connection: "keep-alive",
275
188
  });
189
+ if (method === "HEAD") {
190
+ res.end();
191
+ return;
192
+ }
276
193
  res.write(`event: connected\ndata: ${JSON.stringify({ build: buildCount })}\n\n`);
277
194
  sseClients.add(res);
278
195
  req.on("close", () => {
@@ -281,27 +198,12 @@ export async function startEditorDevServer(
281
198
  return;
282
199
  }
283
200
 
201
+ applyOriginPolicy(res, refused);
284
202
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
285
- res.end("not found");
203
+ res.end(method === "HEAD" ? undefined : "not found");
286
204
  });
287
205
 
288
- const requestedHostPort = options.hostPort ?? 4300;
289
- const boundHostPort = await listenOnLoopback(hostServer, requestedHostPort);
290
- const requestedViewPort =
291
- options.viewPort ?? (requestedHostPort === 0 ? 0 : boundHostPort + 1);
292
- let boundViewPort: number;
293
- try {
294
- boundViewPort = await listenOnLoopback(viewServer, requestedViewPort);
295
- } catch (err) {
296
- if (options.viewPort === undefined) {
297
- boundViewPort = await listenOnLoopback(viewServer, 0);
298
- } else {
299
- await closeServer(hostServer);
300
- throw err;
301
- }
302
- }
303
-
304
- hostOrigin = `http://127.0.0.1:${String(boundHostPort)}`;
206
+ const boundViewPort = await listenOnLoopback(viewServer, options.viewPort ?? 4310);
305
207
  viewOrigin = `http://127.0.0.1:${String(boundViewPort)}`;
306
208
 
307
209
  function broadcastSse(event: string, payload: unknown): void {
@@ -329,8 +231,7 @@ export async function startEditorDevServer(
329
231
 
330
232
  const srcDir = join(options.packageRoot, "src");
331
233
  if (options.watch !== false && existsSync(srcDir)) {
332
- watcher = watch(srcDir, { recursive: true }, (_event, filename) => {
333
- if (filename && String(filename).includes(".view-css-")) return;
234
+ watcher = watch(srcDir, { recursive: true }, () => {
334
235
  if (watchTimer !== null) clearTimeout(watchTimer);
335
236
  watchTimer = setTimeout(() => {
336
237
  watchTimer = null;
@@ -341,9 +242,7 @@ export async function startEditorDevServer(
341
242
 
342
243
  return {
343
244
  editorId: currentView.id,
344
- hostOrigin,
345
245
  viewOrigin,
346
- hostPort: boundHostPort,
347
246
  viewPort: boundViewPort,
348
247
  rebuild,
349
248
  async close() {
@@ -359,7 +258,7 @@ export async function startEditorDevServer(
359
258
  client.end();
360
259
  }
361
260
  sseClients.clear();
362
- await Promise.all([closeServer(hostServer), closeServer(viewServer)]);
261
+ await closeServer(viewServer);
363
262
  },
364
263
  };
365
264
  }
@@ -0,0 +1,38 @@
1
+ import { getWorkspaceHostUrl } from "@moloodev/platform";
2
+ import { startEditorDevServer, type StartEditorDevServerOptions } from "./dev-server";
3
+ import { resolveWorkspace, type WorkspaceOptions } from "./workspace";
4
+
5
+ async function openBrowser(url: string) {
6
+ const { default: open } = await import("open");
7
+ await open(url, { wait: false });
8
+ }
9
+
10
+ function waitForStop() {
11
+ return new Promise<void>((resolve) => {
12
+ const stop = () => {
13
+ process.off("SIGINT", stop);
14
+ process.off("SIGTERM", stop);
15
+ resolve();
16
+ };
17
+ process.on("SIGINT", stop);
18
+ process.on("SIGTERM", stop);
19
+ });
20
+ }
21
+
22
+ export async function runEditorDev(
23
+ options: StartEditorDevServerOptions & WorkspaceOptions & { workspace?: string },
24
+ deps = { resolve: resolveWorkspace, start: startEditorDevServer, open: openBrowser, stopped: waitForStop },
25
+ ): Promise<number> {
26
+ const slug = await deps.resolve({ ...options, flag: options.workspace });
27
+ const server = await deps.start(options);
28
+ try {
29
+ const destination = new URL(`/w/${encodeURIComponent(slug)}/dev-editor`, getWorkspaceHostUrl("production"));
30
+ destination.searchParams.set("view", server.viewOrigin);
31
+ console.log(`${server.editorId} view origin -> ${server.viewOrigin}`);
32
+ await deps.open(destination.href);
33
+ await deps.stopped();
34
+ } finally {
35
+ await server.close();
36
+ }
37
+ return 0;
38
+ }
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
+ import { findPackageJSON } from "node:module";
3
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import esbuild from "esbuild";
@@ -13,13 +14,10 @@ import { readEditorManifest } from "./read-editor-manifest";
13
14
 
14
15
  export type EditorView = {
15
16
  id: string;
16
- editor: string;
17
17
  packageRoot: string;
18
18
  network: readonly string[];
19
19
  };
20
20
 
21
- const defaultFallback = join(dirname(fileURLToPath(import.meta.url)), "view-page.ts");
22
-
23
21
  export function readPackageEditorView(packageRoot: string): EditorView & { manifest: Record<string, unknown> } {
24
22
  const manifestPath = join(packageRoot, "src/manifest.ts");
25
23
  if (!existsSync(manifestPath)) {
@@ -35,41 +33,16 @@ export function readPackageEditorView(packageRoot: string): EditorView & { manif
35
33
  throw new Error(`Invalid editor id ${manifest.id}`);
36
34
  }
37
35
  const network = parseNetwork("network" in manifest ? manifest.network : undefined);
38
- return { id: manifest.id, editor: parsed.editor, packageRoot, network, manifest };
36
+ return { id: manifest.id, packageRoot, network, manifest };
39
37
  }
40
38
 
41
- export function findEditorViews(repo: string): EditorView[] {
42
- const singleManifest = join(repo, "src/manifest.ts");
43
- if (existsSync(singleManifest)) {
44
- const view = readPackageEditorView(repo);
45
- return [{ id: view.id, editor: view.editor, packageRoot: view.packageRoot, network: view.network }];
46
- }
47
- const dir = join(repo, "editors");
48
- if (!existsSync(dir)) return [];
49
- const found: EditorView[] = [];
50
- for (const name of readdirSync(dir)) {
51
- const packageRoot = join(dir, name);
52
- const manifestPath = join(packageRoot, "src/manifest.ts");
53
- if (!existsSync(manifestPath)) continue;
54
- const manifest: unknown = readEditorManifest(manifestPath);
55
- if (!isRecord(manifest) || typeof manifest.id !== "string") continue;
56
- const parsed = parseEditorId(manifest.id, true);
57
- if (parsed === undefined || parsed.publisher !== "moloo") continue;
58
- const network = parseNetwork("network" in manifest ? manifest.network : undefined);
59
- if (found.some((entry) => entry.id === manifest.id)) throw new Error(`Duplicate editor ${manifest.id}`);
60
- found.push({ id: manifest.id, editor: parsed.editor, packageRoot, network });
61
- }
62
- return found.sort((left, right) => left.editor.localeCompare(right.editor));
63
- }
64
-
65
- export function resolveViewEntry(packageRoot: string, fallback: string = defaultFallback): string {
39
+ export function resolveViewEntry(packageRoot: string): string {
66
40
  const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
67
41
  if (!isRecord(manifest)) throw new Error("Invalid editor package.json");
68
42
  const exports = manifest.exports;
69
- if (exports === undefined || typeof exports === "string") return fallback;
70
- if (!isRecord(exports)) throw new Error("Invalid editor exports");
43
+ if (!isRecord(exports)) throw new Error("Editor package must export ./view");
71
44
  const target = exports["./view"];
72
- if (target === undefined) return fallback;
45
+ if (target === undefined) throw new Error("Editor package must export ./view");
73
46
  if (
74
47
  typeof target !== "string" ||
75
48
  !target.startsWith("./") ||
@@ -122,14 +95,14 @@ export async function buildEditorView(entry: string, output: string, metafile: s
122
95
  writeFileSync(metafile, JSON.stringify(view.metafile));
123
96
  }
124
97
 
125
- export async function buildEditorScript(editor: EditorView, fallback: string = defaultFallback): Promise<string> {
98
+ export async function buildEditorScript(editor: EditorView): Promise<string> {
126
99
  const directory = mkdtempSync(join(tmpdir(), "moloo-editor-"));
127
100
  try {
128
101
  const output = join(directory, "editor.js");
129
102
  const packageStyles = join(editor.packageRoot, "src/styles.css");
130
103
  const styles = existsSync(packageStyles) ? packageStyles : undefined;
131
104
  await buildEditorView(
132
- resolveViewEntry(editor.packageRoot, fallback),
105
+ resolveViewEntry(editor.packageRoot),
133
106
  output,
134
107
  join(directory, "meta.json"),
135
108
  styles,
@@ -151,7 +124,7 @@ export async function buildEditorPackage(
151
124
  const metafilePath = join(outDir, "meta.json");
152
125
  const styles = join(packageRoot, "src/styles.css");
153
126
  await buildEditorView(
154
- resolveViewEntry(packageRoot, defaultFallback),
127
+ resolveViewEntry(packageRoot),
155
128
  scriptPath,
156
129
  metafilePath,
157
130
  existsSync(styles) ? styles : undefined,
@@ -161,20 +134,27 @@ export async function buildEditorPackage(
161
134
  return { id: view.id, scriptPath, manifestPath, metafilePath, script, manifest: view.manifest };
162
135
  }
163
136
 
164
- export async function styleHeader(styles: string, collected: string[], sources: readonly string[] = []): Promise<string> {
165
- const directory = mkdtempSync(join(dirname(styles), ".view-css-"));
137
+ async function styleHeader(styles: string, collected: string[]): Promise<string> {
138
+ const animationPackage = findPackageJSON("tw-animate-css", import.meta.url);
139
+ if (!animationPackage) throw new Error("Missing CLI dependency: tw-animate-css");
140
+ const directory = mkdtempSync(join(tmpdir(), "moloo-view-css-"));
166
141
  try {
167
142
  const entry = join(directory, "input.css");
168
143
  writeFileSync(
169
144
  entry,
170
- [
171
- ...[styles, ...collected].map((file) => `@import ${JSON.stringify(file)};`),
172
- ...sources.map((dir) => `@source ${JSON.stringify(dir)};`),
173
- ].join("\n"),
145
+ [styles, ...collected].map((file) => `@import ${JSON.stringify(file)};`).join("\n"),
174
146
  );
175
147
  const compiled = await build({
176
148
  configFile: false,
177
- root: directory,
149
+ root: dirname(styles),
150
+ cacheDir: join(directory, ".vite"),
151
+ resolve: {
152
+ alias: {
153
+ tailwindcss: dirname(fileURLToPath(import.meta.resolve("tailwindcss/package.json"))),
154
+ "tw-animate-css": dirname(animationPackage),
155
+ "shadcn/tailwind.css": fileURLToPath(import.meta.resolve("shadcn/tailwind.css")),
156
+ },
157
+ },
178
158
  plugins: [tailwind()],
179
159
  logLevel: "error",
180
160
  build: { write: false, cssMinify: false, rollupOptions: { input: entry } },
package/src/index.ts CHANGED
@@ -2,7 +2,6 @@ export {
2
2
  buildEditorPackage,
3
3
  buildEditorScript,
4
4
  buildEditorView,
5
- findEditorViews,
6
5
  readPackageEditorView,
7
6
  resolveViewEntry,
8
7
  type EditorView,
@@ -17,9 +16,7 @@ export {
17
16
  publishEditor,
18
17
  publishEditorPackage,
19
18
  publishEndpoint,
20
- reservedEditorId,
21
19
  uploadEndpoint,
22
20
  } from "./publish";
23
21
  export { readEditorManifest } from "./read-editor-manifest";
24
- export { rewriteThreeLoaders } from "./three-local-loaders";
25
22
  export { runCli } from "./cli";
package/src/oauth.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import type { Tokens } from "./credentials";
5
+
6
+ export class BrowserUnavailable extends Error {}
7
+
8
+ export const CLIENT_ID = "moloo-cli";
9
+ export const SCOPE = "profile email offline_access workspace:read";
10
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
11
+
12
+ export async function oauthPost(issuer: string, path: string, body: Record<string, string>) {
13
+ return fetch(`${issuer}${path}`, {
14
+ method: "POST",
15
+ redirect: "error",
16
+ signal: AbortSignal.timeout(30_000),
17
+ headers: { "content-type": "application/x-www-form-urlencoded" },
18
+ body: new URLSearchParams({ client_id: CLIENT_ID, ...body }),
19
+ });
20
+ }
21
+
22
+ export async function readTokens(response: Response, refreshToken?: string): Promise<Tokens> {
23
+ const value = (await response.json()) as Record<string, unknown>;
24
+ if (
25
+ !response.ok ||
26
+ typeof value.access_token !== "string" ||
27
+ typeof (value.refresh_token ?? refreshToken) !== "string" ||
28
+ typeof value.expires_in !== "number" ||
29
+ !Number.isFinite(value.expires_in) ||
30
+ value.expires_in <= 0 ||
31
+ typeof value.token_type !== "string" ||
32
+ value.token_type.toLowerCase() !== "bearer" ||
33
+ typeof value.scope !== "string"
34
+ ) {
35
+ throw new Error(
36
+ `OAuth token exchange failed (${String(value.error ?? response.status)}). Run moloo login.`,
37
+ );
38
+ }
39
+ return {
40
+ accessToken: value.access_token,
41
+ refreshToken: (value.refresh_token ?? refreshToken) as string,
42
+ expiresAt: Date.now() + value.expires_in * 1000,
43
+ scopes: value.scope.split(" ").filter(Boolean),
44
+ };
45
+ }
46
+
47
+ /** Bind before opening the browser; tolerate unrelated/forged requests without consuming the callback. */
48
+ export async function loopbackCallback(state: string, timeout = 120_000) {
49
+ let accept!: (code: string) => void;
50
+ let fail!: (error: Error) => void;
51
+ const code = new Promise<string>((resolve, reject) => {
52
+ accept = resolve;
53
+ fail = reject;
54
+ });
55
+ // Opening a browser can fail before the caller starts awaiting the code.
56
+ void code.catch(() => {});
57
+ const server = createServer((req, res) => {
58
+ res.setHeader("Cache-Control", "no-store");
59
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
60
+ const received = Buffer.from(url.searchParams.get("state") ?? "");
61
+ const expected = Buffer.from(state);
62
+ if (
63
+ req.method !== "GET" ||
64
+ url.pathname !== "/callback" ||
65
+ received.length !== expected.length ||
66
+ !timingSafeEqual(received, expected)
67
+ ) {
68
+ res.writeHead(400).end("Invalid callback");
69
+ return;
70
+ }
71
+ const error = url.searchParams.get("error");
72
+ const value = url.searchParams.get("code");
73
+ if (error) {
74
+ res.end("Login declined. Return to the terminal.");
75
+ fail(new Error(`OAuth authorization failed: ${error}`));
76
+ } else if (value) {
77
+ res.end("Authorization received. Return to the terminal.");
78
+ accept(value);
79
+ } else {
80
+ res.writeHead(400).end("Missing authorization code");
81
+ }
82
+ });
83
+ await new Promise<void>((resolve, reject) => {
84
+ server.once("error", reject);
85
+ server.listen(0, "127.0.0.1", () => {
86
+ server.off("error", reject);
87
+ resolve();
88
+ });
89
+ });
90
+ const address = server.address();
91
+ if (!address || typeof address === "string") throw new Error("Cannot bind login callback");
92
+ const timer = setTimeout(
93
+ () => fail(new Error("Login timed out. Run moloo login again.")),
94
+ timeout,
95
+ );
96
+ return {
97
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
98
+ code,
99
+ close() {
100
+ clearTimeout(timer);
101
+ server.close();
102
+ server.closeAllConnections();
103
+ },
104
+ };
105
+ }
106
+
107
+ export async function browserLogin(
108
+ issuer: string,
109
+ openBrowser: (url: string) => Promise<unknown>,
110
+ scope = SCOPE,
111
+ ) {
112
+ const verifier = randomBytes(32).toString("base64url");
113
+ const state = randomBytes(32).toString("base64url");
114
+ const callback = await loopbackCallback(state);
115
+ try {
116
+ const url = new URL(`${issuer}/oauth2/authorize`);
117
+ url.search = new URLSearchParams({
118
+ client_id: CLIENT_ID,
119
+ response_type: "code",
120
+ scope,
121
+ redirect_uri: callback.redirectUri,
122
+ state,
123
+ code_challenge_method: "S256",
124
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
125
+ }).toString();
126
+ const opened = openBrowser(url.href).catch(() => {
127
+ throw new BrowserUnavailable("Cannot open browser. Use device login.");
128
+ });
129
+ // Some desktop launchers stay alive for the browser's lifetime.
130
+ await Promise.race([opened, callback.code.then(() => undefined)]);
131
+ return await readTokens(
132
+ await oauthPost(issuer, "/oauth2/token", {
133
+ grant_type: "authorization_code",
134
+ code: await callback.code,
135
+ code_verifier: verifier,
136
+ redirect_uri: callback.redirectUri,
137
+ }),
138
+ );
139
+ } finally {
140
+ callback.close();
141
+ }
142
+ }
143
+
144
+ type DeviceCode = {
145
+ device_code: string;
146
+ user_code: string;
147
+ verification_uri: string;
148
+ expires_in: number;
149
+ interval?: number;
150
+ };
151
+ export async function pollDevice(
152
+ issuer: string,
153
+ device: DeviceCode,
154
+ wait: (ms: number) => Promise<void> = delay,
155
+ now = Date.now,
156
+ ) {
157
+ const deadline = now() + device.expires_in * 1000;
158
+ let interval = Math.max(1, device.interval ?? 5) * 1000;
159
+ while (now() + interval < deadline) {
160
+ await wait(interval);
161
+ const response = await oauthPost(issuer, "/oauth2/token", {
162
+ grant_type: DEVICE_GRANT,
163
+ device_code: device.device_code,
164
+ });
165
+ if (response.ok) return readTokens(response);
166
+ const value = (await response.json()) as { error?: string };
167
+ if (value.error === "slow_down") interval += 5000;
168
+ else if (value.error !== "authorization_pending")
169
+ throw new Error(`Device login failed: ${value.error ?? response.status}`);
170
+ }
171
+ throw new Error("Device code expired. Run moloo login again.");
172
+ }
173
+
174
+ export async function deviceLogin(issuer: string, scope = SCOPE) {
175
+ const response = await oauthPost(issuer, "/device/code", { scope });
176
+ const device = (await response.json()) as DeviceCode;
177
+ if (
178
+ !response.ok ||
179
+ !device.device_code ||
180
+ !device.user_code ||
181
+ !device.verification_uri ||
182
+ !Number.isFinite(device.expires_in) ||
183
+ device.expires_in <= 0 ||
184
+ (device.interval !== undefined && (!Number.isFinite(device.interval) || device.interval <= 0))
185
+ ) {
186
+ throw new Error("Cannot request a device code");
187
+ }
188
+ console.log(`Open ${device.verification_uri}\nEnter code: ${device.user_code}`);
189
+ return pollDevice(issuer, device);
190
+ }