@tinacms/cli 0.0.0-e6ffde4-20251216055147 → 0.0.0-e797ebd-20260331104743

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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Cli, Builtins } from "clipanion";
3
3
 
4
4
  // package.json
5
- var version = "2.0.3";
5
+ var version = "2.2.1";
6
6
 
7
7
  // src/next/commands/dev-command/index.ts
8
8
  import path8 from "path";
@@ -124,6 +124,15 @@ var S_SUCCESS = s("\u25C6", "*");
124
124
  var S_WARN = s("\u25B2", "!");
125
125
  var S_ERROR = s("\u25A0", "x");
126
126
 
127
+ // src/utils/host.ts
128
+ var LOCALHOST_ADDRESSES = ["localhost", "127.0.0.1", "::1"];
129
+ function isHostExposed(host) {
130
+ if (host === true) return true;
131
+ if (typeof host === "string" && !LOCALHOST_ADDRESSES.includes(host))
132
+ return true;
133
+ return false;
134
+ }
135
+
127
136
  // src/utils/spinner.ts
128
137
  import { Spinner } from "cli-spinner";
129
138
  async function localSpin({
@@ -417,6 +426,38 @@ var loadGraphQLDocuments = async (globPath) => {
417
426
  import { transform } from "esbuild";
418
427
  import { mapUserFields } from "@tinacms/graphql";
419
428
  import normalizePath from "normalize-path";
429
+
430
+ // src/next/codegen/stripSearchTokenFromConfig.ts
431
+ function stripSearchTokenFromConfig(config2) {
432
+ const cfg = config2;
433
+ if (!cfg?.search) {
434
+ return config2;
435
+ }
436
+ const search = cfg.search;
437
+ const tina = search?.tina;
438
+ if (tina) {
439
+ const { indexerToken, ...safeSearchConfig } = tina;
440
+ const newConfig = {};
441
+ for (const key of Object.keys(cfg)) {
442
+ if (key === "search") {
443
+ newConfig.search = { tina: safeSearchConfig };
444
+ } else {
445
+ newConfig[key] = cfg[key];
446
+ }
447
+ }
448
+ return newConfig;
449
+ } else {
450
+ const newConfig = {};
451
+ for (const key of Object.keys(cfg)) {
452
+ if (key !== "search") {
453
+ newConfig[key] = cfg[key];
454
+ }
455
+ }
456
+ return newConfig;
457
+ }
458
+ }
459
+
460
+ // src/next/codegen/index.ts
420
461
  var TINA_HOST = "content.tinajs.io";
421
462
  var Codegen = class {
422
463
  configManager;
@@ -487,8 +528,9 @@ var Codegen = class {
487
528
  "_graphql.json",
488
529
  JSON.stringify(this.graphqlSchemaDoc)
489
530
  );
490
- const { search, ...rest } = this.tinaSchema.schema.config;
491
- this.tinaSchema.schema.config = rest;
531
+ this.tinaSchema.schema.config = stripSearchTokenFromConfig(
532
+ this.tinaSchema.schema.config
533
+ );
492
534
  await this.writeConfigFile(
493
535
  "_schema.json",
494
536
  JSON.stringify(this.tinaSchema.schema)
@@ -784,6 +826,14 @@ function stripNativeTrailingSlash(p) {
784
826
  }
785
827
  return str;
786
828
  }
829
+ var PathTraversalError = class extends Error {
830
+ constructor(attemptedPath) {
831
+ super(
832
+ `Path traversal detected: the path "${attemptedPath}" escapes the allowed directory`
833
+ );
834
+ this.name = "PathTraversalError";
835
+ }
836
+ };
787
837
 
788
838
  // src/next/config-manager.ts
789
839
  var TINA_FOLDER = "tina";
@@ -998,13 +1048,15 @@ var ConfigManager = class {
998
1048
  this.contentRootPath = this.rootPath;
999
1049
  }
1000
1050
  this.generatedFolderPathContentRepo = path3.join(
1001
- await this.getTinaFolderPath(this.contentRootPath),
1051
+ await this.getTinaFolderPath(this.contentRootPath, {
1052
+ isContentRoot: this.hasSeparateContentRoot()
1053
+ }),
1002
1054
  GENERATED_FOLDER
1003
1055
  );
1004
1056
  this.spaMainPath = require2.resolve("@tinacms/app");
1005
1057
  this.spaRootPath = path3.join(this.spaMainPath, "..", "..");
1006
1058
  }
1007
- async getTinaFolderPath(rootPath) {
1059
+ async getTinaFolderPath(rootPath, { isContentRoot } = {}) {
1008
1060
  const tinaFolderPath = path3.join(rootPath, TINA_FOLDER);
1009
1061
  const tinaFolderExists = await fs2.pathExists(tinaFolderPath);
1010
1062
  if (tinaFolderExists) {
@@ -1017,6 +1069,11 @@ var ConfigManager = class {
1017
1069
  this.isUsingLegacyFolder = true;
1018
1070
  return legacyFolderPath;
1019
1071
  }
1072
+ if (isContentRoot) {
1073
+ throw new Error(
1074
+ `Unable to find a ${chalk3.cyan("tina/")} folder in your content root at ${chalk3.cyan(rootPath)}. When using localContentPath, the content directory must contain a ${chalk3.cyan("tina/")} folder for generated files. Create one with: mkdir ${path3.join(rootPath, TINA_FOLDER)}`
1075
+ );
1076
+ }
1020
1077
  throw new Error(
1021
1078
  `Unable to find Tina folder, if you're working in folder outside of the Tina config be sure to specify --rootPath`
1022
1079
  );
@@ -1207,15 +1264,15 @@ var loaders = {
1207
1264
  };
1208
1265
 
1209
1266
  // src/next/database.ts
1267
+ import { createServer } from "net";
1210
1268
  import {
1211
- createDatabaseInternal,
1212
1269
  FilesystemBridge,
1213
- TinaLevelClient
1270
+ TinaLevelClient,
1271
+ createDatabaseInternal
1214
1272
  } from "@tinacms/graphql";
1215
- import { pipeline } from "readable-stream";
1216
- import { createServer } from "net";
1217
1273
  import { ManyLevelHost } from "many-level";
1218
1274
  import { MemoryLevel } from "memory-level";
1275
+ import { pipeline } from "readable-stream";
1219
1276
  var createDBServer = (port) => {
1220
1277
  const levelHost = new ManyLevelHost(
1221
1278
  // @ts-ignore
@@ -1234,7 +1291,7 @@ var createDBServer = (port) => {
1234
1291
  );
1235
1292
  }
1236
1293
  });
1237
- dbServer.listen(port);
1294
+ dbServer.listen(port, "localhost");
1238
1295
  };
1239
1296
  async function createAndInitializeDatabase(configManager, datalayerPort, bridgeOverride) {
1240
1297
  let database;
@@ -1538,6 +1595,66 @@ import {
1538
1595
  splitVendorChunkPlugin
1539
1596
  } from "vite";
1540
1597
 
1598
+ // src/next/vite/cors.ts
1599
+ var LOCALHOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
1600
+ var PRIVATE_NETWORK_RE = /^https?:\/\/(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})(:\d+)?$/;
1601
+ function expandOrigins(raw) {
1602
+ const hasPrivate = raw.some((o) => o === "private");
1603
+ const filtered = raw.filter((o) => o !== "private");
1604
+ return hasPrivate ? [...filtered, PRIVATE_NETWORK_RE] : filtered;
1605
+ }
1606
+ function buildCorsOriginCheck(allowedOrigins = []) {
1607
+ const extra = expandOrigins(allowedOrigins);
1608
+ return (origin, callback) => {
1609
+ if (!origin) {
1610
+ callback(null, true);
1611
+ return;
1612
+ }
1613
+ if (LOCALHOST_RE.test(origin)) {
1614
+ callback(null, true);
1615
+ return;
1616
+ }
1617
+ for (const allowed of extra) {
1618
+ if (typeof allowed === "string") {
1619
+ if (allowed === origin) {
1620
+ callback(null, true);
1621
+ return;
1622
+ }
1623
+ } else {
1624
+ allowed.lastIndex = 0;
1625
+ if (allowed.test(origin)) {
1626
+ callback(null, true);
1627
+ return;
1628
+ }
1629
+ }
1630
+ }
1631
+ callback(null, false);
1632
+ };
1633
+ }
1634
+
1635
+ // src/next/vite/filterPublicEnv.ts
1636
+ function filterPublicEnv(env = process.env) {
1637
+ const publicEnv = {};
1638
+ Object.keys(env).forEach((key) => {
1639
+ if (key.startsWith("TINA_PUBLIC_") || key.startsWith("NEXT_PUBLIC_") || key === "NODE_ENV" || key === "HEAD") {
1640
+ try {
1641
+ const value = env[key];
1642
+ if (typeof value === "string") {
1643
+ publicEnv[key] = value;
1644
+ } else {
1645
+ publicEnv[key] = JSON.stringify(value);
1646
+ }
1647
+ } catch (error) {
1648
+ console.warn(
1649
+ `Could not stringify public env process.env.${key} env variable`
1650
+ );
1651
+ console.warn(error);
1652
+ }
1653
+ }
1654
+ });
1655
+ return publicEnv;
1656
+ }
1657
+
1541
1658
  // src/next/vite/tailwind.ts
1542
1659
  import path4 from "node:path";
1543
1660
  import aspectRatio from "@tailwindcss/aspect-ratio";
@@ -1870,23 +1987,7 @@ var createConfig = async ({
1870
1987
  noWatch,
1871
1988
  rollupOptions
1872
1989
  }) => {
1873
- const publicEnv = {};
1874
- Object.keys(process.env).forEach((key) => {
1875
- if (key.startsWith("TINA_PUBLIC_") || key.startsWith("NEXT_PUBLIC_") || key === "NODE_ENV" || key === "HEAD") {
1876
- try {
1877
- if (typeof process.env[key] === "string") {
1878
- publicEnv[key] = process.env[key];
1879
- } else {
1880
- publicEnv[key] = JSON.stringify(process.env[key]);
1881
- }
1882
- } catch (error) {
1883
- console.warn(
1884
- `Could not stringify public env process.env.${key} env variable`
1885
- );
1886
- console.warn(error);
1887
- }
1888
- }
1889
- });
1990
+ const publicEnv = filterPublicEnv();
1890
1991
  const staticMediaPath = path5.join(
1891
1992
  configManager.generatedFolderPath,
1892
1993
  "static-media.json"
@@ -1967,6 +2068,13 @@ var createConfig = async ({
1967
2068
  },
1968
2069
  server: {
1969
2070
  host: configManager.config?.build?.host ?? false,
2071
+ // Restrict Vite's built-in CORS to the same origins our custom
2072
+ // middleware allows (localhost + user-configured allowedOrigins).
2073
+ cors: {
2074
+ origin: buildCorsOriginCheck(
2075
+ configManager.config?.server?.allowedOrigins
2076
+ )
2077
+ },
1970
2078
  watch: noWatch ? {
1971
2079
  ignored: ["**/*"]
1972
2080
  } : {
@@ -1976,7 +2084,15 @@ var createConfig = async ({
1976
2084
  ]
1977
2085
  },
1978
2086
  fs: {
1979
- strict: false
2087
+ strict: true,
2088
+ // Allow serving files from the project root and the SPA package.
2089
+ // Without this, Vite would block access to tina config/generated
2090
+ // files since the Vite root is the @tinacms/app package directory.
2091
+ allow: [
2092
+ configManager.spaRootPath,
2093
+ configManager.rootPath,
2094
+ ...configManager.contentRootPath && configManager.contentRootPath !== configManager.rootPath ? [configManager.contentRootPath] : []
2095
+ ]
1980
2096
  }
1981
2097
  },
1982
2098
  build: {
@@ -2006,19 +2122,19 @@ var createConfig = async ({
2006
2122
  };
2007
2123
 
2008
2124
  // src/next/vite/plugins.ts
2009
- import { createFilter } from "@rollup/pluginutils";
2010
2125
  import fs6 from "fs";
2011
- import { transformWithEsbuild } from "vite";
2012
- import { transform as esbuildTransform } from "esbuild";
2013
2126
  import path7 from "path";
2127
+ import { createFilter } from "@rollup/pluginutils";
2128
+ import { resolve as gqlResolve } from "@tinacms/graphql";
2014
2129
  import bodyParser from "body-parser";
2015
2130
  import cors from "cors";
2016
- import { resolve as gqlResolve } from "@tinacms/graphql";
2131
+ import { transform as esbuildTransform } from "esbuild";
2132
+ import { transformWithEsbuild } from "vite";
2017
2133
 
2018
2134
  // src/next/commands/dev-command/server/media.ts
2019
- import fs5 from "fs-extra";
2020
2135
  import path6, { join } from "path";
2021
2136
  import busboy from "busboy";
2137
+ import fs5 from "fs-extra";
2022
2138
  var createMediaRouter = (config2) => {
2023
2139
  const mediaFolder = path6.join(
2024
2140
  config2.rootPath,
@@ -2027,31 +2143,68 @@ var createMediaRouter = (config2) => {
2027
2143
  );
2028
2144
  const mediaModel = new MediaModel(config2);
2029
2145
  const handleList = async (req, res) => {
2030
- const requestURL = new URL(req.url, config2.apiURL);
2031
- const folder = requestURL.pathname.replace("/media/list/", "");
2032
- const limit = requestURL.searchParams.get("limit");
2033
- const cursor = requestURL.searchParams.get("cursor");
2034
- const media = await mediaModel.listMedia({
2035
- searchPath: folder,
2036
- cursor,
2037
- limit
2038
- });
2039
- res.end(JSON.stringify(media));
2146
+ try {
2147
+ const requestURL = new URL(req.url, config2.apiURL);
2148
+ const folder = decodeURIComponent(
2149
+ requestURL.pathname.replace("/media/list/", "")
2150
+ );
2151
+ const limit = requestURL.searchParams.get("limit");
2152
+ const cursor = requestURL.searchParams.get("cursor");
2153
+ const media = await mediaModel.listMedia({
2154
+ searchPath: folder,
2155
+ cursor,
2156
+ limit
2157
+ });
2158
+ res.end(JSON.stringify(media));
2159
+ } catch (error) {
2160
+ if (error instanceof PathTraversalError) {
2161
+ res.statusCode = 403;
2162
+ res.end(JSON.stringify({ error: error.message }));
2163
+ return;
2164
+ }
2165
+ throw error;
2166
+ }
2040
2167
  };
2041
2168
  const handleDelete = async (req, res) => {
2042
- const file = decodeURIComponent(req.url.slice("/media/".length));
2043
- const didDelete = await mediaModel.deleteMedia({ searchPath: file });
2044
- res.end(JSON.stringify(didDelete));
2169
+ try {
2170
+ const file = decodeURIComponent(req.url.slice("/media/".length));
2171
+ const didDelete = await mediaModel.deleteMedia({ searchPath: file });
2172
+ res.end(JSON.stringify(didDelete));
2173
+ } catch (error) {
2174
+ if (error instanceof PathTraversalError) {
2175
+ res.statusCode = 403;
2176
+ res.end(JSON.stringify({ error: error.message }));
2177
+ return;
2178
+ }
2179
+ throw error;
2180
+ }
2045
2181
  };
2046
2182
  const handlePost = async function(req, res) {
2047
2183
  const bb = busboy({ headers: req.headers });
2184
+ let responded = false;
2048
2185
  bb.on("file", async (_name, file, _info) => {
2049
- const fullPath = decodeURI(req.url?.slice("/media/upload/".length));
2050
- const saveTo = path6.join(mediaFolder, ...fullPath.split("/"));
2186
+ const fullPath = decodeURIComponent(
2187
+ req.url?.slice("/media/upload/".length)
2188
+ );
2189
+ let saveTo;
2190
+ try {
2191
+ saveTo = resolveStrictlyWithinBase(fullPath, mediaFolder);
2192
+ } catch {
2193
+ responded = true;
2194
+ file.resume();
2195
+ res.statusCode = 403;
2196
+ res.end(
2197
+ JSON.stringify({
2198
+ error: `Path traversal detected: ${fullPath}`
2199
+ })
2200
+ );
2201
+ return;
2202
+ }
2051
2203
  await fs5.ensureDir(path6.dirname(saveTo));
2052
2204
  file.pipe(fs5.createWriteStream(saveTo));
2053
2205
  });
2054
2206
  bb.on("error", (error) => {
2207
+ responded = true;
2055
2208
  res.statusCode = 500;
2056
2209
  if (error instanceof Error) {
2057
2210
  res.end(JSON.stringify({ message: error }));
@@ -2060,6 +2213,7 @@ var createMediaRouter = (config2) => {
2060
2213
  }
2061
2214
  });
2062
2215
  bb.on("close", () => {
2216
+ if (responded) return;
2063
2217
  res.statusCode = 200;
2064
2218
  res.end(JSON.stringify({ success: true }));
2065
2219
  });
@@ -2074,6 +2228,55 @@ var parseMediaFolder = (str) => {
2074
2228
  returnString = returnString.substr(0, returnString.length - 1);
2075
2229
  return returnString;
2076
2230
  };
2231
+ var ENCODED_TRAVERSAL_RE = /%2e%2e|%2f|%5c/i;
2232
+ function resolveRealPath(candidate) {
2233
+ try {
2234
+ return fs5.realpathSync(candidate);
2235
+ } catch {
2236
+ const parent = path6.dirname(candidate);
2237
+ if (parent === candidate) return candidate;
2238
+ return path6.join(resolveRealPath(parent), path6.basename(candidate));
2239
+ }
2240
+ }
2241
+ function assertSymlinkWithinBase(resolved, resolvedBase, userPath) {
2242
+ try {
2243
+ const realBase = fs5.realpathSync(resolvedBase);
2244
+ const realResolved = resolveRealPath(resolved);
2245
+ if (realResolved !== realBase && !realResolved.startsWith(realBase + path6.sep)) {
2246
+ throw new PathTraversalError(userPath);
2247
+ }
2248
+ } catch (err) {
2249
+ if (err instanceof PathTraversalError) throw err;
2250
+ }
2251
+ }
2252
+ function resolveWithinBase(userPath, baseDir) {
2253
+ if (ENCODED_TRAVERSAL_RE.test(userPath)) {
2254
+ throw new PathTraversalError(userPath);
2255
+ }
2256
+ const resolvedBase = path6.resolve(baseDir);
2257
+ const resolved = path6.resolve(path6.join(baseDir, userPath));
2258
+ if (resolved === resolvedBase) {
2259
+ assertSymlinkWithinBase(resolved, resolvedBase, userPath);
2260
+ return resolvedBase;
2261
+ }
2262
+ if (resolved.startsWith(resolvedBase + path6.sep)) {
2263
+ assertSymlinkWithinBase(resolved, resolvedBase, userPath);
2264
+ return resolved;
2265
+ }
2266
+ throw new PathTraversalError(userPath);
2267
+ }
2268
+ function resolveStrictlyWithinBase(userPath, baseDir) {
2269
+ if (ENCODED_TRAVERSAL_RE.test(userPath)) {
2270
+ throw new PathTraversalError(userPath);
2271
+ }
2272
+ const resolvedBase = path6.resolve(baseDir) + path6.sep;
2273
+ const resolved = path6.resolve(path6.join(baseDir, userPath));
2274
+ if (!resolved.startsWith(resolvedBase)) {
2275
+ throw new PathTraversalError(userPath);
2276
+ }
2277
+ assertSymlinkWithinBase(resolved, path6.resolve(baseDir), userPath);
2278
+ return resolved;
2279
+ }
2077
2280
  var MediaModel = class {
2078
2281
  rootPath;
2079
2282
  publicFolder;
@@ -2085,22 +2288,18 @@ var MediaModel = class {
2085
2288
  }
2086
2289
  async listMedia(args) {
2087
2290
  try {
2088
- const folderPath = join(
2089
- this.rootPath,
2090
- this.publicFolder,
2091
- this.mediaRoot,
2092
- decodeURIComponent(args.searchPath)
2093
- );
2291
+ const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
2292
+ const validatedPath = resolveWithinBase(args.searchPath, mediaBase);
2094
2293
  const searchPath = parseMediaFolder(args.searchPath);
2095
- if (!await fs5.pathExists(folderPath)) {
2294
+ if (!await fs5.pathExists(validatedPath)) {
2096
2295
  return {
2097
2296
  files: [],
2098
2297
  directories: []
2099
2298
  };
2100
2299
  }
2101
- const filesStr = await fs5.readdir(folderPath);
2300
+ const filesStr = await fs5.readdir(validatedPath);
2102
2301
  const filesProm = filesStr.map(async (file) => {
2103
- const filePath = join(folderPath, file);
2302
+ const filePath = join(validatedPath, file);
2104
2303
  const stat = await fs5.stat(filePath);
2105
2304
  let src = `/${file}`;
2106
2305
  const isFile = stat.isFile();
@@ -2147,6 +2346,7 @@ var MediaModel = class {
2147
2346
  cursor
2148
2347
  };
2149
2348
  } catch (error) {
2349
+ if (error instanceof PathTraversalError) throw error;
2150
2350
  console.error(error);
2151
2351
  return {
2152
2352
  files: [],
@@ -2157,16 +2357,13 @@ var MediaModel = class {
2157
2357
  }
2158
2358
  async deleteMedia(args) {
2159
2359
  try {
2160
- const file = join(
2161
- this.rootPath,
2162
- this.publicFolder,
2163
- this.mediaRoot,
2164
- decodeURIComponent(args.searchPath)
2165
- );
2360
+ const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
2361
+ const file = resolveStrictlyWithinBase(args.searchPath, mediaBase);
2166
2362
  await fs5.stat(file);
2167
2363
  await fs5.remove(file);
2168
2364
  return { ok: true };
2169
2365
  } catch (error) {
2366
+ if (error instanceof PathTraversalError) throw error;
2170
2367
  console.error(error);
2171
2368
  return { ok: false, message: error?.toString() };
2172
2369
  }
@@ -2179,34 +2376,83 @@ var createSearchIndexRouter = ({
2179
2376
  searchIndex
2180
2377
  }) => {
2181
2378
  const put = async (req, res) => {
2182
- const { docs } = req.body;
2379
+ const docs = req.body?.docs ?? [];
2183
2380
  const result = await searchIndex.PUT(docs);
2184
2381
  res.writeHead(200, { "Content-Type": "application/json" });
2185
2382
  res.end(JSON.stringify({ result }));
2186
2383
  };
2187
2384
  const get = async (req, res) => {
2188
- const requestURL = new URL(req.url, config2.apiURL);
2385
+ const requestURL = new URL(req.url ?? "", config2.apiURL);
2386
+ const isV2 = requestURL.pathname.startsWith("/v2/searchIndex");
2387
+ res.writeHead(200, { "Content-Type": "application/json" });
2388
+ if (isV2) {
2389
+ const queryParam = requestURL.searchParams.get("query");
2390
+ const collectionParam = requestURL.searchParams.get("collection");
2391
+ const limitParam = requestURL.searchParams.get("limit");
2392
+ const cursorParam = requestURL.searchParams.get("cursor");
2393
+ if (!queryParam) {
2394
+ res.end(JSON.stringify({ RESULT: [], RESULT_LENGTH: 0 }));
2395
+ return;
2396
+ }
2397
+ if (!searchIndex.fuzzySearchWrapper) {
2398
+ res.end(JSON.stringify({ RESULT: [], RESULT_LENGTH: 0 }));
2399
+ return;
2400
+ }
2401
+ try {
2402
+ const paginationOptions = {};
2403
+ if (limitParam) {
2404
+ paginationOptions.limit = parseInt(limitParam, 10);
2405
+ }
2406
+ if (cursorParam) {
2407
+ paginationOptions.cursor = cursorParam;
2408
+ }
2409
+ const searchQuery = collectionParam ? `${queryParam} _collection:${collectionParam}` : queryParam;
2410
+ const result2 = await searchIndex.fuzzySearchWrapper.query(searchQuery, {
2411
+ ...paginationOptions
2412
+ });
2413
+ if (collectionParam) {
2414
+ result2.results = result2.results.filter(
2415
+ (r) => r._id && r._id.startsWith(`${collectionParam}:`)
2416
+ );
2417
+ }
2418
+ res.end(
2419
+ JSON.stringify({
2420
+ RESULT: result2.results,
2421
+ RESULT_LENGTH: result2.total,
2422
+ NEXT_CURSOR: result2.nextCursor,
2423
+ PREV_CURSOR: result2.prevCursor,
2424
+ FUZZY_MATCHES: result2.fuzzyMatches || {}
2425
+ })
2426
+ );
2427
+ return;
2428
+ } catch (error) {
2429
+ console.warn(
2430
+ "[search] v2 fuzzy search failed:",
2431
+ error instanceof Error ? error.message : error
2432
+ );
2433
+ res.end(JSON.stringify({ RESULT: [], RESULT_LENGTH: 0 }));
2434
+ return;
2435
+ }
2436
+ }
2189
2437
  const query = requestURL.searchParams.get("q");
2190
2438
  const optionsParam = requestURL.searchParams.get("options");
2191
- let options = {
2192
- DOCUMENTS: false
2193
- };
2439
+ if (!query) {
2440
+ res.end(JSON.stringify({ RESULT: [] }));
2441
+ return;
2442
+ }
2443
+ let searchIndexOptions = { DOCUMENTS: false };
2194
2444
  if (optionsParam) {
2195
- options = {
2196
- ...options,
2445
+ searchIndexOptions = {
2446
+ ...searchIndexOptions,
2197
2447
  ...JSON.parse(optionsParam)
2198
2448
  };
2199
2449
  }
2200
- res.writeHead(200, { "Content-Type": "application/json" });
2201
- if (query) {
2202
- const result = await searchIndex.QUERY(JSON.parse(query), options);
2203
- res.end(JSON.stringify(result));
2204
- } else {
2205
- res.end(JSON.stringify({ RESULT: [] }));
2206
- }
2450
+ const queryObj = JSON.parse(query);
2451
+ const result = await searchIndex.QUERY(queryObj, searchIndexOptions);
2452
+ res.end(JSON.stringify(result));
2207
2453
  };
2208
2454
  const del = async (req, res) => {
2209
- const requestURL = new URL(req.url, config2.apiURL);
2455
+ const requestURL = new URL(req.url ?? "", config2.apiURL);
2210
2456
  const docId = requestURL.pathname.split("/").filter(Boolean).slice(1).join("/");
2211
2457
  const result = await searchIndex.DELETE(docId);
2212
2458
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -2240,10 +2486,18 @@ var devServerEndPointsPlugin = ({
2240
2486
  searchIndex,
2241
2487
  databaseLock
2242
2488
  }) => {
2489
+ const corsOriginCheck = buildCorsOriginCheck(
2490
+ configManager.config?.server?.allowedOrigins
2491
+ );
2243
2492
  const plug = {
2244
2493
  name: "graphql-endpoints",
2245
2494
  configureServer(server) {
2246
- server.middlewares.use(cors());
2495
+ server.middlewares.use(
2496
+ cors({
2497
+ origin: corsOriginCheck,
2498
+ methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
2499
+ })
2500
+ );
2247
2501
  server.middlewares.use(bodyParser.json({ limit: "5mb" }));
2248
2502
  server.middlewares.use(async (req, res, next) => {
2249
2503
  const mediaPaths = configManager.config.media?.tina;
@@ -2296,7 +2550,7 @@ var devServerEndPointsPlugin = ({
2296
2550
  res.end(JSON.stringify(result));
2297
2551
  return;
2298
2552
  }
2299
- if (req.url.startsWith("/searchIndex")) {
2553
+ if (req.url.startsWith("/searchIndex") || req.url.startsWith("/v2/searchIndex")) {
2300
2554
  if (req.method === "POST") {
2301
2555
  await searchIndexRouter.put(req, res);
2302
2556
  } else if (req.method === "GET") {
@@ -2487,7 +2741,8 @@ var DevCommand = class extends BaseCommand {
2487
2741
  );
2488
2742
  if (configManager.hasSeparateContentRoot()) {
2489
2743
  const rootPath = await configManager.getTinaFolderPath(
2490
- configManager.contentRootPath
2744
+ configManager.contentRootPath,
2745
+ { isContentRoot: true }
2491
2746
  );
2492
2747
  const filePath = path8.join(rootPath, tinaLockFilename);
2493
2748
  await fs7.ensureFile(filePath);
@@ -2577,15 +2832,24 @@ ${dangerText(e.message)}
2577
2832
  configManager.config.search && searchIndexer
2578
2833
  );
2579
2834
  }
2835
+ const searchIndexWithFuzzy = searchIndexClient.searchIndex;
2836
+ if (searchIndexWithFuzzy && searchIndexClient.fuzzySearchWrapper) {
2837
+ searchIndexWithFuzzy.fuzzySearchWrapper = searchIndexClient.fuzzySearchWrapper;
2838
+ }
2580
2839
  const server = await createDevServer(
2581
2840
  configManager,
2582
2841
  database,
2583
- searchIndexClient.searchIndex,
2842
+ searchIndexWithFuzzy,
2584
2843
  apiURL,
2585
2844
  this.noWatch,
2586
2845
  dbLock
2587
2846
  );
2588
2847
  await server.listen(Number(this.port));
2848
+ if (isHostExposed(server.config.server.host)) {
2849
+ logger.warn(
2850
+ "\u26A0\uFE0F The TinaCMS dev server is listening on a non-localhost address. It has no authentication and is not intended to be exposed to the internet."
2851
+ );
2852
+ }
2589
2853
  if (!this.noWatch) {
2590
2854
  chokidar.watch(configManager.watchList).on("change", async () => {
2591
2855
  await dbLock(async () => {
@@ -2659,6 +2923,13 @@ ${dangerText(e.message)}
2659
2923
  // },
2660
2924
  ]
2661
2925
  });
2926
+ if (configManager?.config?.telemetry === "anonymous") {
2927
+ logger.info(
2928
+ `
2929
+ \u{1F4CA} Note: TinaCMS now collects anonymous telemetry regarding usage. More information on TinaCMS Telemetry: https://tina.io/telemetry
2930
+ `
2931
+ );
2932
+ }
2662
2933
  await this.startSubCommand();
2663
2934
  }
2664
2935
  watchContentFiles(configManager, database, databaseLock, searchIndexer) {
@@ -2759,23 +3030,6 @@ async function sleepAndCallFunc({
2759
3030
  // src/next/commands/build-command/server.ts
2760
3031
  import { build as build2 } from "vite";
2761
3032
  var buildProductionSpa = async (configManager, database, apiURL) => {
2762
- const publicEnv = {};
2763
- Object.keys(process.env).forEach((key) => {
2764
- if (key.startsWith("TINA_PUBLIC_") || key.startsWith("NEXT_PUBLIC_") || key === "NODE_ENV" || key === "HEAD") {
2765
- try {
2766
- if (typeof process.env[key] === "string") {
2767
- publicEnv[key] = process.env[key];
2768
- } else {
2769
- publicEnv[key] = JSON.stringify(process.env[key]);
2770
- }
2771
- } catch (error) {
2772
- console.warn(
2773
- `Could not stringify public env process.env.${key} env variable`
2774
- );
2775
- console.warn(error);
2776
- }
2777
- }
2778
- });
2779
3033
  const config2 = await createConfig({
2780
3034
  plugins: [transformTsxPlugin({ configManager }), viteTransformExtension()],
2781
3035
  configManager,
@@ -5715,6 +5969,12 @@ import { LocalAuthProvider } from "tinacms";`;
5715
5969
  outputFolder: "admin",
5716
5970
  publicFolder: "${args.publicFolder}",
5717
5971
  },
5972
+ // Uncomment to allow cross-origin requests from non-localhost origins
5973
+ // during local development (e.g. GitHub Codespaces, Gitpod, Docker).
5974
+ // Use 'private' to allow all private-network IPs (WSL2, Docker, etc.)
5975
+ // server: {
5976
+ // allowedOrigins: ['https://your-codespace.github.dev'],
5977
+ // },
5718
5978
  media: {
5719
5979
  tina: {
5720
5980
  mediaRoot: "",
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Strips `indexerToken` from `search.tina` before serialization to
3
+ * _schema.json / tina-lock.json.
4
+ *
5
+ * @see https://github.com/tinacms/tinacms/security/advisories/GHSA-4qrm-9h4r-v2fx
6
+ */
7
+ export declare function stripSearchTokenFromConfig<T extends object>(config: T): T;
@@ -1,5 +1,5 @@
1
- import type { Connect } from 'vite';
2
1
  import type { ServerResponse } from 'http';
2
+ import type { Connect } from 'vite';
3
3
  export declare const createMediaRouter: (config: PathConfig) => {
4
4
  handleList: (req: any, res: any) => Promise<void>;
5
5
  handleDelete: (req: Connect.IncomingMessage, res: any) => Promise<void>;
@@ -34,6 +34,23 @@ type SuccessRecord = {
34
34
  ok: false;
35
35
  message: string;
36
36
  };
37
+ /**
38
+ * Handles media file operations (list, delete) for the Vite-based dev server.
39
+ *
40
+ * @security Every method that accepts a user-supplied `searchPath` validates
41
+ * it against the media root using `resolveWithinBase` (list) or
42
+ * `resolveStrictlyWithinBase` (delete) before any filesystem access.
43
+ *
44
+ * - **list** uses `resolveWithinBase` because listing the media root itself
45
+ * (empty path / exact base match) is a valid operation.
46
+ * - **delete** uses `resolveStrictlyWithinBase` because deleting the media
47
+ * root directory itself must never be allowed.
48
+ *
49
+ * Both methods catch `PathTraversalError` and re-throw it so that the
50
+ * route handler can return a 403 response. Other errors are caught and
51
+ * returned as structured error responses (this avoids leaking stack traces
52
+ * to the client).
53
+ */
37
54
  export declare class MediaModel {
38
55
  readonly rootPath: string;
39
56
  readonly publicFolder: string;
@@ -1,12 +1,47 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { SearchQueryResponse, SearchResult } from '@tinacms/search';
1
3
  export interface PathConfig {
2
4
  apiURL: string;
3
5
  searchPath: string;
4
6
  }
7
+ interface SearchIndexOptions {
8
+ DOCUMENTS?: boolean;
9
+ PAGE?: {
10
+ NUMBER: number;
11
+ SIZE: number;
12
+ };
13
+ }
14
+ interface SearchIndexResult {
15
+ RESULT: SearchResult[];
16
+ RESULT_LENGTH: number;
17
+ }
18
+ interface FuzzySearchWrapper {
19
+ query: (query: string, options: {
20
+ limit?: number;
21
+ cursor?: string;
22
+ fuzzyOptions?: Record<string, unknown>;
23
+ }) => Promise<SearchQueryResponse>;
24
+ }
25
+ interface SearchIndex {
26
+ PUT: (docs: Record<string, unknown>[]) => Promise<unknown>;
27
+ DELETE: (id: string) => Promise<unknown>;
28
+ QUERY: (query: {
29
+ AND?: string[];
30
+ OR?: string[];
31
+ }, options: SearchIndexOptions) => Promise<SearchIndexResult>;
32
+ fuzzySearchWrapper?: FuzzySearchWrapper;
33
+ }
34
+ interface RequestWithBody extends IncomingMessage {
35
+ body?: {
36
+ docs?: Record<string, unknown>[];
37
+ };
38
+ }
5
39
  export declare const createSearchIndexRouter: ({ config, searchIndex, }: {
6
40
  config: PathConfig;
7
- searchIndex: any;
41
+ searchIndex: SearchIndex;
8
42
  }) => {
9
- del: (req: any, res: any) => Promise<void>;
10
- get: (req: any, res: any) => Promise<void>;
11
- put: (req: any, res: any) => Promise<void>;
43
+ del: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
44
+ get: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
45
+ put: (req: RequestWithBody, res: ServerResponse) => Promise<void>;
12
46
  };
47
+ export {};
@@ -54,7 +54,9 @@ export declare class ConfigManager {
54
54
  hasSeparateContentRoot(): boolean;
55
55
  shouldSkipSDK(): boolean;
56
56
  processConfig(): Promise<void>;
57
- getTinaFolderPath(rootPath: any): Promise<string>;
57
+ getTinaFolderPath(rootPath: string, { isContentRoot }?: {
58
+ isContentRoot?: boolean;
59
+ }): Promise<string>;
58
60
  getTinaGraphQLVersion(): {
59
61
  fullVersion: string;
60
62
  major: string;
@@ -1,4 +1,4 @@
1
- import { Database, Bridge } from '@tinacms/graphql';
1
+ import { Bridge, Database } from '@tinacms/graphql';
2
2
  import { ConfigManager } from './config-manager';
3
3
  export declare const createDBServer: (port: number) => void;
4
4
  export declare function createAndInitializeDatabase(configManager: ConfigManager, datalayerPort: number, bridgeOverride?: Bridge): Promise<Database>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared CORS origin-checking logic for the TinaCMS dev server.
3
+ *
4
+ * By default only localhost / 127.0.0.1 / [::1] (any port) are allowed.
5
+ * Users can extend this via `server.allowedOrigins` in their tina config.
6
+ * The special keyword `'private'` expands to all RFC 1918 private-network
7
+ * IP ranges (10.x, 172.16-31.x, 192.168.x) — useful for WSL2, Docker
8
+ * bridge networks, etc.
9
+ */
10
+ /**
11
+ * Build a CORS `origin` callback compatible with the `cors` npm package.
12
+ */
13
+ export declare function buildCorsOriginCheck(allowedOrigins?: (string | RegExp)[]): (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => void;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Filters env vars to only those safe for client-side bundles.
3
+ *
4
+ * Allows: TINA_PUBLIC_*, NEXT_PUBLIC_*, NODE_ENV, HEAD.
5
+ * Everything else is excluded to prevent leaking secrets.
6
+ *
7
+ * @see https://github.com/tinacms/tinacms/security/advisories/GHSA-pc2q-jcxq-rjrr
8
+ */
9
+ export declare function filterPublicEnv(env?: Record<string, string | undefined>): Record<string, string>;
@@ -1,8 +1,8 @@
1
- import type { Plugin } from 'vite';
2
1
  import { FilterPattern } from '@rollup/pluginutils';
3
2
  import type { Config } from '@svgr/core';
4
- import { transformWithEsbuild } from 'vite';
5
3
  import type { Database } from '@tinacms/graphql';
4
+ import type { Plugin } from 'vite';
5
+ import { transformWithEsbuild } from 'vite';
6
6
  import type { ConfigManager } from '../config-manager';
7
7
  export declare const transformTsxPlugin: ({ configManager: _configManager, }: {
8
8
  configManager: ConfigManager;
@@ -28,6 +28,23 @@ type SuccessRecord = {
28
28
  ok: false;
29
29
  message: string;
30
30
  };
31
+ /**
32
+ * Handles media file operations (list, delete) for the Express-based server.
33
+ *
34
+ * @security Every method that accepts a user-supplied `searchPath` validates
35
+ * it against the media root using `resolveWithinBase` (list) or
36
+ * `resolveStrictlyWithinBase` (delete) before any filesystem access.
37
+ *
38
+ * - **list** uses `resolveWithinBase` because listing the media root itself
39
+ * (empty path / exact base match) is a valid operation.
40
+ * - **delete** uses `resolveStrictlyWithinBase` because deleting the media
41
+ * root directory itself must never be allowed.
42
+ *
43
+ * Both methods catch `PathTraversalError` and re-throw it so that the
44
+ * route handler can return a 403 response. Other errors are caught and
45
+ * returned as structured error responses (this avoids leaking stack traces
46
+ * to the client).
47
+ */
31
48
  export declare class MediaModel {
32
49
  readonly rootPath: string;
33
50
  readonly publicFolder: string;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns true when the Vite server host config indicates the server is
3
+ * listening on a non-localhost address (i.e. exposed to the network).
4
+ */
5
+ export declare function isHostExposed(host: string | boolean | undefined): boolean;
@@ -1,3 +1,29 @@
1
1
  /** Removes trailing slash from path. Separator to remove is chosen based on
2
2
  * operating system. */
3
3
  export declare function stripNativeTrailingSlash(p: string): string;
4
+ /**
5
+ * Validates that a user-supplied path does not escape the base directory
6
+ * via path traversal (CWE-22). Returns the resolved absolute path.
7
+ *
8
+ * Allows an exact base match (empty or `.` input) — use this for list/read
9
+ * operations where referencing the root directory itself is valid. For
10
+ * delete/write operations where you need to target an actual file, use the
11
+ * `resolveStrictlyWithinBase` variant (currently inlined in media models).
12
+ *
13
+ * As a safety net, also rejects paths that still contain URL-encoded
14
+ * traversal sequences (`%2e%2e`, `%2f`, `%5c`), catching cases where the
15
+ * caller forgot to decode.
16
+ *
17
+ * @security This is the canonical implementation. Inlined copies exist in
18
+ * the media model files for CodeQL compatibility — keep them in sync.
19
+ *
20
+ * @param userPath - The untrusted path from the request (must already be
21
+ * URI-decoded by the caller).
22
+ * @param baseDir - The trusted base directory the path must stay within.
23
+ * @returns The resolved absolute path.
24
+ * @throws {PathTraversalError} If the path escapes the base directory.
25
+ */
26
+ export declare function assertPathWithinBase(userPath: string, baseDir: string): string;
27
+ export declare class PathTraversalError extends Error {
28
+ constructor(attemptedPath: string);
29
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tinacms/cli",
3
3
  "type": "module",
4
- "version": "0.0.0-e6ffde4-20251216055147",
4
+ "version": "0.0.0-e797ebd-20260331104743",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
7
7
  "files": [
@@ -41,7 +41,7 @@
41
41
  "@types/progress": "^2.0.7",
42
42
  "@types/prompts": "^2.4.9",
43
43
  "jest": "^29.7.0",
44
- "@tinacms/scripts": "1.4.2"
44
+ "@tinacms/scripts": "1.6.0"
45
45
  },
46
46
  "dependencies": {
47
47
  "@graphql-codegen/core": "^2.6.8",
@@ -88,12 +88,12 @@
88
88
  "vite": "^4.5.9",
89
89
  "yup": "^1.6.1",
90
90
  "zod": "^3.24.2",
91
- "@tinacms/app": "0.0.0-e6ffde4-20251216055147",
92
- "@tinacms/graphql": "2.0.2",
91
+ "@tinacms/app": "0.0.0-e797ebd-20260331104743",
92
+ "@tinacms/graphql": "2.2.2",
93
93
  "@tinacms/metrics": "2.0.1",
94
- "@tinacms/schema-tools": "2.1.0",
95
- "tinacms": "0.0.0-e6ffde4-20251216055147",
96
- "@tinacms/search": "1.1.6"
94
+ "@tinacms/schema-tools": "2.7.0",
95
+ "@tinacms/search": "1.2.8",
96
+ "tinacms": "0.0.0-e797ebd-20260331104743"
97
97
  },
98
98
  "publishConfig": {
99
99
  "registry": "https://registry.npmjs.org"