@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.cjs CHANGED
@@ -1254,8 +1254,8 @@ function compareSegments(a, b) {
1254
1254
  var RULES = [
1255
1255
  { platform: "netlify", any: ["NETLIFY", "NETLIFY_BUILD_BASE", "DEPLOY_PRIME_URL"] },
1256
1256
  { platform: "vercel", any: ["VERCEL", "VERCEL_ENV", "VERCEL_URL"] },
1257
- { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "CLOUDFLARE_ACCOUNT_ID"] },
1258
- { platform: "aws", any: ["AWS_APP_ID", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
1257
+ { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "WORKERS_CI", "WORKERS_CI_BRANCH", "CLOUDFLARE_ACCOUNT_ID"] },
1258
+ { platform: "aws", any: ["AWS_APP_ID", "AWS_BRANCH", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
1259
1259
  { platform: "render", any: ["RENDER", "RENDER_SERVICE_ID", "RENDER_EXTERNAL_URL"] },
1260
1260
  { platform: "railway", any: ["RAILWAY_ENVIRONMENT", "RAILWAY_ENVIRONMENT_NAME", "RAILWAY_PROJECT_ID"] },
1261
1261
  { platform: "fly", any: ["FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"] },
@@ -1278,6 +1278,128 @@ function detectHostingPlatform(env = process.env) {
1278
1278
  return { platform: null, evidence: [] };
1279
1279
  }
1280
1280
 
1281
+ // src/protect/safe-origin.js
1282
+ function isSafeOrigin(value) {
1283
+ try {
1284
+ const u = new URL(value);
1285
+ if (u.protocol === "https:") return true;
1286
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
1287
+ } catch {
1288
+ return false;
1289
+ }
1290
+ }
1291
+
1292
+ // src/endpoint-policy.ts
1293
+ function assertConnectableEndpoint(config, target = config.endpoint) {
1294
+ if (config.endpointTrusted === false) {
1295
+ throw new PatchstackError(
1296
+ "The custom endpoint in .patchstackrc.json must be confirmed with --endpoint or PATCHSTACK_ENDPOINT before it can be used.",
1297
+ "CONFIG_INVALID"
1298
+ );
1299
+ }
1300
+ if (safeRemoteUrl(config.endpoint) === null) {
1301
+ throw new PatchstackError(
1302
+ "Patchstack endpoints must use HTTPS, except for localhost development endpoints.",
1303
+ "CONFIG_INVALID"
1304
+ );
1305
+ }
1306
+ let endpointOrigin;
1307
+ let targetOrigin;
1308
+ try {
1309
+ endpointOrigin = new URL(config.endpoint).origin;
1310
+ targetOrigin = new URL(target).origin;
1311
+ } catch (cause) {
1312
+ throw new PatchstackError("The configured Patchstack endpoint is not a valid URL.", "CONFIG_INVALID", cause);
1313
+ }
1314
+ if (safeRemoteUrl(target) === null || targetOrigin !== endpointOrigin) {
1315
+ throw new PatchstackError(
1316
+ "A Patchstack request resolved outside the configured endpoint origin.",
1317
+ "CONFIG_INVALID"
1318
+ );
1319
+ }
1320
+ }
1321
+ function safeRemoteUrl(value) {
1322
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
1323
+ try {
1324
+ const parsed = new URL(value);
1325
+ if (!isSafeOrigin(parsed.toString()) || parsed.username !== "" || parsed.password !== "") return null;
1326
+ return parsed.toString();
1327
+ } catch {
1328
+ return null;
1329
+ }
1330
+ }
1331
+ function safeDisplayString(value, maxLength = 512) {
1332
+ if (typeof value !== "string") return null;
1333
+ const clean = value.replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
1334
+ if (clean.length === 0) return null;
1335
+ return clean.slice(0, maxLength);
1336
+ }
1337
+ function isCanonicalUuid(value) {
1338
+ 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);
1339
+ }
1340
+ function isCredential(value) {
1341
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return false;
1342
+ for (let i = 0; i < value.length; i += 1) {
1343
+ const code = value.charCodeAt(i);
1344
+ if (code < 33 || code > 126) return false;
1345
+ }
1346
+ const separator = value.lastIndexOf("-");
1347
+ return separator > 0 && separator < value.length - 1 && /^\d+$/.test(value.slice(separator + 1));
1348
+ }
1349
+ function isHeaderValue(value, maxLength = 8192) {
1350
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return false;
1351
+ for (let i = 0; i < value.length; i += 1) {
1352
+ const code = value.charCodeAt(i);
1353
+ if (code < 33 || code > 126) return false;
1354
+ }
1355
+ return true;
1356
+ }
1357
+
1358
+ // src/bounded-response.ts
1359
+ var MAX_CONTROL_RESPONSE_BYTES = 256 * 1024;
1360
+ async function readBoundedText(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
1361
+ const stated2 = Number(response.headers?.get?.("content-length"));
1362
+ if (Number.isFinite(stated2) && stated2 > maxBytes) {
1363
+ void response.body?.cancel().catch(() => {
1364
+ });
1365
+ throw new Error(`response exceeds ${maxBytes} bytes`);
1366
+ }
1367
+ const stream = response.body;
1368
+ if (stream === null || typeof stream.getReader !== "function") {
1369
+ if (stated2 === 0) return "";
1370
+ throw new Error("response body cannot be read with a size bound");
1371
+ }
1372
+ const reader = stream.getReader();
1373
+ const chunks = [];
1374
+ let total = 0;
1375
+ try {
1376
+ for (; ; ) {
1377
+ const { done, value } = await reader.read();
1378
+ if (done) break;
1379
+ total += value.byteLength;
1380
+ if (total > maxBytes) {
1381
+ void reader.cancel().catch(() => {
1382
+ });
1383
+ throw new Error(`response exceeds ${maxBytes} bytes`);
1384
+ }
1385
+ chunks.push(value);
1386
+ }
1387
+ } finally {
1388
+ reader.releaseLock();
1389
+ }
1390
+ const bytes = new Uint8Array(total);
1391
+ let offset = 0;
1392
+ for (const chunk of chunks) {
1393
+ bytes.set(chunk, offset);
1394
+ offset += chunk.byteLength;
1395
+ }
1396
+ return new TextDecoder().decode(bytes);
1397
+ }
1398
+ async function readBoundedJson(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
1399
+ const text = await readBoundedText(response, maxBytes);
1400
+ return JSON.parse(text);
1401
+ }
1402
+
1281
1403
  // src/pulse-token.ts
1282
1404
  var TOKEN_SKEW_MS = 6e4;
1283
1405
  function buildTokenUrl(manifestEndpoint) {
@@ -1295,22 +1417,48 @@ function parsePulseAuth(credential) {
1295
1417
  if (!/^\d+$/.test(clientId)) return null;
1296
1418
  return { clientId, clientSecret: credential.slice(0, index) };
1297
1419
  }
1298
- var cached = null;
1299
- var inflight = null;
1300
- function clearPulseToken() {
1301
- cached = null;
1420
+ var cached = /* @__PURE__ */ new Map();
1421
+ var inflight = /* @__PURE__ */ new Map();
1422
+ function cacheKey(tokenUrl, credential) {
1423
+ return `${tokenUrl}
1424
+ ${credential}`;
1425
+ }
1426
+ function clearPulseToken(config) {
1427
+ if (!config || typeof config.pulseAuth !== "string") {
1428
+ cached.clear();
1429
+ inflight.clear();
1430
+ return;
1431
+ }
1432
+ try {
1433
+ const key = cacheKey(buildTokenUrl(config.endpoint), config.pulseAuth);
1434
+ cached.delete(key);
1435
+ inflight.delete(key);
1436
+ } catch {
1437
+ }
1302
1438
  }
1303
1439
  async function getPulseToken(config, fetchImpl = fetch) {
1304
1440
  if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
1305
- if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
1306
- return cached.token;
1441
+ if (config.endpointTrusted === false) return null;
1442
+ let tokenUrl;
1443
+ try {
1444
+ tokenUrl = buildTokenUrl(config.endpoint);
1445
+ } catch {
1446
+ return null;
1307
1447
  }
1308
- if (inflight !== null) return inflight;
1448
+ if (!isSafeOrigin(tokenUrl)) return null;
1449
+ const key = cacheKey(tokenUrl, config.pulseAuth);
1450
+ const existing = cached.get(key);
1451
+ if (existing !== void 0 && Date.now() < existing.expiresAt - TOKEN_SKEW_MS) {
1452
+ return existing.token;
1453
+ }
1454
+ const pending = inflight.get(key);
1455
+ if (pending !== void 0) return pending;
1309
1456
  const credentials = parsePulseAuth(config.pulseAuth);
1310
1457
  if (credentials === null) return null;
1311
- inflight = (async () => {
1458
+ let exchange;
1459
+ exchange = (async () => {
1312
1460
  try {
1313
- const response = await fetchImpl(buildTokenUrl(config.endpoint), {
1461
+ const response = await fetchImpl(tokenUrl, {
1314
1462
  method: "POST",
1315
1463
  headers: {
1316
1464
  "Content-Type": "application/json",
@@ -1325,25 +1473,27 @@ async function getPulseToken(config, fetchImpl = fetch) {
1325
1473
  signal: AbortSignal.timeout(config.timeoutMs)
1326
1474
  });
1327
1475
  if (!response.ok) return null;
1328
- const body = await response.json();
1329
- if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
1476
+ const body = await readBoundedJson(response);
1477
+ if (!isHeaderValue(body.access_token)) return null;
1330
1478
  const expiresIn = Number(body.expires_in);
1331
1479
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
1332
- cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
1480
+ cached.set(key, { token: body.access_token, expiresAt: Date.now() + ttlMs });
1333
1481
  return body.access_token;
1334
1482
  } catch {
1335
1483
  return null;
1336
1484
  } finally {
1337
- inflight = null;
1485
+ if (inflight.get(key) === exchange) inflight.delete(key);
1338
1486
  }
1339
1487
  })();
1340
- return inflight;
1488
+ inflight.set(key, exchange);
1489
+ return exchange;
1341
1490
  }
1342
1491
  async function pulseAuthHeader(config, fetchImpl = fetch) {
1343
1492
  const token = await getPulseToken(config, fetchImpl);
1344
1493
  return token === null ? {} : { Authorization: `Bearer ${token}` };
1345
1494
  }
1346
1495
  async function pulseFetch(config, url, init, fetchImpl = fetch) {
1496
+ assertConnectableEndpoint(config, url);
1347
1497
  const send = async () => {
1348
1498
  const auth = await pulseAuthHeader(config, fetchImpl);
1349
1499
  const response = await fetchImpl(url, {
@@ -1354,7 +1504,7 @@ async function pulseFetch(config, url, init, fetchImpl = fetch) {
1354
1504
  };
1355
1505
  const first = await send();
1356
1506
  if (first.response.status === 401 && first.authenticated) {
1357
- clearPulseToken();
1507
+ clearPulseToken(config);
1358
1508
  return (await send()).response;
1359
1509
  }
1360
1510
  return first.response;
@@ -1365,7 +1515,7 @@ var DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
1365
1515
  var DEFAULT_TIMEOUT_MS = 3e4;
1366
1516
  var CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
1367
1517
  function claimTokenHeader(config) {
1368
- return typeof config.claimToken === "string" && config.claimToken !== "" ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1518
+ return config.endpointTrusted !== false && isHeaderValue(config.claimToken) ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1369
1519
  }
1370
1520
  function buildEndpointUrl(base, siteUuid) {
1371
1521
  const trimmed = base.replace(/\/$/, "");
@@ -1404,9 +1554,46 @@ function validationFields(body) {
1404
1554
  if (typeof errors !== "object" || errors === null || Array.isArray(errors)) return [];
1405
1555
  return Object.keys(errors);
1406
1556
  }
1557
+ function manifestResponse(body, provisioning) {
1558
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
1559
+ const value = body;
1560
+ if (typeof value.stored !== "boolean") return null;
1561
+ const uuid = value.uuid === void 0 ? void 0 : isCanonicalUuid(value.uuid) ? value.uuid : null;
1562
+ if (uuid === null || provisioning && uuid === void 0) return null;
1563
+ if (value.api_key !== void 0 && !isCredential(value.api_key)) return null;
1564
+ if (value.manifest_id !== void 0 && (!Number.isInteger(value.manifest_id) || Number(value.manifest_id) <= 0)) return null;
1565
+ const claimValue = value.claim;
1566
+ let claim2;
1567
+ if (claimValue !== void 0) {
1568
+ if (typeof claimValue !== "object" || claimValue === null || Array.isArray(claimValue)) return null;
1569
+ const raw = claimValue;
1570
+ if (!["claimed", "owned-by-you", "owned-by-other", "rejected"].includes(String(raw.state))) return null;
1571
+ const dashboardUrl = raw.dashboard_url === void 0 || raw.dashboard_url === null ? null : safeRemoteUrl(raw.dashboard_url);
1572
+ if (raw.dashboard_url !== void 0 && raw.dashboard_url !== null && dashboardUrl === null) return null;
1573
+ claim2 = {
1574
+ state: raw.state,
1575
+ ...Number.isInteger(raw.site_id) ? { site_id: Number(raw.site_id) } : {},
1576
+ ...dashboardUrl !== null ? { dashboard_url: dashboardUrl } : {},
1577
+ ...raw.reason === "expired" || raw.reason === "invalid" ? { reason: raw.reason } : {}
1578
+ };
1579
+ }
1580
+ const result = {
1581
+ stored: value.stored,
1582
+ ...uuid !== void 0 ? { uuid } : {},
1583
+ ...typeof value.api_key === "string" ? { api_key: value.api_key } : {},
1584
+ ...Number.isInteger(value.manifest_id) ? { manifest_id: Number(value.manifest_id) } : {},
1585
+ ...safeDisplayString(value.checksum, 256) !== null ? { checksum: safeDisplayString(value.checksum, 256) } : {},
1586
+ ...safeDisplayString(value.reason) !== null ? { reason: safeDisplayString(value.reason) } : {},
1587
+ ...safeDisplayString(value.message) !== null ? { message: safeDisplayString(value.message) } : {},
1588
+ ...safeDisplayString(value.error) !== null ? { error: safeDisplayString(value.error) } : {},
1589
+ ...claim2 !== void 0 ? { claim: claim2 } : {}
1590
+ };
1591
+ return result;
1592
+ }
1407
1593
  async function postManifest(config, payload) {
1408
1594
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
1409
1595
  const timeoutMs = config.timeoutMs;
1596
+ assertConnectableEndpoint(config, url);
1410
1597
  let response;
1411
1598
  try {
1412
1599
  response = await pulseFetch(config, url, {
@@ -1434,25 +1621,27 @@ async function postManifest(config, payload) {
1434
1621
  cause
1435
1622
  );
1436
1623
  }
1437
- const text = await response.text();
1438
- let body = null;
1624
+ let text = "";
1625
+ let parsed = null;
1439
1626
  try {
1440
- body = text.length > 0 ? JSON.parse(text) : null;
1627
+ text = await readBoundedText(response);
1628
+ parsed = text.length > 0 ? JSON.parse(text) : null;
1441
1629
  } catch {
1442
- body = null;
1630
+ parsed = null;
1443
1631
  }
1632
+ const body = manifestResponse(parsed, config.siteUuid === null);
1444
1633
  if (response.status === 404) {
1445
1634
  throw new PatchstackError(
1446
- 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.",
1635
+ 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.",
1447
1636
  "SITE_NOT_FOUND"
1448
1637
  );
1449
1638
  }
1450
1639
  if (response.status === 422) {
1451
1640
  const refused2 = new PatchstackError(
1452
- body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
1641
+ safeDisplayString(parsed?.message) ?? "Patchstack rejected the manifest payload (validation failed).",
1453
1642
  "VALIDATION_ERROR"
1454
1643
  );
1455
- refused2.fields = validationFields(body);
1644
+ refused2.fields = validationFields(parsed);
1456
1645
  throw refused2;
1457
1646
  }
1458
1647
  const refused = authFailureMessage(response.status, config);
@@ -1461,12 +1650,12 @@ async function postManifest(config, payload) {
1461
1650
  }
1462
1651
  if (response.status < 200 || response.status >= 300) {
1463
1652
  throw new PatchstackError(
1464
- `Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
1653
+ `Patchstack returned ${response.status}: ${safeDisplayString(text, 200) ?? "no response details"}`,
1465
1654
  "SERVER_ERROR"
1466
1655
  );
1467
1656
  }
1468
1657
  if (body === null) {
1469
- throw new PatchstackError("Patchstack returned an empty response.", "SERVER_ERROR");
1658
+ throw new PatchstackError("Patchstack returned an unexpected response.", "SERVER_ERROR");
1470
1659
  }
1471
1660
  return body;
1472
1661
  }
@@ -1480,11 +1669,23 @@ function isTimeoutError(cause) {
1480
1669
  // src/config.ts
1481
1670
  var import_promises9 = require("fs/promises");
1482
1671
  var import_node_path8 = __toESM(require("path"), 1);
1672
+ var import_node_crypto = require("crypto");
1483
1673
 
1484
1674
  // src/environment.ts
1485
1675
  var import_node_fs = require("fs");
1486
1676
  var import_node_path6 = __toESM(require("path"), 1);
1487
1677
  var set = (value) => value !== void 0 && value !== "";
1678
+ var PRODUCTION_BRANCHES = /* @__PURE__ */ new Set([
1679
+ "main",
1680
+ "master",
1681
+ "production",
1682
+ "prod",
1683
+ "release",
1684
+ "live"
1685
+ ]);
1686
+ function environmentFromBranch(variable, branch) {
1687
+ 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)` };
1688
+ }
1488
1689
  var DISCRIMINATORS = [
1489
1690
  {
1490
1691
  platform: "vercel",
@@ -1513,6 +1714,79 @@ var DISCRIMINATORS = [
1513
1714
  if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null;
1514
1715
  return env.RAILWAY_ENVIRONMENT_NAME === "production" ? { environment: "production", evidence: "RAILWAY_ENVIRONMENT_NAME=production" } : { environment: "sandbox", evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` };
1515
1716
  }
1717
+ },
1718
+ {
1719
+ // Pages and Workers Builds both name the branch and neither names the tier.
1720
+ platform: "cloudflare",
1721
+ read: (env) => {
1722
+ if (set(env.CF_PAGES) && set(env.CF_PAGES_BRANCH)) {
1723
+ return environmentFromBranch("CF_PAGES_BRANCH", env.CF_PAGES_BRANCH);
1724
+ }
1725
+ if (set(env.WORKERS_CI) && set(env.WORKERS_CI_BRANCH)) {
1726
+ return environmentFromBranch("WORKERS_CI_BRANCH", env.WORKERS_CI_BRANCH);
1727
+ }
1728
+ return null;
1729
+ }
1730
+ },
1731
+ {
1732
+ // Amplify names the branch, and separately whether the build is a pull-request preview.
1733
+ platform: "aws",
1734
+ read: (env) => {
1735
+ if (!set(env.AWS_APP_ID) || !set(env.AWS_BRANCH)) return null;
1736
+ 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);
1737
+ }
1738
+ },
1739
+ {
1740
+ // A Deployment build carries `REPLIT_DEPLOYMENT`; the workspace carries `REPL_ID` alone. Read here,
1741
+ // before the hosted-builder rule, so a workspace build in a Replit project is not its publish step.
1742
+ platform: "replit",
1743
+ read: (env) => {
1744
+ if (set(env.REPLIT_DEPLOYMENT)) {
1745
+ return { environment: "production", evidence: "REPLIT_DEPLOYMENT set (a Replit Deployment build)" };
1746
+ }
1747
+ if (set(env.REPL_ID)) {
1748
+ return { environment: "sandbox", evidence: "REPL_ID set without REPLIT_DEPLOYMENT (the workspace)" };
1749
+ }
1750
+ return null;
1751
+ }
1752
+ },
1753
+ {
1754
+ // GitHub Pages deploys build here. A pull request checks out a merge ref whose name says nothing, so
1755
+ // the event is read first; a tag is a release; otherwise the branch name decides.
1756
+ platform: "github-actions",
1757
+ read: (env) => {
1758
+ if (env.GITHUB_ACTIONS !== "true") return null;
1759
+ if (set(env.GITHUB_EVENT_NAME) && env.GITHUB_EVENT_NAME.startsWith("pull_request")) {
1760
+ return { environment: "sandbox", evidence: `GITHUB_EVENT_NAME=${env.GITHUB_EVENT_NAME} (a pull-request build)` };
1761
+ }
1762
+ if (env.GITHUB_REF_TYPE === "tag") {
1763
+ return { environment: "production", evidence: "GITHUB_REF_TYPE=tag (a tag build)" };
1764
+ }
1765
+ if (!set(env.GITHUB_REF_NAME)) return null;
1766
+ return environmentFromBranch("GITHUB_REF_NAME", env.GITHUB_REF_NAME);
1767
+ }
1768
+ },
1769
+ {
1770
+ // The tier, when a job declares one, is GitLab's own word for it. Without one: a merge request is a
1771
+ // check, a tag is a release, the default branch goes live, and any other branch name decides.
1772
+ platform: "gitlab-ci",
1773
+ read: (env) => {
1774
+ if (env.GITLAB_CI !== "true") return null;
1775
+ if (set(env.CI_ENVIRONMENT_TIER)) {
1776
+ return env.CI_ENVIRONMENT_TIER === "production" ? { environment: "production", evidence: "CI_ENVIRONMENT_TIER=production" } : { environment: "sandbox", evidence: `CI_ENVIRONMENT_TIER=${env.CI_ENVIRONMENT_TIER}` };
1777
+ }
1778
+ if (set(env.CI_MERGE_REQUEST_IID)) {
1779
+ return { environment: "sandbox", evidence: "CI_MERGE_REQUEST_IID set (a merge-request build)" };
1780
+ }
1781
+ if (set(env.CI_COMMIT_TAG)) {
1782
+ return { environment: "production", evidence: "CI_COMMIT_TAG set (a tag build)" };
1783
+ }
1784
+ if (!set(env.CI_COMMIT_BRANCH)) return null;
1785
+ if (set(env.CI_DEFAULT_BRANCH) && env.CI_COMMIT_BRANCH === env.CI_DEFAULT_BRANCH) {
1786
+ return { environment: "production", evidence: `CI_COMMIT_BRANCH=${env.CI_COMMIT_BRANCH} (the default branch)` };
1787
+ }
1788
+ return environmentFromBranch("CI_COMMIT_BRANCH", env.CI_COMMIT_BRANCH);
1789
+ }
1516
1790
  }
1517
1791
  ];
1518
1792
  var HOSTED_BUILDER_PACKAGES = [
@@ -1750,7 +2024,15 @@ async function resolveConfig(options) {
1750
2024
  const fromSecretFile = await readSecretFile(options.cwd);
1751
2025
  const fromEnv = readEnv();
1752
2026
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1753
- const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
2027
+ const endpointRaw = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
2028
+ const endpointTrusted = endpointRaw === DEFAULT_ENDPOINT || options.cliEndpoint !== void 0 || fromEnv.endpoint !== void 0 || fromFile.endpoint === void 0;
2029
+ const endpoint = safeRemoteUrl(endpointRaw);
2030
+ if (endpoint === null) {
2031
+ throw new PatchstackError(
2032
+ "Patchstack endpoints must be valid HTTPS URLs, except for localhost development endpoints.",
2033
+ "CONFIG_INVALID"
2034
+ );
2035
+ }
1754
2036
  const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1755
2037
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
1756
2038
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
@@ -1762,7 +2044,7 @@ async function resolveConfig(options) {
1762
2044
  const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
1763
2045
  const environment = environmentRaw ?? inferred.environment;
1764
2046
  const environmentEvidence = inferred?.evidence ?? [];
1765
- if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
2047
+ if (siteUuid !== null && siteUuid.length > 0 && !isCanonicalUuid(siteUuid)) {
1766
2048
  throw new PatchstackError(
1767
2049
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
1768
2050
  "CONFIG_INVALID"
@@ -1785,6 +2067,7 @@ async function resolveConfig(options) {
1785
2067
  siteUrl: identity.url,
1786
2068
  siteName: identity.name,
1787
2069
  endpoint,
2070
+ endpointTrusted,
1788
2071
  timeoutMs,
1789
2072
  environment,
1790
2073
  environmentEvidence,
@@ -1795,12 +2078,12 @@ async function resolveConfig(options) {
1795
2078
  async function writeConfigFile(cwd, config) {
1796
2079
  const target = import_node_path8.default.join(cwd, CONFIG_FILENAME);
1797
2080
  const content = JSON.stringify(config, null, 2) + "\n";
1798
- await (0, import_promises9.writeFile)(target, content, "utf8");
2081
+ await writeAtomicFile(target, content, 420);
1799
2082
  return target;
1800
2083
  }
1801
2084
  async function writeSecretFile(cwd, secrets) {
1802
2085
  const target = import_node_path8.default.join(cwd, SECRET_FILENAME);
1803
- await (0, import_promises9.writeFile)(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
2086
+ await writeAtomicFile(target, JSON.stringify(secrets, null, 2) + "\n", 384);
1804
2087
  try {
1805
2088
  await (0, import_promises9.chmod)(target, 384);
1806
2089
  } catch {
@@ -1808,6 +2091,39 @@ async function writeSecretFile(cwd, secrets) {
1808
2091
  const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1809
2092
  return { path: target, ...ignore };
1810
2093
  }
2094
+ async function writeAtomicFile(target, content, mode) {
2095
+ const temporary = `${target}.${process.pid}.${(0, import_node_crypto.randomUUID)()}.tmp`;
2096
+ let handle;
2097
+ try {
2098
+ handle = await (0, import_promises9.open)(temporary, "wx", mode);
2099
+ await handle.writeFile(content, "utf8");
2100
+ await handle.sync();
2101
+ await handle.close();
2102
+ handle = void 0;
2103
+ try {
2104
+ await (0, import_promises9.rename)(temporary, target);
2105
+ } catch (error) {
2106
+ const code = error.code;
2107
+ if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
2108
+ try {
2109
+ await (0, import_promises9.unlink)(target);
2110
+ } catch (unlinkError) {
2111
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
2112
+ }
2113
+ await (0, import_promises9.rename)(temporary, target);
2114
+ }
2115
+ } catch (error) {
2116
+ try {
2117
+ await handle?.close();
2118
+ } catch {
2119
+ }
2120
+ try {
2121
+ await (0, import_promises9.unlink)(temporary);
2122
+ } catch {
2123
+ }
2124
+ throw error;
2125
+ }
2126
+ }
1811
2127
  async function ensureIgnored(cwd, entry) {
1812
2128
  const target = import_node_path8.default.join(cwd, ".gitignore");
1813
2129
  let existing = "";
@@ -1826,7 +2142,7 @@ async function ensureIgnored(cwd, entry) {
1826
2142
  ${entry}
1827
2143
  `;
1828
2144
  try {
1829
- await (0, import_promises9.writeFile)(target, existing + block, "utf8");
2145
+ await writeAtomicFile(target, existing + block, 420);
1830
2146
  } catch (err) {
1831
2147
  const code = err.code;
1832
2148
  return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
@@ -1879,8 +2195,15 @@ async function readJsonFile(cwd, filename) {
1879
2195
  const target = import_node_path8.default.join(cwd, filename);
1880
2196
  let raw;
1881
2197
  try {
2198
+ if ((await (0, import_promises9.lstat)(target)).isSymbolicLink()) {
2199
+ throw new PatchstackError(
2200
+ `Could not read ${target}: configuration files must not be symbolic links.`,
2201
+ "CONFIG_INVALID"
2202
+ );
2203
+ }
1882
2204
  raw = await (0, import_promises9.readFile)(target, "utf8");
1883
2205
  } catch (err) {
2206
+ if (err instanceof PatchstackError) throw err;
1884
2207
  if (err.code === "ENOENT") {
1885
2208
  return {};
1886
2209
  }
@@ -1925,9 +2248,6 @@ function readEnv() {
1925
2248
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
1926
2249
  };
1927
2250
  }
1928
- function isUuid(value) {
1929
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1930
- }
1931
2251
  function isEnvironment(value) {
1932
2252
  return value === "production" || value === "sandbox" || value === "local";
1933
2253
  }
@@ -2022,8 +2342,97 @@ function isEmptyStack(stack) {
2022
2342
  }
2023
2343
 
2024
2344
  // src/widget.ts
2345
+ var import_node_fs3 = require("fs");
2346
+ var import_node_path10 = __toESM(require("path"), 1);
2347
+
2348
+ // src/safe-file.ts
2025
2349
  var import_node_fs2 = require("fs");
2026
- var import_node_path9 = __toESM(require("path"), 1);
2350
+ var import_node_crypto2 = require("crypto");
2351
+ var import_node_path9 = require("path");
2352
+ function pathError(message) {
2353
+ return Object.assign(new Error(message), { code: "EINVAL" });
2354
+ }
2355
+ function existingEntry(path10) {
2356
+ try {
2357
+ return (0, import_node_fs2.lstatSync)(path10);
2358
+ } catch (error) {
2359
+ if (error.code === "ENOENT") return null;
2360
+ throw error;
2361
+ }
2362
+ }
2363
+ function assertWritableTarget(target) {
2364
+ const entry = existingEntry(target);
2365
+ if (entry?.isSymbolicLink()) throw pathError(`refusing to replace symbolic link: ${target}`);
2366
+ if (entry && !entry.isFile()) throw pathError(`refusing to replace non-file path: ${target}`);
2367
+ return entry;
2368
+ }
2369
+ function atomicWriteFileSync(target, content, options = {}) {
2370
+ const current = assertWritableTarget(target);
2371
+ const preserveMode = options.preserveMode !== false;
2372
+ const exactMode = options.mode ?? (preserveMode && current ? current.mode & 4095 : options.createMode);
2373
+ const temporary = (0, import_node_path9.join)((0, import_node_path9.dirname)(target), `.${(0, import_node_crypto2.randomUUID)()}.patchstack-tmp`);
2374
+ let descriptor = null;
2375
+ try {
2376
+ descriptor = (0, import_node_fs2.openSync)(temporary, "wx", exactMode ?? 438);
2377
+ (0, import_node_fs2.writeFileSync)(descriptor, content, options.encoding);
2378
+ (0, import_node_fs2.fsyncSync)(descriptor);
2379
+ (0, import_node_fs2.closeSync)(descriptor);
2380
+ descriptor = null;
2381
+ if (exactMode !== void 0) (0, import_node_fs2.chmodSync)(temporary, exactMode);
2382
+ try {
2383
+ (0, import_node_fs2.renameSync)(temporary, target);
2384
+ } catch (error) {
2385
+ const code = error.code;
2386
+ if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
2387
+ try {
2388
+ (0, import_node_fs2.unlinkSync)(target);
2389
+ } catch (unlinkError) {
2390
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
2391
+ }
2392
+ (0, import_node_fs2.renameSync)(temporary, target);
2393
+ }
2394
+ } catch (error) {
2395
+ if (descriptor !== null) {
2396
+ try {
2397
+ (0, import_node_fs2.closeSync)(descriptor);
2398
+ } catch {
2399
+ }
2400
+ }
2401
+ try {
2402
+ (0, import_node_fs2.unlinkSync)(temporary);
2403
+ } catch {
2404
+ }
2405
+ throw error;
2406
+ }
2407
+ }
2408
+ function projectTarget(cwd, target, allowRoot = false) {
2409
+ const logicalRoot = (0, import_node_path9.resolve)(cwd);
2410
+ const logicalTarget = (0, import_node_path9.isAbsolute)(target) ? (0, import_node_path9.resolve)(target) : (0, import_node_path9.resolve)(logicalRoot, target);
2411
+ const rel = (0, import_node_path9.relative)(logicalRoot, logicalTarget);
2412
+ if (!allowRoot && rel === "" || rel === ".." || rel.startsWith(`..${import_node_path9.sep}`) || (0, import_node_path9.isAbsolute)(rel)) {
2413
+ throw pathError(`path is outside the project: ${target}`);
2414
+ }
2415
+ const root = (0, import_node_fs2.realpathSync)(logicalRoot);
2416
+ return { root, path: rel === "" ? root : (0, import_node_path9.join)(root, rel), parts: rel === "" ? [] : rel.split(import_node_path9.sep) };
2417
+ }
2418
+ function assertProjectParents(root, parts) {
2419
+ let current = root;
2420
+ for (const part of parts.slice(0, -1)) {
2421
+ current = (0, import_node_path9.join)(current, part);
2422
+ const entry = existingEntry(current);
2423
+ if (entry === null) throw Object.assign(new Error(`project directory does not exist: ${current}`), { code: "ENOENT" });
2424
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
2425
+ throw pathError(`project path contains a non-directory or symbolic link: ${current}`);
2426
+ }
2427
+ }
2428
+ }
2429
+ function writeProjectFileSync(cwd, target, content, options = {}) {
2430
+ const resolved = projectTarget(cwd, target);
2431
+ assertProjectParents(resolved.root, resolved.parts);
2432
+ atomicWriteFileSync(resolved.path, content, options);
2433
+ }
2434
+
2435
+ // src/widget.ts
2027
2436
  var WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
2028
2437
  var WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
2029
2438
  var WIDGET_NEEDLE = "patchstack-widget";
@@ -2061,7 +2470,7 @@ ${indent}</body>`;
2061
2470
  }
2062
2471
  function findSourceShell(cwd) {
2063
2472
  for (const candidate of SOURCE_SHELL_CANDIDATES) {
2064
- if ((0, import_node_fs2.existsSync)(import_node_path9.default.join(cwd, candidate))) {
2473
+ if ((0, import_node_fs3.existsSync)(import_node_path10.default.join(cwd, candidate))) {
2065
2474
  return candidate;
2066
2475
  }
2067
2476
  }
@@ -2072,14 +2481,14 @@ function ensureSourceWidget(cwd, siteUuid, jsxShell = null) {
2072
2481
  if (shell === null) {
2073
2482
  return { shell: null, action: "no-shell" };
2074
2483
  }
2075
- const file = import_node_path9.default.join(cwd, shell);
2076
- if (!(0, import_node_fs2.existsSync)(file)) {
2484
+ const file = import_node_path10.default.join(cwd, shell);
2485
+ if (!(0, import_node_fs3.existsSync)(file)) {
2077
2486
  return { shell: null, action: "no-shell" };
2078
2487
  }
2079
- const before = (0, import_node_fs2.readFileSync)(file, "utf8");
2488
+ const before = (0, import_node_fs3.readFileSync)(file, "utf8");
2080
2489
  const { html, action } = ensureWidgetInHtml(before, siteUuid);
2081
2490
  if (html !== before) {
2082
- (0, import_node_fs2.writeFileSync)(file, html);
2491
+ writeProjectFileSync(cwd, file, html, { encoding: "utf8" });
2083
2492
  }
2084
2493
  return { shell, action };
2085
2494
  }