@patchstack/connect 0.5.6 → 0.5.8

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
@@ -1195,8 +1195,8 @@ function compareSegments(a, b) {
1195
1195
  var RULES = [
1196
1196
  { platform: "netlify", any: ["NETLIFY", "NETLIFY_BUILD_BASE", "DEPLOY_PRIME_URL"] },
1197
1197
  { platform: "vercel", any: ["VERCEL", "VERCEL_ENV", "VERCEL_URL"] },
1198
- { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "CLOUDFLARE_ACCOUNT_ID"] },
1199
- { platform: "aws", any: ["AWS_APP_ID", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
1198
+ { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "WORKERS_CI", "WORKERS_CI_BRANCH", "CLOUDFLARE_ACCOUNT_ID"] },
1199
+ { platform: "aws", any: ["AWS_APP_ID", "AWS_BRANCH", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
1200
1200
  { platform: "render", any: ["RENDER", "RENDER_SERVICE_ID", "RENDER_EXTERNAL_URL"] },
1201
1201
  { platform: "railway", any: ["RAILWAY_ENVIRONMENT", "RAILWAY_ENVIRONMENT_NAME", "RAILWAY_PROJECT_ID"] },
1202
1202
  { platform: "fly", any: ["FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"] },
@@ -1219,6 +1219,128 @@ function detectHostingPlatform(env = process.env) {
1219
1219
  return { platform: null, evidence: [] };
1220
1220
  }
1221
1221
 
1222
+ // src/protect/safe-origin.js
1223
+ function isSafeOrigin(value) {
1224
+ try {
1225
+ const u = new URL(value);
1226
+ if (u.protocol === "https:") return true;
1227
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
1228
+ } catch {
1229
+ return false;
1230
+ }
1231
+ }
1232
+
1233
+ // src/endpoint-policy.ts
1234
+ function assertConnectableEndpoint(config, target = config.endpoint) {
1235
+ if (config.endpointTrusted === false) {
1236
+ throw new PatchstackError(
1237
+ "The custom endpoint in .patchstackrc.json must be confirmed with --endpoint or PATCHSTACK_ENDPOINT before it can be used.",
1238
+ "CONFIG_INVALID"
1239
+ );
1240
+ }
1241
+ if (safeRemoteUrl(config.endpoint) === null) {
1242
+ throw new PatchstackError(
1243
+ "Patchstack endpoints must use HTTPS, except for localhost development endpoints.",
1244
+ "CONFIG_INVALID"
1245
+ );
1246
+ }
1247
+ let endpointOrigin;
1248
+ let targetOrigin;
1249
+ try {
1250
+ endpointOrigin = new URL(config.endpoint).origin;
1251
+ targetOrigin = new URL(target).origin;
1252
+ } catch (cause) {
1253
+ throw new PatchstackError("The configured Patchstack endpoint is not a valid URL.", "CONFIG_INVALID", cause);
1254
+ }
1255
+ if (safeRemoteUrl(target) === null || targetOrigin !== endpointOrigin) {
1256
+ throw new PatchstackError(
1257
+ "A Patchstack request resolved outside the configured endpoint origin.",
1258
+ "CONFIG_INVALID"
1259
+ );
1260
+ }
1261
+ }
1262
+ function safeRemoteUrl(value) {
1263
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
1264
+ try {
1265
+ const parsed = new URL(value);
1266
+ if (!isSafeOrigin(parsed.toString()) || parsed.username !== "" || parsed.password !== "") return null;
1267
+ return parsed.toString();
1268
+ } catch {
1269
+ return null;
1270
+ }
1271
+ }
1272
+ function safeDisplayString(value, maxLength = 512) {
1273
+ if (typeof value !== "string") return null;
1274
+ const clean = value.replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
1275
+ if (clean.length === 0) return null;
1276
+ return clean.slice(0, maxLength);
1277
+ }
1278
+ function isCanonicalUuid(value) {
1279
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1280
+ }
1281
+ function isCredential(value) {
1282
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return false;
1283
+ for (let i = 0; i < value.length; i += 1) {
1284
+ const code = value.charCodeAt(i);
1285
+ if (code < 33 || code > 126) return false;
1286
+ }
1287
+ const separator = value.lastIndexOf("-");
1288
+ return separator > 0 && separator < value.length - 1 && /^\d+$/.test(value.slice(separator + 1));
1289
+ }
1290
+ function isHeaderValue(value, maxLength = 8192) {
1291
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return false;
1292
+ for (let i = 0; i < value.length; i += 1) {
1293
+ const code = value.charCodeAt(i);
1294
+ if (code < 33 || code > 126) return false;
1295
+ }
1296
+ return true;
1297
+ }
1298
+
1299
+ // src/bounded-response.ts
1300
+ var MAX_CONTROL_RESPONSE_BYTES = 256 * 1024;
1301
+ async function readBoundedText(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
1302
+ const stated2 = Number(response.headers?.get?.("content-length"));
1303
+ if (Number.isFinite(stated2) && stated2 > maxBytes) {
1304
+ void response.body?.cancel().catch(() => {
1305
+ });
1306
+ throw new Error(`response exceeds ${maxBytes} bytes`);
1307
+ }
1308
+ const stream = response.body;
1309
+ if (stream === null || typeof stream.getReader !== "function") {
1310
+ if (stated2 === 0) return "";
1311
+ throw new Error("response body cannot be read with a size bound");
1312
+ }
1313
+ const reader = stream.getReader();
1314
+ const chunks = [];
1315
+ let total = 0;
1316
+ try {
1317
+ for (; ; ) {
1318
+ const { done, value } = await reader.read();
1319
+ if (done) break;
1320
+ total += value.byteLength;
1321
+ if (total > maxBytes) {
1322
+ void reader.cancel().catch(() => {
1323
+ });
1324
+ throw new Error(`response exceeds ${maxBytes} bytes`);
1325
+ }
1326
+ chunks.push(value);
1327
+ }
1328
+ } finally {
1329
+ reader.releaseLock();
1330
+ }
1331
+ const bytes = new Uint8Array(total);
1332
+ let offset = 0;
1333
+ for (const chunk of chunks) {
1334
+ bytes.set(chunk, offset);
1335
+ offset += chunk.byteLength;
1336
+ }
1337
+ return new TextDecoder().decode(bytes);
1338
+ }
1339
+ async function readBoundedJson(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
1340
+ const text = await readBoundedText(response, maxBytes);
1341
+ return JSON.parse(text);
1342
+ }
1343
+
1222
1344
  // src/pulse-token.ts
1223
1345
  var TOKEN_SKEW_MS = 6e4;
1224
1346
  function buildTokenUrl(manifestEndpoint) {
@@ -1236,22 +1358,48 @@ function parsePulseAuth(credential) {
1236
1358
  if (!/^\d+$/.test(clientId)) return null;
1237
1359
  return { clientId, clientSecret: credential.slice(0, index) };
1238
1360
  }
1239
- var cached = null;
1240
- var inflight = null;
1241
- function clearPulseToken() {
1242
- cached = null;
1361
+ var cached = /* @__PURE__ */ new Map();
1362
+ var inflight = /* @__PURE__ */ new Map();
1363
+ function cacheKey(tokenUrl, credential) {
1364
+ return `${tokenUrl}
1365
+ ${credential}`;
1366
+ }
1367
+ function clearPulseToken(config) {
1368
+ if (!config || typeof config.pulseAuth !== "string") {
1369
+ cached.clear();
1370
+ inflight.clear();
1371
+ return;
1372
+ }
1373
+ try {
1374
+ const key = cacheKey(buildTokenUrl(config.endpoint), config.pulseAuth);
1375
+ cached.delete(key);
1376
+ inflight.delete(key);
1377
+ } catch {
1378
+ }
1243
1379
  }
1244
1380
  async function getPulseToken(config, fetchImpl = fetch) {
1245
1381
  if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
1246
- if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
1247
- return cached.token;
1382
+ if (config.endpointTrusted === false) return null;
1383
+ let tokenUrl;
1384
+ try {
1385
+ tokenUrl = buildTokenUrl(config.endpoint);
1386
+ } catch {
1387
+ return null;
1388
+ }
1389
+ if (!isSafeOrigin(tokenUrl)) return null;
1390
+ const key = cacheKey(tokenUrl, config.pulseAuth);
1391
+ const existing = cached.get(key);
1392
+ if (existing !== void 0 && Date.now() < existing.expiresAt - TOKEN_SKEW_MS) {
1393
+ return existing.token;
1248
1394
  }
1249
- if (inflight !== null) return inflight;
1395
+ const pending = inflight.get(key);
1396
+ if (pending !== void 0) return pending;
1250
1397
  const credentials = parsePulseAuth(config.pulseAuth);
1251
1398
  if (credentials === null) return null;
1252
- inflight = (async () => {
1399
+ let exchange;
1400
+ exchange = (async () => {
1253
1401
  try {
1254
- const response = await fetchImpl(buildTokenUrl(config.endpoint), {
1402
+ const response = await fetchImpl(tokenUrl, {
1255
1403
  method: "POST",
1256
1404
  headers: {
1257
1405
  "Content-Type": "application/json",
@@ -1266,25 +1414,27 @@ async function getPulseToken(config, fetchImpl = fetch) {
1266
1414
  signal: AbortSignal.timeout(config.timeoutMs)
1267
1415
  });
1268
1416
  if (!response.ok) return null;
1269
- const body = await response.json();
1270
- if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
1417
+ const body = await readBoundedJson(response);
1418
+ if (!isHeaderValue(body.access_token)) return null;
1271
1419
  const expiresIn = Number(body.expires_in);
1272
1420
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
1273
- cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
1421
+ cached.set(key, { token: body.access_token, expiresAt: Date.now() + ttlMs });
1274
1422
  return body.access_token;
1275
1423
  } catch {
1276
1424
  return null;
1277
1425
  } finally {
1278
- inflight = null;
1426
+ if (inflight.get(key) === exchange) inflight.delete(key);
1279
1427
  }
1280
1428
  })();
1281
- return inflight;
1429
+ inflight.set(key, exchange);
1430
+ return exchange;
1282
1431
  }
1283
1432
  async function pulseAuthHeader(config, fetchImpl = fetch) {
1284
1433
  const token = await getPulseToken(config, fetchImpl);
1285
1434
  return token === null ? {} : { Authorization: `Bearer ${token}` };
1286
1435
  }
1287
1436
  async function pulseFetch(config, url, init, fetchImpl = fetch) {
1437
+ assertConnectableEndpoint(config, url);
1288
1438
  const send = async () => {
1289
1439
  const auth = await pulseAuthHeader(config, fetchImpl);
1290
1440
  const response = await fetchImpl(url, {
@@ -1295,7 +1445,7 @@ async function pulseFetch(config, url, init, fetchImpl = fetch) {
1295
1445
  };
1296
1446
  const first = await send();
1297
1447
  if (first.response.status === 401 && first.authenticated) {
1298
- clearPulseToken();
1448
+ clearPulseToken(config);
1299
1449
  return (await send()).response;
1300
1450
  }
1301
1451
  return first.response;
@@ -1306,7 +1456,7 @@ var DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
1306
1456
  var DEFAULT_TIMEOUT_MS = 3e4;
1307
1457
  var CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
1308
1458
  function claimTokenHeader(config) {
1309
- return typeof config.claimToken === "string" && config.claimToken !== "" ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1459
+ return config.endpointTrusted !== false && isHeaderValue(config.claimToken) ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1310
1460
  }
1311
1461
  function buildEndpointUrl(base, siteUuid) {
1312
1462
  const trimmed = base.replace(/\/$/, "");
@@ -1345,9 +1495,46 @@ function validationFields(body) {
1345
1495
  if (typeof errors !== "object" || errors === null || Array.isArray(errors)) return [];
1346
1496
  return Object.keys(errors);
1347
1497
  }
1498
+ function manifestResponse(body, provisioning) {
1499
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
1500
+ const value = body;
1501
+ if (typeof value.stored !== "boolean") return null;
1502
+ const uuid = value.uuid === void 0 ? void 0 : isCanonicalUuid(value.uuid) ? value.uuid : null;
1503
+ if (uuid === null || provisioning && uuid === void 0) return null;
1504
+ if (value.api_key !== void 0 && !isCredential(value.api_key)) return null;
1505
+ if (value.manifest_id !== void 0 && (!Number.isInteger(value.manifest_id) || Number(value.manifest_id) <= 0)) return null;
1506
+ const claimValue = value.claim;
1507
+ let claim2;
1508
+ if (claimValue !== void 0) {
1509
+ if (typeof claimValue !== "object" || claimValue === null || Array.isArray(claimValue)) return null;
1510
+ const raw = claimValue;
1511
+ if (!["claimed", "owned-by-you", "owned-by-other", "rejected"].includes(String(raw.state))) return null;
1512
+ const dashboardUrl = raw.dashboard_url === void 0 || raw.dashboard_url === null ? null : safeRemoteUrl(raw.dashboard_url);
1513
+ if (raw.dashboard_url !== void 0 && raw.dashboard_url !== null && dashboardUrl === null) return null;
1514
+ claim2 = {
1515
+ state: raw.state,
1516
+ ...Number.isInteger(raw.site_id) ? { site_id: Number(raw.site_id) } : {},
1517
+ ...dashboardUrl !== null ? { dashboard_url: dashboardUrl } : {},
1518
+ ...raw.reason === "expired" || raw.reason === "invalid" ? { reason: raw.reason } : {}
1519
+ };
1520
+ }
1521
+ const result = {
1522
+ stored: value.stored,
1523
+ ...uuid !== void 0 ? { uuid } : {},
1524
+ ...typeof value.api_key === "string" ? { api_key: value.api_key } : {},
1525
+ ...Number.isInteger(value.manifest_id) ? { manifest_id: Number(value.manifest_id) } : {},
1526
+ ...safeDisplayString(value.checksum, 256) !== null ? { checksum: safeDisplayString(value.checksum, 256) } : {},
1527
+ ...safeDisplayString(value.reason) !== null ? { reason: safeDisplayString(value.reason) } : {},
1528
+ ...safeDisplayString(value.message) !== null ? { message: safeDisplayString(value.message) } : {},
1529
+ ...safeDisplayString(value.error) !== null ? { error: safeDisplayString(value.error) } : {},
1530
+ ...claim2 !== void 0 ? { claim: claim2 } : {}
1531
+ };
1532
+ return result;
1533
+ }
1348
1534
  async function postManifest(config, payload) {
1349
1535
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
1350
1536
  const timeoutMs = config.timeoutMs;
1537
+ assertConnectableEndpoint(config, url);
1351
1538
  let response;
1352
1539
  try {
1353
1540
  response = await pulseFetch(config, url, {
@@ -1375,25 +1562,27 @@ async function postManifest(config, payload) {
1375
1562
  cause
1376
1563
  );
1377
1564
  }
1378
- const text = await response.text();
1379
- let body = null;
1565
+ let text = "";
1566
+ let parsed = null;
1380
1567
  try {
1381
- body = text.length > 0 ? JSON.parse(text) : null;
1568
+ text = await readBoundedText(response);
1569
+ parsed = text.length > 0 ? JSON.parse(text) : null;
1382
1570
  } catch {
1383
- body = null;
1571
+ parsed = null;
1384
1572
  }
1573
+ const body = manifestResponse(parsed, config.siteUuid === null);
1385
1574
  if (response.status === 404) {
1386
1575
  throw new PatchstackError(
1387
- body?.error ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
1576
+ safeDisplayString(parsed?.error) ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
1388
1577
  "SITE_NOT_FOUND"
1389
1578
  );
1390
1579
  }
1391
1580
  if (response.status === 422) {
1392
1581
  const refused2 = new PatchstackError(
1393
- body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
1582
+ safeDisplayString(parsed?.message) ?? "Patchstack rejected the manifest payload (validation failed).",
1394
1583
  "VALIDATION_ERROR"
1395
1584
  );
1396
- refused2.fields = validationFields(body);
1585
+ refused2.fields = validationFields(parsed);
1397
1586
  throw refused2;
1398
1587
  }
1399
1588
  const refused = authFailureMessage(response.status, config);
@@ -1402,12 +1591,12 @@ async function postManifest(config, payload) {
1402
1591
  }
1403
1592
  if (response.status < 200 || response.status >= 300) {
1404
1593
  throw new PatchstackError(
1405
- `Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
1594
+ `Patchstack returned ${response.status}: ${safeDisplayString(text, 200) ?? "no response details"}`,
1406
1595
  "SERVER_ERROR"
1407
1596
  );
1408
1597
  }
1409
1598
  if (body === null) {
1410
- throw new PatchstackError("Patchstack returned an empty response.", "SERVER_ERROR");
1599
+ throw new PatchstackError("Patchstack returned an unexpected response.", "SERVER_ERROR");
1411
1600
  }
1412
1601
  return body;
1413
1602
  }
@@ -1419,13 +1608,25 @@ function isTimeoutError(cause) {
1419
1608
  }
1420
1609
 
1421
1610
  // src/config.ts
1422
- import { readFile as readFile8, writeFile, chmod } from "fs/promises";
1611
+ import { readFile as readFile8, chmod, lstat as lstat2, open, rename, unlink } from "fs/promises";
1423
1612
  import path8 from "path";
1613
+ import { randomUUID } from "crypto";
1424
1614
 
1425
1615
  // src/environment.ts
1426
1616
  import { readFileSync } from "fs";
1427
1617
  import path6 from "path";
1428
1618
  var set = (value) => value !== void 0 && value !== "";
1619
+ var PRODUCTION_BRANCHES = /* @__PURE__ */ new Set([
1620
+ "main",
1621
+ "master",
1622
+ "production",
1623
+ "prod",
1624
+ "release",
1625
+ "live"
1626
+ ]);
1627
+ function environmentFromBranch(variable, branch) {
1628
+ return PRODUCTION_BRANCHES.has(branch.toLowerCase()) ? { environment: "production", evidence: `${variable}=${branch} (a production branch by name)` } : { environment: "sandbox", evidence: `${variable}=${branch} (not a production branch by name)` };
1629
+ }
1429
1630
  var DISCRIMINATORS = [
1430
1631
  {
1431
1632
  platform: "vercel",
@@ -1454,6 +1655,79 @@ var DISCRIMINATORS = [
1454
1655
  if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null;
1455
1656
  return env.RAILWAY_ENVIRONMENT_NAME === "production" ? { environment: "production", evidence: "RAILWAY_ENVIRONMENT_NAME=production" } : { environment: "sandbox", evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` };
1456
1657
  }
1658
+ },
1659
+ {
1660
+ // Pages and Workers Builds both name the branch and neither names the tier.
1661
+ platform: "cloudflare",
1662
+ read: (env) => {
1663
+ if (set(env.CF_PAGES) && set(env.CF_PAGES_BRANCH)) {
1664
+ return environmentFromBranch("CF_PAGES_BRANCH", env.CF_PAGES_BRANCH);
1665
+ }
1666
+ if (set(env.WORKERS_CI) && set(env.WORKERS_CI_BRANCH)) {
1667
+ return environmentFromBranch("WORKERS_CI_BRANCH", env.WORKERS_CI_BRANCH);
1668
+ }
1669
+ return null;
1670
+ }
1671
+ },
1672
+ {
1673
+ // Amplify names the branch, and separately whether the build is a pull-request preview.
1674
+ platform: "aws",
1675
+ read: (env) => {
1676
+ if (!set(env.AWS_APP_ID) || !set(env.AWS_BRANCH)) return null;
1677
+ return set(env.AWS_PULL_REQUEST_ID) ? { environment: "sandbox", evidence: "AWS_PULL_REQUEST_ID set (a pull-request preview)" } : environmentFromBranch("AWS_BRANCH", env.AWS_BRANCH);
1678
+ }
1679
+ },
1680
+ {
1681
+ // A Deployment build carries `REPLIT_DEPLOYMENT`; the workspace carries `REPL_ID` alone. Read here,
1682
+ // before the hosted-builder rule, so a workspace build in a Replit project is not its publish step.
1683
+ platform: "replit",
1684
+ read: (env) => {
1685
+ if (set(env.REPLIT_DEPLOYMENT)) {
1686
+ return { environment: "production", evidence: "REPLIT_DEPLOYMENT set (a Replit Deployment build)" };
1687
+ }
1688
+ if (set(env.REPL_ID)) {
1689
+ return { environment: "sandbox", evidence: "REPL_ID set without REPLIT_DEPLOYMENT (the workspace)" };
1690
+ }
1691
+ return null;
1692
+ }
1693
+ },
1694
+ {
1695
+ // GitHub Pages deploys build here. A pull request checks out a merge ref whose name says nothing, so
1696
+ // the event is read first; a tag is a release; otherwise the branch name decides.
1697
+ platform: "github-actions",
1698
+ read: (env) => {
1699
+ if (env.GITHUB_ACTIONS !== "true") return null;
1700
+ if (set(env.GITHUB_EVENT_NAME) && env.GITHUB_EVENT_NAME.startsWith("pull_request")) {
1701
+ return { environment: "sandbox", evidence: `GITHUB_EVENT_NAME=${env.GITHUB_EVENT_NAME} (a pull-request build)` };
1702
+ }
1703
+ if (env.GITHUB_REF_TYPE === "tag") {
1704
+ return { environment: "production", evidence: "GITHUB_REF_TYPE=tag (a tag build)" };
1705
+ }
1706
+ if (!set(env.GITHUB_REF_NAME)) return null;
1707
+ return environmentFromBranch("GITHUB_REF_NAME", env.GITHUB_REF_NAME);
1708
+ }
1709
+ },
1710
+ {
1711
+ // The tier, when a job declares one, is GitLab's own word for it. Without one: a merge request is a
1712
+ // check, a tag is a release, the default branch goes live, and any other branch name decides.
1713
+ platform: "gitlab-ci",
1714
+ read: (env) => {
1715
+ if (env.GITLAB_CI !== "true") return null;
1716
+ if (set(env.CI_ENVIRONMENT_TIER)) {
1717
+ return env.CI_ENVIRONMENT_TIER === "production" ? { environment: "production", evidence: "CI_ENVIRONMENT_TIER=production" } : { environment: "sandbox", evidence: `CI_ENVIRONMENT_TIER=${env.CI_ENVIRONMENT_TIER}` };
1718
+ }
1719
+ if (set(env.CI_MERGE_REQUEST_IID)) {
1720
+ return { environment: "sandbox", evidence: "CI_MERGE_REQUEST_IID set (a merge-request build)" };
1721
+ }
1722
+ if (set(env.CI_COMMIT_TAG)) {
1723
+ return { environment: "production", evidence: "CI_COMMIT_TAG set (a tag build)" };
1724
+ }
1725
+ if (!set(env.CI_COMMIT_BRANCH)) return null;
1726
+ if (set(env.CI_DEFAULT_BRANCH) && env.CI_COMMIT_BRANCH === env.CI_DEFAULT_BRANCH) {
1727
+ return { environment: "production", evidence: `CI_COMMIT_BRANCH=${env.CI_COMMIT_BRANCH} (the default branch)` };
1728
+ }
1729
+ return environmentFromBranch("CI_COMMIT_BRANCH", env.CI_COMMIT_BRANCH);
1730
+ }
1457
1731
  }
1458
1732
  ];
1459
1733
  var HOSTED_BUILDER_PACKAGES = [
@@ -1691,7 +1965,15 @@ async function resolveConfig(options) {
1691
1965
  const fromSecretFile = await readSecretFile(options.cwd);
1692
1966
  const fromEnv = readEnv();
1693
1967
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1694
- const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
1968
+ const endpointRaw = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
1969
+ const endpointTrusted = endpointRaw === DEFAULT_ENDPOINT || options.cliEndpoint !== void 0 || fromEnv.endpoint !== void 0 || fromFile.endpoint === void 0;
1970
+ const endpoint = safeRemoteUrl(endpointRaw);
1971
+ if (endpoint === null) {
1972
+ throw new PatchstackError(
1973
+ "Patchstack endpoints must be valid HTTPS URLs, except for localhost development endpoints.",
1974
+ "CONFIG_INVALID"
1975
+ );
1976
+ }
1695
1977
  const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1696
1978
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
1697
1979
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
@@ -1703,7 +1985,7 @@ async function resolveConfig(options) {
1703
1985
  const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
1704
1986
  const environment = environmentRaw ?? inferred.environment;
1705
1987
  const environmentEvidence = inferred?.evidence ?? [];
1706
- if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
1988
+ if (siteUuid !== null && siteUuid.length > 0 && !isCanonicalUuid(siteUuid)) {
1707
1989
  throw new PatchstackError(
1708
1990
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
1709
1991
  "CONFIG_INVALID"
@@ -1726,6 +2008,7 @@ async function resolveConfig(options) {
1726
2008
  siteUrl: identity.url,
1727
2009
  siteName: identity.name,
1728
2010
  endpoint,
2011
+ endpointTrusted,
1729
2012
  timeoutMs,
1730
2013
  environment,
1731
2014
  environmentEvidence,
@@ -1736,12 +2019,12 @@ async function resolveConfig(options) {
1736
2019
  async function writeConfigFile(cwd, config) {
1737
2020
  const target = path8.join(cwd, CONFIG_FILENAME);
1738
2021
  const content = JSON.stringify(config, null, 2) + "\n";
1739
- await writeFile(target, content, "utf8");
2022
+ await writeAtomicFile(target, content, 420);
1740
2023
  return target;
1741
2024
  }
1742
2025
  async function writeSecretFile(cwd, secrets) {
1743
2026
  const target = path8.join(cwd, SECRET_FILENAME);
1744
- await writeFile(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
2027
+ await writeAtomicFile(target, JSON.stringify(secrets, null, 2) + "\n", 384);
1745
2028
  try {
1746
2029
  await chmod(target, 384);
1747
2030
  } catch {
@@ -1749,6 +2032,39 @@ async function writeSecretFile(cwd, secrets) {
1749
2032
  const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1750
2033
  return { path: target, ...ignore };
1751
2034
  }
2035
+ async function writeAtomicFile(target, content, mode) {
2036
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
2037
+ let handle;
2038
+ try {
2039
+ handle = await open(temporary, "wx", mode);
2040
+ await handle.writeFile(content, "utf8");
2041
+ await handle.sync();
2042
+ await handle.close();
2043
+ handle = void 0;
2044
+ try {
2045
+ await rename(temporary, target);
2046
+ } catch (error) {
2047
+ const code = error.code;
2048
+ if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
2049
+ try {
2050
+ await unlink(target);
2051
+ } catch (unlinkError) {
2052
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
2053
+ }
2054
+ await rename(temporary, target);
2055
+ }
2056
+ } catch (error) {
2057
+ try {
2058
+ await handle?.close();
2059
+ } catch {
2060
+ }
2061
+ try {
2062
+ await unlink(temporary);
2063
+ } catch {
2064
+ }
2065
+ throw error;
2066
+ }
2067
+ }
1752
2068
  async function ensureIgnored(cwd, entry) {
1753
2069
  const target = path8.join(cwd, ".gitignore");
1754
2070
  let existing = "";
@@ -1767,7 +2083,7 @@ async function ensureIgnored(cwd, entry) {
1767
2083
  ${entry}
1768
2084
  `;
1769
2085
  try {
1770
- await writeFile(target, existing + block, "utf8");
2086
+ await writeAtomicFile(target, existing + block, 420);
1771
2087
  } catch (err) {
1772
2088
  const code = err.code;
1773
2089
  return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
@@ -1820,8 +2136,15 @@ async function readJsonFile(cwd, filename) {
1820
2136
  const target = path8.join(cwd, filename);
1821
2137
  let raw;
1822
2138
  try {
2139
+ if ((await lstat2(target)).isSymbolicLink()) {
2140
+ throw new PatchstackError(
2141
+ `Could not read ${target}: configuration files must not be symbolic links.`,
2142
+ "CONFIG_INVALID"
2143
+ );
2144
+ }
1823
2145
  raw = await readFile8(target, "utf8");
1824
2146
  } catch (err) {
2147
+ if (err instanceof PatchstackError) throw err;
1825
2148
  if (err.code === "ENOENT") {
1826
2149
  return {};
1827
2150
  }
@@ -1866,9 +2189,6 @@ function readEnv() {
1866
2189
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
1867
2190
  };
1868
2191
  }
1869
- function isUuid(value) {
1870
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1871
- }
1872
2192
  function isEnvironment(value) {
1873
2193
  return value === "production" || value === "sandbox" || value === "local";
1874
2194
  }
@@ -1963,8 +2283,110 @@ function isEmptyStack(stack) {
1963
2283
  }
1964
2284
 
1965
2285
  // src/widget.ts
1966
- import { existsSync, readFileSync as readFileSync2, writeFileSync } from "fs";
2286
+ import { existsSync, readFileSync as readFileSync3 } from "fs";
1967
2287
  import path9 from "path";
2288
+
2289
+ // src/safe-file.ts
2290
+ import {
2291
+ chmodSync,
2292
+ closeSync,
2293
+ fsyncSync,
2294
+ lstatSync,
2295
+ mkdirSync,
2296
+ openSync,
2297
+ readFileSync as readFileSync2,
2298
+ realpathSync,
2299
+ renameSync,
2300
+ statSync,
2301
+ unlinkSync,
2302
+ writeFileSync
2303
+ } from "fs";
2304
+ import { randomUUID as randomUUID2 } from "crypto";
2305
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
2306
+ function pathError(message) {
2307
+ return Object.assign(new Error(message), { code: "EINVAL" });
2308
+ }
2309
+ function existingEntry(path10) {
2310
+ try {
2311
+ return lstatSync(path10);
2312
+ } catch (error) {
2313
+ if (error.code === "ENOENT") return null;
2314
+ throw error;
2315
+ }
2316
+ }
2317
+ function assertWritableTarget(target) {
2318
+ const entry = existingEntry(target);
2319
+ if (entry?.isSymbolicLink()) throw pathError(`refusing to replace symbolic link: ${target}`);
2320
+ if (entry && !entry.isFile()) throw pathError(`refusing to replace non-file path: ${target}`);
2321
+ return entry;
2322
+ }
2323
+ function atomicWriteFileSync(target, content, options = {}) {
2324
+ const current = assertWritableTarget(target);
2325
+ const preserveMode = options.preserveMode !== false;
2326
+ const exactMode = options.mode ?? (preserveMode && current ? current.mode & 4095 : options.createMode);
2327
+ const temporary = join(dirname(target), `.${randomUUID2()}.patchstack-tmp`);
2328
+ let descriptor = null;
2329
+ try {
2330
+ descriptor = openSync(temporary, "wx", exactMode ?? 438);
2331
+ writeFileSync(descriptor, content, options.encoding);
2332
+ fsyncSync(descriptor);
2333
+ closeSync(descriptor);
2334
+ descriptor = null;
2335
+ if (exactMode !== void 0) chmodSync(temporary, exactMode);
2336
+ try {
2337
+ renameSync(temporary, target);
2338
+ } catch (error) {
2339
+ const code = error.code;
2340
+ if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
2341
+ try {
2342
+ unlinkSync(target);
2343
+ } catch (unlinkError) {
2344
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
2345
+ }
2346
+ renameSync(temporary, target);
2347
+ }
2348
+ } catch (error) {
2349
+ if (descriptor !== null) {
2350
+ try {
2351
+ closeSync(descriptor);
2352
+ } catch {
2353
+ }
2354
+ }
2355
+ try {
2356
+ unlinkSync(temporary);
2357
+ } catch {
2358
+ }
2359
+ throw error;
2360
+ }
2361
+ }
2362
+ function projectTarget(cwd, target, allowRoot = false) {
2363
+ const logicalRoot = resolve(cwd);
2364
+ const logicalTarget = isAbsolute(target) ? resolve(target) : resolve(logicalRoot, target);
2365
+ const rel = relative(logicalRoot, logicalTarget);
2366
+ if (!allowRoot && rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
2367
+ throw pathError(`path is outside the project: ${target}`);
2368
+ }
2369
+ const root = realpathSync(logicalRoot);
2370
+ return { root, path: rel === "" ? root : join(root, rel), parts: rel === "" ? [] : rel.split(sep) };
2371
+ }
2372
+ function assertProjectParents(root, parts) {
2373
+ let current = root;
2374
+ for (const part of parts.slice(0, -1)) {
2375
+ current = join(current, part);
2376
+ const entry = existingEntry(current);
2377
+ if (entry === null) throw Object.assign(new Error(`project directory does not exist: ${current}`), { code: "ENOENT" });
2378
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
2379
+ throw pathError(`project path contains a non-directory or symbolic link: ${current}`);
2380
+ }
2381
+ }
2382
+ }
2383
+ function writeProjectFileSync(cwd, target, content, options = {}) {
2384
+ const resolved = projectTarget(cwd, target);
2385
+ assertProjectParents(resolved.root, resolved.parts);
2386
+ atomicWriteFileSync(resolved.path, content, options);
2387
+ }
2388
+
2389
+ // src/widget.ts
1968
2390
  var WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
1969
2391
  var WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
1970
2392
  var WIDGET_NEEDLE = "patchstack-widget";
@@ -2017,10 +2439,10 @@ function ensureSourceWidget(cwd, siteUuid, jsxShell = null) {
2017
2439
  if (!existsSync(file)) {
2018
2440
  return { shell: null, action: "no-shell" };
2019
2441
  }
2020
- const before = readFileSync2(file, "utf8");
2442
+ const before = readFileSync3(file, "utf8");
2021
2443
  const { html, action } = ensureWidgetInHtml(before, siteUuid);
2022
2444
  if (html !== before) {
2023
- writeFileSync(file, html);
2445
+ writeProjectFileSync(cwd, file, html, { encoding: "utf8" });
2024
2446
  }
2025
2447
  return { shell, action };
2026
2448
  }