@jxsuite/server 4.4.5 → 4.5.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/src/studio-api.ts CHANGED
@@ -40,8 +40,9 @@ import {
40
40
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
41
41
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
42
42
  import type { ClassJsonDef } from "./types.ts";
43
- import type { DesignOptions } from "@jxsuite/create/generate";
44
- import type { ProjectConfig } from "@jxsuite/schema/types";
43
+ import type { DesignOptions, ProjectOptions } from "@jxsuite/create/generate";
44
+ import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
45
+ import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
45
46
  import { problem } from "./problem.ts";
46
47
 
47
48
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
@@ -55,13 +56,13 @@ const fwd = (p: string) => p.replaceAll("\\", "/");
55
56
  const expandTilde = (p: string) =>
56
57
  p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
57
58
 
59
+ /** The `package.json` members these routes read. */
58
60
  interface PackageJson {
59
61
  name?: string;
60
- workspaces?: unknown;
62
+ workspaces?: JsonValue;
61
63
  dependencies?: Record<string, string>;
62
64
  devDependencies?: Record<string, string>;
63
65
  customElements?: string;
64
- [key: string]: unknown;
65
66
  }
66
67
 
67
68
  /** A custom-element-manifest declaration (subset consumed here). */
@@ -69,17 +70,17 @@ interface CemDeclaration {
69
70
  customElement?: boolean;
70
71
  tagName?: string;
71
72
  description?: string;
72
- cssProperties?: unknown[];
73
- events?: unknown[];
74
- slots?: unknown[];
75
- members?: { kind?: string; privacy?: string; [key: string]: unknown }[];
73
+ cssProperties?: JsonValue[];
74
+ events?: JsonValue[];
75
+ slots?: JsonValue[];
76
+ /** Relayed verbatim once filtered on `kind` and `privacy`. */
77
+ members?: JsonObject[];
76
78
  attributes?: {
77
79
  name?: string;
78
- default?: unknown;
80
+ default?: JsonValue;
79
81
  description?: string;
80
82
  type?: { text?: string };
81
83
  }[];
82
- [key: string]: unknown;
83
84
  }
84
85
 
85
86
  interface CemModule {
@@ -91,15 +92,6 @@ interface Cem {
91
92
  modules?: CemModule[];
92
93
  }
93
94
 
94
- /** A parsed Jx component document (subset consumed by component discovery). */
95
- interface ComponentDoc {
96
- $id?: string;
97
- tagName?: string;
98
- $elements?: unknown[];
99
- state?: Record<string, unknown>;
100
- [key: string]: unknown;
101
- }
102
-
103
95
  // ─── Extension registry (per project root, invalidated on project.json change) ──
104
96
 
105
97
  const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
@@ -117,6 +109,8 @@ async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegis
117
109
  let projectConfig: ProjectConfig | undefined;
118
110
  try {
119
111
  mtime = statSync(projectJsonPath).mtimeMs;
112
+ /* SAFETY: buildProjectExtensionRegistry reads only `projectConfig?.extensions` and validates
113
+ that list itself; every other ProjectConfig member is optional and unread on this path. */
120
114
  projectConfig = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
121
115
  } catch {
122
116
  projectConfig = undefined;
@@ -206,16 +200,6 @@ export function isOwnedProjectDir(dir: string): boolean {
206
200
  return existsSync(resolve(dir, "project.json"));
207
201
  }
208
202
 
209
- const statusMap: Record<string, string> = {
210
- A: "A",
211
- C: "C",
212
- D: "D",
213
- M: "M",
214
- R: "R",
215
- T: "T",
216
- U: "U",
217
- };
218
-
219
203
  /**
220
204
  * Parse raw `git status --porcelain=v2 --branch` output into structured data.
221
205
  *
@@ -262,18 +246,19 @@ export function parseGitStatus(out: string) {
262
246
  } else {
263
247
  filePath = parts.slice(8).join(" ");
264
248
  }
249
+ // Porcelain v2 XY codes (A, C, D, M, R, T, U, ...) are reported verbatim; "." means unchanged.
265
250
  if (stagedCode !== ".") {
266
251
  files.push({
267
252
  path: filePath,
268
253
  staged: true,
269
- status: statusMap[stagedCode] || stagedCode,
254
+ status: stagedCode,
270
255
  });
271
256
  }
272
257
  if (unstagedCode !== ".") {
273
258
  files.push({
274
259
  path: filePath,
275
260
  staged: false,
276
- status: statusMap[unstagedCode] || unstagedCode,
261
+ status: unstagedCode,
277
262
  });
278
263
  }
279
264
  } else if (line.startsWith("? ")) {
@@ -284,6 +269,33 @@ export function parseGitStatus(out: string) {
284
269
  return { ahead, behind, branch, files };
285
270
  }
286
271
 
272
+ /** An imported plugin module, or its default export, read by export name. */
273
+ interface PluginModule {
274
+ readonly [exportName: string]: PluginExport;
275
+ }
276
+
277
+ /** A function a plugin module exports: the plugin class, whose static `schema` the studio renders. */
278
+ interface PluginClass extends PluginModule {
279
+ (...args: never[]): void;
280
+ }
281
+
282
+ /**
283
+ * What schema discovery can meet under an export name, spelled out by kind. Only a function is used
284
+ * (as the plugin class); an object is searched one level, as a default export; every other kind of
285
+ * value is a miss.
286
+ */
287
+ type PluginExport = PluginClass | PluginModule | JsonPrimitive | bigint | symbol | undefined;
288
+
289
+ /** Whether an export is a function, and so a candidate plugin class. */
290
+ function isPluginClass(value: PluginExport): value is PluginClass {
291
+ return isFunction(value);
292
+ }
293
+
294
+ /** The body of `POST /__studio/git/push`; an absent or unparsable body means a plain push. */
295
+ interface PushBody {
296
+ setUpstream?: boolean;
297
+ }
298
+
287
299
  /** Host hooks for the routes that reach outside the server root (project creation). */
288
300
  export interface StudioApiOptions {
289
301
  /** Extra roots a new project may be created under, mirroring `createDevServer.allowedRoots`. */
@@ -357,6 +369,27 @@ function byPathOrder<T extends { path: string }>(entries: T[]): T[] {
357
369
  return entries.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
358
370
  }
359
371
 
372
+ /**
373
+ * A git command that exited non-zero, carrying both output streams (git splits failures across
374
+ * them).
375
+ */
376
+ class GitCommandError extends Error {
377
+ readonly stderr: string;
378
+ readonly stdout: string;
379
+
380
+ constructor(message: string, stderr: string, stdout: string) {
381
+ super(message);
382
+ this.name = "GitCommandError";
383
+ this.stderr = stderr;
384
+ this.stdout = stdout;
385
+ }
386
+ }
387
+
388
+ /** True when a failed filesystem call failed because the path does not exist (`ENOENT`). */
389
+ function isNotFound(cause: unknown): boolean {
390
+ return isNonNullObject(cause) && "code" in cause && cause.code === "ENOENT";
391
+ }
392
+
360
393
  export async function handleStudioApi(
361
394
  req: Request,
362
395
  url: URL,
@@ -375,6 +408,9 @@ export async function handleStudioApi(
375
408
  // Project metadata
376
409
  if (path === "/__studio/project" && req.method === "GET") {
377
410
  try {
411
+ /* SAFETY: only `name` and `workspaces` are read, each behind a `??` fallback; a file whose
412
+ JSON is not an object yields undefined members (or throws on null) and answers the same
413
+ fallback shape as the catch below. */
378
414
  const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")) as PackageJson;
379
415
  return Response.json({
380
416
  name: pkg.name ?? basename(root),
@@ -419,9 +455,14 @@ export async function handleStudioApi(
419
455
  let isSiteProject = false;
420
456
  let projectConfig: ProjectConfig | null = null;
421
457
  try {
422
- const raw = JSON.parse(await readFile(resolve(absDir, "project.json"), "utf8")) as unknown;
423
- if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
458
+ // SAFETY: JSON.parse without a reviver only ever produces JSON values.
459
+ const raw = JSON.parse(
460
+ await readFile(resolve(absDir, "project.json"), "utf8"),
461
+ ) as JsonValue;
462
+ if (isJsonObject(raw)) {
424
463
  isSiteProject = true;
464
+ /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the config
465
+ is only relayed to the client here, never interpreted. */
425
466
  projectConfig = raw as ProjectConfig;
426
467
  }
427
468
  } catch {}
@@ -462,6 +503,8 @@ export async function handleStudioApi(
462
503
  while (dir) {
463
504
  const candidate = resolve(dir, "project.json");
464
505
  if (existsSync(candidate)) {
506
+ /* SAFETY: the config is relayed to the client verbatim and not interpreted here, so its
507
+ member types are the client's to check; malformed JSON throws into the catch below. */
465
508
  const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
466
509
  const relPath = fwd(dir);
467
510
  const fileRelPath = fwd(relative(dir, absFile));
@@ -595,9 +638,12 @@ export async function handleStudioApi(
595
638
  }
596
639
  const fp = resolve(root, match);
597
640
  try {
598
- const raw = JSON.parse(await readFile(fp, "utf8")) as unknown;
599
- if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
641
+ // SAFETY: JSON.parse without a reviver only ever produces JSON values.
642
+ const raw = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
643
+ if (isJsonObject(raw)) {
600
644
  const projectDir = fwd(dirname(fp));
645
+ /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the site
646
+ list only relays the config to the client. */
601
647
  sites.push({ config: raw as ProjectConfig, path: projectDir });
602
648
  }
603
649
  } catch {}
@@ -611,6 +657,12 @@ export async function handleStudioApi(
611
657
  // Create a new project
612
658
  if (path === "/__studio/create-project" && req.method === "POST") {
613
659
  try {
660
+ /* SAFETY: trust boundary. The only client is Studio's New Project modal, which sends these
661
+ members with these types. `name`, `directory` and `destination` are checked below before
662
+ any filesystem use, and `template` goes through isTemplateId. `description`, `url`,
663
+ `adapter`, `starter` and `design` are relayed to generateProject unchecked (a falsy
664
+ `adapter` is dropped so the static default applies), and it writes them into the scaffold
665
+ as given. */
614
666
  const body = (await req.json()) as {
615
667
  name?: string;
616
668
  description?: string;
@@ -661,16 +713,29 @@ export async function handleStudioApi(
661
713
  const destPath = resolve(destination.parent, directory);
662
714
 
663
715
  const { generateProject } = await import("@jxsuite/create/generate");
664
- await generateProject(destPath, {
665
- name,
666
- ...(adapter !== undefined ? { adapter } : {}),
667
- ...(description !== undefined ? { description } : {}),
668
- ...(siteUrl !== undefined ? { url: siteUrl } : {}),
669
- ...(starter !== undefined ? { starter } : {}),
670
- ...(template !== undefined && isTemplateId(template) ? { template } : {}),
671
- ...(design !== undefined ? { design } : {}),
672
- });
716
+ const projectOptions: ProjectOptions = { name };
717
+ if (adapter) {
718
+ projectOptions.adapter = adapter;
719
+ }
720
+ if (description !== undefined) {
721
+ projectOptions.description = description;
722
+ }
723
+ if (siteUrl !== undefined) {
724
+ projectOptions.url = siteUrl;
725
+ }
726
+ if (starter !== undefined) {
727
+ projectOptions.starter = starter;
728
+ }
729
+ if (template !== undefined && isTemplateId(template)) {
730
+ projectOptions.template = template;
731
+ }
732
+ if (design !== undefined) {
733
+ projectOptions.design = design;
734
+ }
735
+ await generateProject(destPath, projectOptions);
673
736
 
737
+ /* SAFETY: generateProject just wrote this project.json from its own template, so it is a
738
+ project config; it is relayed to the client, not interpreted here. */
674
739
  const config = JSON.parse(
675
740
  await readFile(resolve(destPath, "project.json"), "utf8"),
676
741
  ) as ProjectConfig;
@@ -784,16 +849,20 @@ export async function handleStudioApi(
784
849
  }
785
850
  const fp = resolve(scanRoot, match);
786
851
  try {
787
- let content: ComponentDoc;
852
+ let content: JsonValue;
788
853
  if (match.endsWith(".json")) {
789
- content = JSON.parse(await readFile(fp, "utf8")) as ComponentDoc;
854
+ // SAFETY: JSON.parse without a reviver only ever produces JSON values.
855
+ content = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
790
856
  } else {
791
857
  const entry = registry.byExtension(extname(match), "parse");
792
858
  if (!entry) {
793
859
  continue;
794
860
  }
795
861
  const source = await readFile(fp, "utf8");
796
- content = (await entry.call("parse", source)) as ComponentDoc;
862
+ const parsed = await entry.call("parse", source);
863
+ /* Only a document object can declare a component, and componentMetaFrom answers null
864
+ for anything else, so loaded entries reach it as null rather than as a document. */
865
+ content = isJsonObject(parsed) ? parsed : null;
797
866
  }
798
867
  /* The metadata rules live in @jxsuite/schema/component-meta because THREE backends need
799
868
  the same answer — this one, the desktop session, and now the cloud adapter, which can
@@ -811,6 +880,9 @@ export async function handleStudioApi(
811
880
  try {
812
881
  const projectPkgPath = resolve(scanRoot, "package.json");
813
882
  if (existsSync(projectPkgPath)) {
883
+ /* SAFETY: only the keys of the dependency maps are used, each as a package name that must
884
+ resolve to node_modules/<name>/package.json before anything else is read, so a name
885
+ that does not is skipped; a throw skips this whole scan. */
814
886
  const pkg = JSON.parse(await readFile(projectPkgPath, "utf8")) as PackageJson;
815
887
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
816
888
  for (const name of Object.keys(deps)) {
@@ -836,6 +908,8 @@ export async function handleStudioApi(
836
908
  if (!actualPath) {
837
909
  continue;
838
910
  }
911
+ /* SAFETY: `customElements` is the one member read; npm defines it as the manifest
912
+ path, and a non-string value makes resolve() throw into this package's catch. */
839
913
  const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
840
914
  if (!depPkg.customElements) {
841
915
  continue;
@@ -844,6 +918,9 @@ export async function handleStudioApi(
844
918
  if (!existsSync(cemPath)) {
845
919
  continue;
846
920
  }
921
+ /* SAFETY: the package names this file as its custom-elements manifest, and every
922
+ member read below has a fallback; a manifest of another shape either throws into
923
+ this package's catch or contributes no declarations. */
847
924
  const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
848
925
  for (const mod of cem.modules || []) {
849
926
  for (const decl of mod.declarations || []) {
@@ -912,12 +989,9 @@ export async function handleStudioApi(
912
989
  // First would mean every asset 404s for as long as the build takes.
913
990
  const result = await buildSite(dir, { clean: false, verbose: false });
914
991
  const preview = startSitePreview(dir);
915
- return Response.json({
916
- errors: result.errors,
917
- files: result.files,
918
- routes: result.routes,
919
- ...(preview ? { url: preview.origin } : {}),
920
- });
992
+ const built = { errors: result.errors, files: result.files, routes: result.routes };
993
+ // `url` follows the build fields, and only when a preview origin exists.
994
+ return Response.json(preview ? { ...built, url: preview.origin } : built);
921
995
  } catch (error) {
922
996
  return problem("internalError", errorMessage(error));
923
997
  }
@@ -943,6 +1017,8 @@ export async function handleStudioApi(
943
1017
  }
944
1018
  let body: { route?: string };
945
1019
  try {
1020
+ /* SAFETY: `route` is the only member read, and only when truthy; it is relayed verbatim in
1021
+ the preview tab's `navigate` event, so nothing here depends on its runtime type. */
946
1022
  body = (await req.json()) as { route?: string };
947
1023
  } catch {
948
1024
  return problem("invalidRequest", "Invalid JSON body");
@@ -976,19 +1052,21 @@ export async function handleStudioApi(
976
1052
  const dir = activeProjectRoot ?? root;
977
1053
  let body: { path?: string; contents?: string };
978
1054
  try {
1055
+ /* SAFETY: both members are re-checked with isString below before either reaches the
1056
+ overlay store. */
979
1057
  body = (await req.json()) as { path?: string; contents?: string };
980
1058
  } catch {
981
1059
  return problem("invalidRequest", "Invalid JSON body");
982
1060
  }
983
1061
  if (req.method === "POST") {
984
- if (typeof body.path !== "string" || typeof body.contents !== "string") {
1062
+ if (!isString(body.path) || !isString(body.contents)) {
985
1063
  return problem("invalidRequest", "Missing path or contents");
986
1064
  }
987
1065
  setLivePreviewOverlay(dir, body.path, body.contents);
988
1066
  return new Response(null, { status: 204 });
989
1067
  }
990
1068
  if (req.method === "DELETE") {
991
- clearLivePreviewOverlay(dir, typeof body.path === "string" ? body.path : undefined);
1069
+ clearLivePreviewOverlay(dir, isString(body.path) ? body.path : undefined);
992
1070
  return new Response(null, { status: 204 });
993
1071
  }
994
1072
  }
@@ -1004,6 +1082,8 @@ export async function handleStudioApi(
1004
1082
  if (!existsSync(pkgPath)) {
1005
1083
  return Response.json([]);
1006
1084
  }
1085
+ /* SAFETY: npm and bun write `dependencies` / `devDependencies` as name-to-range string maps,
1086
+ which is all this route reads; a package.json that is not JSON throws into the catch. */
1007
1087
  const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
1008
1088
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1009
1089
  const packages: {
@@ -1025,6 +1105,8 @@ export async function handleStudioApi(
1025
1105
  continue;
1026
1106
  }
1027
1107
  try {
1108
+ /* SAFETY: `customElements` is the one member read, and it is relayed verbatim or tested
1109
+ for truthiness; malformed JSON skips the package in the catch. */
1028
1110
  const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
1029
1111
  packages.push({
1030
1112
  customElementsPath: depPkg.customElements || null,
@@ -1060,6 +1142,8 @@ export async function handleStudioApi(
1060
1142
  if (!actualPath) {
1061
1143
  return Response.json({ cem: null });
1062
1144
  }
1145
+ /* SAFETY: `customElements` is the one member read; npm defines it as the manifest path, and
1146
+ a non-string value makes resolve() throw into the catch below. */
1063
1147
  const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
1064
1148
  if (!depPkg.customElements) {
1065
1149
  return Response.json({ cem: null });
@@ -1068,7 +1152,9 @@ export async function handleStudioApi(
1068
1152
  if (!existsSync(cemPath)) {
1069
1153
  return Response.json({ cem: null });
1070
1154
  }
1071
- const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
1155
+ /* SAFETY: JSON.parse without a reviver only ever produces JSON values, and the manifest is
1156
+ relayed verbatim, so it needs decoding no further than that. */
1157
+ const cem = JSON.parse(await readFile(cemPath, "utf8")) as JsonValue;
1072
1158
  return Response.json({ cem });
1073
1159
  } catch (error) {
1074
1160
  return problem("internalError", errorMessage(error));
@@ -1078,9 +1164,12 @@ export async function handleStudioApi(
1078
1164
  // Add an npm package
1079
1165
  if (path === "/__studio/packages/add" && req.method === "POST") {
1080
1166
  try {
1167
+ /* SAFETY: `name` reaches argv, so it is re-checked with isString below; `dir` is only
1168
+ path-resolved and `dev` only tested for truthiness, and a wrongly typed `dir` throws in
1169
+ isAbsolute inside this try and answers 500. */
1081
1170
  const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
1082
1171
  const { name } = body;
1083
- if (!name || typeof name !== "string") {
1172
+ if (!name || !isString(name)) {
1084
1173
  return problem("invalidRequest", "Missing name");
1085
1174
  }
1086
1175
  const dir = body.dir || activeProjectRoot;
@@ -1108,9 +1197,11 @@ export async function handleStudioApi(
1108
1197
  // Remove an npm package
1109
1198
  if (path === "/__studio/packages/remove" && req.method === "POST") {
1110
1199
  try {
1200
+ /* SAFETY: `name` reaches argv, so it is re-checked with isString below; a wrongly typed
1201
+ `dir` throws in isAbsolute inside this try and answers 500. */
1111
1202
  const body = (await req.json()) as { name?: string; dir?: string };
1112
1203
  const { name } = body;
1113
- if (!name || typeof name !== "string") {
1204
+ if (!name || !isString(name)) {
1114
1205
  return problem("invalidRequest", "Missing name");
1115
1206
  }
1116
1207
  const dir = body.dir || activeProjectRoot;
@@ -1134,6 +1225,8 @@ export async function handleStudioApi(
1134
1225
  // Install all dependencies (bun install)
1135
1226
  if (path === "/__studio/packages/install" && req.method === "POST") {
1136
1227
  try {
1228
+ /* SAFETY: `dir` is the only member read and is only path-resolved; a wrongly typed `dir`
1229
+ throws in isAbsolute inside this try and answers 500. */
1137
1230
  const body = (await req.json().catch(() => ({}))) as { dir?: string };
1138
1231
  const dir = body.dir || activeProjectRoot || root;
1139
1232
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
@@ -1164,6 +1257,9 @@ export async function handleStudioApi(
1164
1257
  // Set version ranges for one or more packages, then reinstall
1165
1258
  if (path === "/__studio/packages/set-versions" && req.method === "POST") {
1166
1259
  try {
1260
+ /* SAFETY: `updates` is checked to be an array below and each entry is Studio's own
1261
+ `{ name, version, dev }` triple from the versions panel; a wrongly typed `dir` throws in
1262
+ isAbsolute inside this try and answers 500. */
1167
1263
  const body = (await req.json()) as {
1168
1264
  dir?: string;
1169
1265
  updates?: { name: string; version: string; dev?: boolean }[];
@@ -1200,7 +1296,7 @@ export async function handleStudioApi(
1200
1296
  { headers: { "Cache-Control": "public, max-age=5" } },
1201
1297
  );
1202
1298
  } catch (error) {
1203
- return (error as NodeJS.ErrnoException).code === "ENOENT"
1299
+ return isNotFound(error)
1204
1300
  ? problem("notFound", "Not found")
1205
1301
  : problem("internalError", errorMessage(error));
1206
1302
  }
@@ -1292,7 +1388,7 @@ export async function handleStudioApi(
1292
1388
  await unlink(abs);
1293
1389
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1294
1390
  } catch (error) {
1295
- return (error as NodeJS.ErrnoException).code === "ENOENT"
1391
+ return isNotFound(error)
1296
1392
  ? problem("notFound", "Not found")
1297
1393
  : problem("internalError", errorMessage(error));
1298
1394
  }
@@ -1302,6 +1398,8 @@ export async function handleStudioApi(
1302
1398
  if (path === "/__studio/file/rename" && req.method === "POST") {
1303
1399
  let body: { from?: string; to?: string };
1304
1400
  try {
1401
+ /* SAFETY: both members are truthiness-checked below and then path-resolved, where a
1402
+ non-string throws before any filesystem call is made. */
1305
1403
  body = (await req.json()) as { from?: string; to?: string };
1306
1404
  } catch {
1307
1405
  return problem("invalidRequest", "Invalid JSON");
@@ -1386,6 +1484,8 @@ export async function handleStudioApi(
1386
1484
  if (path === "/__studio/locate" && req.method === "POST") {
1387
1485
  let body: { name?: string };
1388
1486
  try {
1487
+ /* SAFETY: `name` is truthiness-checked below and then interpolated into a glob pattern, so
1488
+ a non-string value is stringified the same way a template literal always would. */
1389
1489
  body = (await req.json()) as { name?: string };
1390
1490
  } catch {
1391
1491
  return problem("invalidRequest", "Invalid JSON");
@@ -1405,13 +1505,13 @@ export async function handleStudioApi(
1405
1505
  }
1406
1506
  matches.push(fwd(match));
1407
1507
  }
1408
- if (matches.length === 0) {
1508
+ const [first] = matches;
1509
+ if (first === undefined) {
1409
1510
  return Response.json({ path: null });
1410
1511
  }
1411
- return Response.json({
1412
- path: matches[0],
1413
- ...(matches.length > 1 ? { alternatives: matches } : {}),
1414
- });
1512
+ return Response.json(
1513
+ matches.length > 1 ? { path: first, alternatives: matches } : { path: first },
1514
+ );
1415
1515
  } catch (error) {
1416
1516
  return problem("internalError", errorMessage(error));
1417
1517
  }
@@ -1433,8 +1533,11 @@ export async function handleStudioApi(
1433
1533
  if (!cfToken) {
1434
1534
  return problem("unauthorized", "Missing X-CF-Token header");
1435
1535
  }
1436
- let payload: { path?: string; method?: string; body?: unknown };
1536
+ let payload: { path?: string; method?: string; body?: JsonValue };
1437
1537
  try {
1538
+ /* SAFETY: `path` is matched against CF_PROXY_ALLOWLIST before use, `method` goes to fetch,
1539
+ which converts and validates it per the Fetch standard, and `body` is re-serialized with
1540
+ JSON.stringify, so it only ever needs to be the JSON value it was parsed as. */
1438
1541
  payload = (await req.json()) as typeof payload;
1439
1542
  } catch {
1440
1543
  return problem("invalidRequest", "Invalid JSON body");
@@ -1444,14 +1547,16 @@ export async function handleStudioApi(
1444
1547
  return problem("forbidden", `Path not allowed: ${apiPath}`);
1445
1548
  }
1446
1549
  const method = payload.method ?? "GET";
1447
- const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, {
1448
- method,
1449
- headers: {
1450
- Authorization: `Bearer ${cfToken}`,
1451
- ...(payload.body === undefined ? {} : { "Content-Type": "application/json" }),
1452
- },
1453
- ...(payload.body === undefined ? {} : { body: JSON.stringify(payload.body) }),
1454
- });
1550
+ const authorization = `Bearer ${cfToken}`;
1551
+ const init: RequestInit =
1552
+ payload.body === undefined
1553
+ ? { method, headers: { Authorization: authorization } }
1554
+ : {
1555
+ method,
1556
+ headers: { Authorization: authorization, "Content-Type": "application/json" },
1557
+ body: JSON.stringify(payload.body),
1558
+ };
1559
+ const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, init);
1455
1560
  return new Response(upstream.body, {
1456
1561
  headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" },
1457
1562
  status: upstream.status,
@@ -1462,12 +1567,15 @@ export async function handleStudioApi(
1462
1567
  // Dispatches parse/serialize through the project's format registry by import name.
1463
1568
  if (path === "/__studio/format" && req.method === "POST") {
1464
1569
  try {
1570
+ /* SAFETY: `format` and `action` are checked below (`action` against the two capability
1571
+ names), and `source`, `doc` and `options` are handed to the format's own parse/serialize,
1572
+ which owns their validation; any throw answers 500 from this try. */
1465
1573
  const body = (await req.json()) as {
1466
1574
  format?: string;
1467
1575
  action?: string;
1468
1576
  source?: string;
1469
- doc?: Record<string, unknown>;
1470
- options?: Record<string, unknown>;
1577
+ doc?: JsonObject;
1578
+ options?: JsonObject;
1471
1579
  dir?: string;
1472
1580
  };
1473
1581
  const { format, action, source, doc, options } = body;
@@ -1608,6 +1716,8 @@ export async function handleStudioApi(
1608
1716
  if (moduleAbsPath.endsWith(".class.json")) {
1609
1717
  try {
1610
1718
  const content = readFileSync(moduleAbsPath, "utf8");
1719
+ /* SAFETY: every ClassJsonDef member is optional and extractStudioSchema guards or relays
1720
+ each one it reads; a non-object file throws into the catch below or yields no members. */
1611
1721
  const classDef = JSON.parse(content) as ClassJsonDef;
1612
1722
  return Response.json({
1613
1723
  schema: extractStudioSchema(classDef, moduleAbsPath),
@@ -1626,6 +1736,8 @@ export async function handleStudioApi(
1626
1736
  if (existsSync(classJsonPath)) {
1627
1737
  try {
1628
1738
  const content = readFileSync(classJsonPath, "utf8");
1739
+ /* SAFETY: every ClassJsonDef member is optional and extractStudioSchema guards or relays
1740
+ each one it reads; a malformed file throws and falls through to the module import. */
1629
1741
  const classDef = JSON.parse(content) as ClassJsonDef;
1630
1742
  return Response.json({
1631
1743
  schema: extractStudioSchema(classDef, classJsonPath),
@@ -1637,18 +1749,20 @@ export async function handleStudioApi(
1637
1749
 
1638
1750
  // Fallback: import JS module (backwards compat for classes without .class.json)
1639
1751
  try {
1640
- const mod = (await import(moduleAbsPath)) as {
1641
- default?: Record<string, unknown>;
1642
- [key: string]: unknown;
1643
- };
1644
- const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
1645
- if (typeof ExportedClass !== "function") {
1752
+ /* SAFETY: import() only ever resolves to a module namespace object, and PluginExport names
1753
+ every kind of value (primitive, function, object) a string-keyed member can hold. */
1754
+ const mod = (await import(moduleAbsPath)) as PluginModule;
1755
+ const fallback = mod.default;
1756
+ const ExportedClass =
1757
+ mod[exportName] ??
1758
+ (isNonNullObject(fallback) || isPluginClass(fallback) ? fallback[exportName] : undefined);
1759
+ if (!isPluginClass(ExportedClass)) {
1646
1760
  return Response.json({
1647
1761
  error: `Export "${exportName}" not found`,
1648
1762
  schema: null,
1649
1763
  });
1650
1764
  }
1651
- return Response.json({ schema: (ExportedClass as { schema?: unknown }).schema ?? null });
1765
+ return Response.json({ schema: ExportedClass.schema ?? null });
1652
1766
  } catch (error) {
1653
1767
  return Response.json({
1654
1768
  error: errorMessage(error),
@@ -1680,10 +1794,11 @@ export async function handleStudioApi(
1680
1794
  * one failure this API publishes a shape for into a contentless message, and the 409 the
1681
1795
  * route table promises could never be produced.
1682
1796
  */
1683
- throw Object.assign(new Error(stderr || stdout || `git exited with ${exitCode}`), {
1797
+ throw new GitCommandError(
1798
+ stderr || stdout || `git exited with ${exitCode}`,
1684
1799
  stderr,
1685
1800
  stdout,
1686
- });
1801
+ );
1687
1802
  }
1688
1803
  return stdout;
1689
1804
  };
@@ -1727,6 +1842,9 @@ export async function handleStudioApi(
1727
1842
  }
1728
1843
 
1729
1844
  if (gitCmd === "add-remote" && req.method === "POST") {
1845
+ /* SAFETY: both members are truthiness-checked below and reach git as their own argv
1846
+ entries with no shell; Bun.spawn stringifies a non-string entry, so a wrong type can only
1847
+ become a literal remote name or URL, which git validates and refuses or records. */
1730
1848
  const { name, url: remoteUrl } = (await req.json()) as {
1731
1849
  name?: string;
1732
1850
  url?: string;
@@ -1770,6 +1888,9 @@ export async function handleStudioApi(
1770
1888
  }
1771
1889
 
1772
1890
  if (gitCmd === "stage" && req.method === "POST") {
1891
+ /* SAFETY: `files` is checked to be a non-empty array below; each entry is screened with
1892
+ `.includes("..")` (an entry without that method throws inside this try) and reaches git
1893
+ after `--`, where it can only be read as a pathspec. */
1773
1894
  const { files } = (await req.json()) as { files?: string[] };
1774
1895
  if (!Array.isArray(files) || files.length === 0) {
1775
1896
  return problem("invalidRequest", "Missing files");
@@ -1784,6 +1905,8 @@ export async function handleStudioApi(
1784
1905
  }
1785
1906
 
1786
1907
  if (gitCmd === "unstage" && req.method === "POST") {
1908
+ /* SAFETY: `files` is checked to be a non-empty array below, and its entries reach git
1909
+ after `--`, so they can only ever be read as pathspecs. */
1787
1910
  const { files } = (await req.json()) as { files?: string[] };
1788
1911
  if (!Array.isArray(files) || files.length === 0) {
1789
1912
  return problem("invalidRequest", "Missing files");
@@ -1793,8 +1916,9 @@ export async function handleStudioApi(
1793
1916
  }
1794
1917
 
1795
1918
  if (gitCmd === "commit" && req.method === "POST") {
1919
+ // SAFETY: `message` is re-checked with isString below before it reaches argv.
1796
1920
  const { message } = (await req.json()) as { message?: string };
1797
- if (!message || typeof message !== "string") {
1921
+ if (!message || !isString(message)) {
1798
1922
  return problem("invalidRequest", "Missing message");
1799
1923
  }
1800
1924
  const statusOut = await runGit(["status", "--porcelain"]);
@@ -1808,9 +1932,10 @@ export async function handleStudioApi(
1808
1932
  }
1809
1933
 
1810
1934
  if (gitCmd === "push" && req.method === "POST") {
1811
- let body: { setUpstream?: boolean } = {};
1935
+ let body: PushBody = {};
1812
1936
  try {
1813
- body = (await req.json()) as { setUpstream?: boolean };
1937
+ // SAFETY: `setUpstream` is the only member read, and only for truthiness.
1938
+ body = (await req.json()) as PushBody;
1814
1939
  } catch {}
1815
1940
  const { setUpstream } = body;
1816
1941
  if (setUpstream) {
@@ -1835,8 +1960,10 @@ export async function handleStudioApi(
1835
1960
  try {
1836
1961
  await runGit(["pull"]);
1837
1962
  } catch (error) {
1838
- const output = error as { stdout?: string; stderr?: string };
1839
- const conflicts = conflictingPaths(`${output.stdout ?? ""}\n${output.stderr ?? ""}`);
1963
+ const conflicts =
1964
+ error instanceof GitCommandError
1965
+ ? conflictingPaths(`${error.stdout}\n${error.stderr}`)
1966
+ : [];
1840
1967
  if (conflicts.length === 0) {
1841
1968
  throw error;
1842
1969
  }
@@ -1855,8 +1982,9 @@ export async function handleStudioApi(
1855
1982
  }
1856
1983
 
1857
1984
  if (gitCmd === "checkout" && req.method === "POST") {
1985
+ // SAFETY: `branch` is re-checked with isString below before it reaches argv.
1858
1986
  const { branch } = (await req.json()) as { branch?: string };
1859
- if (!branch || typeof branch !== "string") {
1987
+ if (!branch || !isString(branch)) {
1860
1988
  return problem("invalidRequest", "Missing branch");
1861
1989
  }
1862
1990
  await runGit(["checkout", branch]);
@@ -1864,8 +1992,9 @@ export async function handleStudioApi(
1864
1992
  }
1865
1993
 
1866
1994
  if (gitCmd === "create-branch" && req.method === "POST") {
1995
+ // SAFETY: `name` is re-checked with isString below before it reaches argv.
1867
1996
  const { name } = (await req.json()) as { name?: string };
1868
- if (!name || typeof name !== "string") {
1997
+ if (!name || !isString(name)) {
1869
1998
  return problem("invalidRequest", "Missing name");
1870
1999
  }
1871
2000
  await runGit(["checkout", "-b", name]);
@@ -1904,6 +2033,9 @@ export async function handleStudioApi(
1904
2033
  }
1905
2034
 
1906
2035
  if (gitCmd === "discard" && req.method === "POST") {
2036
+ /* SAFETY: `files` is checked to be a non-empty array below; each entry is screened with
2037
+ `.includes("..")` (an entry without that method throws inside this try) and reaches git
2038
+ after `--`, where it can only be read as a pathspec. */
1907
2039
  const { files } = (await req.json()) as { files?: string[] };
1908
2040
  if (!Array.isArray(files) || files.length === 0) {
1909
2041
  return problem("invalidRequest", "Missing files");
@@ -1918,8 +2050,9 @@ export async function handleStudioApi(
1918
2050
  }
1919
2051
 
1920
2052
  if (gitCmd === "clone" && req.method === "POST") {
2053
+ // SAFETY: `url` is re-checked with isString below before it reaches argv.
1921
2054
  const { url: repoUrl } = (await req.json()) as { url?: string };
1922
- if (!repoUrl || typeof repoUrl !== "string") {
2055
+ if (!repoUrl || !isString(repoUrl)) {
1923
2056
  return problem("invalidRequest", "Missing url");
1924
2057
  }
1925
2058
  const repoName = basename(repoUrl.replace(/\.git$/, ""));
@@ -1944,6 +2077,17 @@ export async function handleStudioApi(
1944
2077
  return null;
1945
2078
  }
1946
2079
 
2080
+ /** The flat form-rendering schema the studio's renderSchemaFields() consumes. */
2081
+ interface StudioSchema {
2082
+ description: string | undefined;
2083
+ properties: Record<string, JsonObject>;
2084
+ required: string[];
2085
+ returns?: JsonValue;
2086
+ format?: JsonValue;
2087
+ $studio?: JsonValue;
2088
+ capabilities?: Record<string, { identifier: string; timing: string[] }>;
2089
+ }
2090
+
1947
2091
  /**
1948
2092
  * Extract a studio-friendly schema from a .class.json definition. Transforms $defs.parameters and
1949
2093
  * $defs.fields into the flat { description, properties, required } shape that renderSchemaFields()
@@ -1951,19 +2095,18 @@ export async function handleStudioApi(
1951
2095
  *
1952
2096
  * @param {ClassJsonDef} classDef
1953
2097
  * @param {string} classJsonPath
1954
- * @returns {{
1955
- * description: string | undefined;
1956
- * properties: Record<string, Record<string, unknown>>;
1957
- * required: string[];
1958
- * }}
1959
2098
  */
1960
- function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2099
+ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string): StudioSchema {
1961
2100
  // If extends.$ref points to a parent, recursively merge
1962
2101
  let parentSchema = null;
1963
- if (classDef.extends && typeof classDef.extends === "object" && classDef.extends.$ref) {
2102
+ // `?.$ref` is undefined for a non-object `extends`, so a primitive never reaches resolve().
2103
+ if (classDef.extends?.$ref) {
1964
2104
  try {
1965
2105
  const parentPath = resolve(dirname(classJsonPath), classDef.extends.$ref);
1966
2106
  const parentContent = readFileSync(parentPath, "utf8");
2107
+ /* SAFETY: every ClassJsonDef member is optional and each one read here is either guarded
2108
+ (isNonNullObject on `type`, truthiness elsewhere) or relayed verbatim; a non-object file
2109
+ throws into the catch below or yields no members. */
1967
2110
  const parentDef = JSON.parse(parentContent) as ClassJsonDef;
1968
2111
  parentSchema = extractStudioSchema(parentDef, parentPath);
1969
2112
  } catch {
@@ -1973,7 +2116,7 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
1973
2116
 
1974
2117
  const params = classDef.$defs?.parameters ?? {};
1975
2118
  const fields = classDef.$defs?.fields ?? {};
1976
- const properties: Record<string, Record<string, unknown>> = {};
2119
+ const properties: Record<string, JsonObject> = {};
1977
2120
  const required: string[] = [];
1978
2121
 
1979
2122
  // Start with parent properties (child overrides)
@@ -1987,8 +2130,8 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
1987
2130
  // Build properties from parameters (constructor config surface)
1988
2131
  for (const [key, param] of Object.entries(params)) {
1989
2132
  const id = param.identifier ?? key;
1990
- const prop: Record<string, unknown> = {};
1991
- if (param.type && typeof param.type === "object") {
2133
+ const prop: JsonObject = {};
2134
+ if (isNonNullObject(param.type)) {
1992
2135
  Object.assign(prop, param.type);
1993
2136
  }
1994
2137
  if (param.description) {
@@ -2012,8 +2155,8 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2012
2155
  continue;
2013
2156
  }
2014
2157
  const id = field.identifier ?? key;
2015
- const prop: Record<string, unknown> = {};
2016
- if (field.type && typeof field.type === "object") {
2158
+ const prop: JsonObject = {};
2159
+ if (isNonNullObject(field.type)) {
2017
2160
  Object.assign(prop, field.type);
2018
2161
  }
2019
2162
  if (field.description) {
@@ -2045,13 +2188,9 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2045
2188
 
2046
2189
  // Surface format-extension metadata: the format block, studio hints, and a
2047
2190
  // Capability summary ({ parse: { timing }, serialize: { timing }, ... }).
2048
- const def = classDef as Record<string, unknown>;
2049
2191
  const capabilityRoles = new Set(["parse", "serialize", "discover", "load"]);
2050
2192
  const capabilities: Record<string, { identifier: string; timing: string[] }> = {};
2051
- const methods = (classDef.$defs?.methods ?? {}) as Record<
2052
- string,
2053
- { role?: string; identifier?: string; timing?: string[] }
2054
- >;
2193
+ const methods = classDef.$defs?.methods ?? {};
2055
2194
  for (const [key, method] of Object.entries(methods)) {
2056
2195
  if (method.role && capabilityRoles.has(method.role)) {
2057
2196
  capabilities[method.role] = {
@@ -2061,13 +2200,23 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2061
2200
  }
2062
2201
  }
2063
2202
 
2064
- return {
2203
+ // Optional members are added in this order, which is the key order the studio receives.
2204
+ const schema: StudioSchema = {
2065
2205
  description: classDef.description ?? classDef.title,
2066
2206
  properties,
2067
2207
  required: [...requiredSet],
2068
- ...(resolveMethod?.returns ? { returns: resolveMethod.returns } : {}),
2069
- ...(def.format ? { format: def.format } : {}),
2070
- ...(def.$studio ? { $studio: def.$studio } : {}),
2071
- ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
2072
2208
  };
2209
+ if (resolveMethod?.returns) {
2210
+ schema.returns = resolveMethod.returns;
2211
+ }
2212
+ if (classDef.format) {
2213
+ schema.format = classDef.format;
2214
+ }
2215
+ if (classDef.$studio) {
2216
+ schema.$studio = classDef.$studio;
2217
+ }
2218
+ if (Object.keys(capabilities).length > 0) {
2219
+ schema.capabilities = capabilities;
2220
+ }
2221
+ return schema;
2073
2222
  }