@impeccable/reviews 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +8 -5
- package/dist/bin.js +0 -0
- package/dist/cloudflare-access.js +213 -0
- package/dist/cloudflare.js +249 -0
- package/dist/fence.js +213 -18
- package/dist/focus.js +49 -21
- package/dist/host-questions.js +46 -1
- package/dist/hosted.js +71 -13
- package/dist/login.js +1 -1
- package/dist/main.js +11 -5
- package/dist/netlify-password.js +88 -0
- package/dist/playbook/cases/cloudflare-previews.md +17 -0
- package/dist/playbook/cases/monorepo.md +22 -0
- package/dist/playbook/cases/netlify-previews.md +1 -1
- package/dist/playbook/cases/netlify-setup.md +1 -1
- package/dist/playbook/cases/preview-protection.md +13 -11
- package/dist/playbook/cases/vercel-setup.md +7 -5
- package/dist/playbook.js +3 -3
- package/dist/preflight.js +5 -1
- package/dist/scan.js +111 -0
- package/dist/tools.js +296 -42
- package/dist/ui/bridge.js +6 -3
- package/dist/ui/look.js +8 -2
- package/dist/ui/screens.js +16 -9
- package/dist/vercel.js +77 -0
- package/package.json +2 -2
- package/dist/playbook/github-previews.js +0 -101
- package/dist/playbook/netlify-pattern.js +0 -24
- package/dist/playbook/netlify-previews.js +0 -144
- package/dist/playbook/probe-preview.js +0 -67
- package/dist/playbook/protection-secrets.js +0 -15
- package/dist/playbook/public-preview-request.js +0 -96
- package/dist/playbook/publication-wait.js +0 -46
- package/dist/playbook/vercel-protection.js +0 -121
package/dist/vercel.js
CHANGED
|
@@ -68,3 +68,80 @@ export async function waitForVercelAccess(vercel, deps) {
|
|
|
68
68
|
}
|
|
69
69
|
return result;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* `vercel api` on the project this folder is linked to: GET /v9/projects/{id} reads it, and PATCH changes a setting
|
|
73
|
+
* through the update-project endpoint (https://vercel.com/docs/rest-api/projects/update-an-existing-project); a team's
|
|
74
|
+
* project needs its team on the request.
|
|
75
|
+
*/
|
|
76
|
+
const projectApi = (link, change) => [
|
|
77
|
+
'api',
|
|
78
|
+
`/v9/projects/${encodeURIComponent(link.projectId)}${link.orgId.startsWith('team_') ? `?teamId=${encodeURIComponent(link.orgId)}` : ''}`,
|
|
79
|
+
...(change ? ['--method', 'PATCH', ...change] : []),
|
|
80
|
+
'--raw', '--non-interactive',
|
|
81
|
+
];
|
|
82
|
+
export const readProjectApi = (link) => projectApi(link);
|
|
83
|
+
/**
|
|
84
|
+
* Vercel Authentication on All Deployments: Vercel's login in front of every address of the project, its production
|
|
85
|
+
* domains included (https://vercel.com/docs/deployment-protection#all-deployments), as the endpoint takes it
|
|
86
|
+
* (https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication#how-to-manage-vercel-authentication-with-the-api).
|
|
87
|
+
*/
|
|
88
|
+
export const lockProjectApi = (link) => projectApi(link, ['--field', 'ssoProtection={"deploymentType":"all"}']);
|
|
89
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
90
|
+
const printedProject = async (vercel, args) => {
|
|
91
|
+
const printed = await vercel(args);
|
|
92
|
+
return record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
93
|
+
};
|
|
94
|
+
const scope = (project) => record(project?.ssoProtection)?.deploymentType;
|
|
95
|
+
/** The linked project as `vercel api` prints it: its production deployment (the production target, or one among the latest) unless every one failed or was cancelled, its Git link's production branch, its protection and its deployments' hosts. Null when the CLI could not read it. */
|
|
96
|
+
export async function readVercelProject(vercel, link) {
|
|
97
|
+
let project;
|
|
98
|
+
try {
|
|
99
|
+
project = await printedProject(vercel, readProjectApi(link));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (project?.id !== link.projectId)
|
|
105
|
+
return null;
|
|
106
|
+
const target = record(record(project.targets)?.production);
|
|
107
|
+
const latest = (Array.isArray(project.latestDeployments) ? project.latestDeployments : []).map(record).filter((d) => d !== undefined);
|
|
108
|
+
const failed = (d) => d.readyState === 'ERROR' || d.readyState === 'CANCELED';
|
|
109
|
+
const exists = (target !== undefined && !failed(target)) || latest.some((d) => d.target === 'production' && !failed(d));
|
|
110
|
+
const branch = record(project.link)?.productionBranch;
|
|
111
|
+
const protection = scope(project);
|
|
112
|
+
const hosts = [target, ...latest].flatMap((d) => (typeof d?.url === 'string' && d.url ? [d.url.replace(/^https?:\/\//, '').toLowerCase()] : []));
|
|
113
|
+
return { production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), protection: typeof protection === 'string' ? protection : null, deploymentHosts: new Set(hosts) };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The linked project before its first push: read through `vercel api`, and, with no production deployment, locked
|
|
117
|
+
* behind Vercel's login at every address, since Vercel makes a new project's first deployment Production whatever
|
|
118
|
+
* branch it builds and Standard Protection serves production domains to anyone. A project that deploys production
|
|
119
|
+
* already (live or building), or is locked already, is left as it is.
|
|
120
|
+
*/
|
|
121
|
+
export async function prepareFirstDeployment(vercel, link) {
|
|
122
|
+
const project = await readVercelProject(vercel, link);
|
|
123
|
+
if (!project)
|
|
124
|
+
return { production: 'unknown', locked: false, reason: 'The Vercel CLI could not read the project (vercel api): it is signed out, or this CLI has no api command.' };
|
|
125
|
+
const branch = project.productionBranch ? { productionBranch: project.productionBranch } : {};
|
|
126
|
+
if (project.production === 'exists')
|
|
127
|
+
return { production: 'exists', ...branch };
|
|
128
|
+
if (project.protection === 'all')
|
|
129
|
+
return { production: 'none', locked: true, ...branch };
|
|
130
|
+
try {
|
|
131
|
+
if (scope(await printedProject(vercel, lockProjectApi(link))) === 'all')
|
|
132
|
+
return { production: 'none', locked: true, ...branch };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// What the answer below says.
|
|
136
|
+
}
|
|
137
|
+
return { production: 'none', locked: false, ...branch, reason: "Vercel did not put its login in front of the project's addresses: that takes the Member role or Project Administrator." };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The hosts of the linked project's deployments while Vercel's login stands in front of every address of it, read
|
|
141
|
+
* now: a Production deployment GitHub lists for a pushed branch is its preview only when it is one of these. Empty
|
|
142
|
+
* when the project is not locked at every address, or could not be read.
|
|
143
|
+
*/
|
|
144
|
+
export async function lockedDeploymentHosts(vercel, link) {
|
|
145
|
+
const project = await readVercelProject(vercel, link);
|
|
146
|
+
return project?.protection === 'all' ? project.deploymentHosts : new Set();
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@impeccable/reviews",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Set up a repository for Impeccable for PRs from your terminal.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const LIMIT = 20;
|
|
5
|
-
const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
|
|
6
|
-
const FAILURE = 'GitHub preview evidence could not be read completely.';
|
|
7
|
-
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
8
|
-
function record(value) {
|
|
9
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
-
? value
|
|
11
|
-
: undefined;
|
|
12
|
-
}
|
|
13
|
-
function positiveInteger(value) {
|
|
14
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
15
|
-
}
|
|
16
|
-
function sha(value) {
|
|
17
|
-
return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
18
|
-
}
|
|
19
|
-
function previewUrl(value) {
|
|
20
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
|
|
21
|
-
return null;
|
|
22
|
-
try {
|
|
23
|
-
const url = new URL(value);
|
|
24
|
-
if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
|
|
25
|
-
return null;
|
|
26
|
-
return url.href;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
|
|
33
|
-
export async function readHostedPreviews(owner, repo, dependencies = {}) {
|
|
34
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
35
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
36
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
37
|
-
}
|
|
38
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
39
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
40
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
41
|
-
});
|
|
42
|
-
return stdout;
|
|
43
|
-
});
|
|
44
|
-
const base = `/repos/${owner}/${repo}`;
|
|
45
|
-
async function get(route) {
|
|
46
|
-
if (dependencies.signal?.aborted)
|
|
47
|
-
throw new Error(FAILURE);
|
|
48
|
-
const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
49
|
-
if (dependencies.signal?.aborted)
|
|
50
|
-
throw new Error(FAILURE);
|
|
51
|
-
const data = JSON.parse(stdout);
|
|
52
|
-
if (!Array.isArray(data))
|
|
53
|
-
throw new Error(FAILURE);
|
|
54
|
-
return data.slice(0, LIMIT);
|
|
55
|
-
}
|
|
56
|
-
try {
|
|
57
|
-
const [deployments, pulls] = await Promise.all([
|
|
58
|
-
get(`${base}/deployments?per_page=${LIMIT}`),
|
|
59
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
|
|
60
|
-
]);
|
|
61
|
-
let incomplete = false;
|
|
62
|
-
const evidence = [];
|
|
63
|
-
for (const entry of deployments) {
|
|
64
|
-
if (dependencies.signal?.aborted) {
|
|
65
|
-
incomplete = true;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
const deployment = record(entry);
|
|
69
|
-
if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
|
|
70
|
-
continue;
|
|
71
|
-
let latest;
|
|
72
|
-
try {
|
|
73
|
-
latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
incomplete = true;
|
|
77
|
-
if (dependencies.signal?.aborted)
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
const status = STATES.find((state) => state === latest?.state) ?? null;
|
|
81
|
-
const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
|
|
82
|
-
&& !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
|
|
83
|
-
const pullRequests = [];
|
|
84
|
-
for (const item of pulls) {
|
|
85
|
-
const pull = record(item);
|
|
86
|
-
if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
|
|
87
|
-
&& record(pull.head)?.sha === deployment.sha) {
|
|
88
|
-
pullRequests.push({ number: pull.number, state: pull.state });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
|
|
92
|
-
previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
|
|
93
|
-
}
|
|
94
|
-
incomplete ||= dependencies.signal?.aborted ?? false;
|
|
95
|
-
return { coverage: COVERAGE, deployments: evidence,
|
|
96
|
-
...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
|
|
2
|
-
export function netlifyPatternError(body, observed) {
|
|
3
|
-
const pattern = body.previewUrlPattern;
|
|
4
|
-
if (typeof pattern !== 'string')
|
|
5
|
-
return null;
|
|
6
|
-
let netlify = /netlify\.app|<site>/i.test(pattern);
|
|
7
|
-
try {
|
|
8
|
-
const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
|
|
9
|
-
netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
|
|
10
|
-
}
|
|
11
|
-
catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
|
|
12
|
-
if (!netlify)
|
|
13
|
-
return null;
|
|
14
|
-
const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
|
|
15
|
-
if (!match || match[1].length > 63)
|
|
16
|
-
return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
|
|
17
|
-
if (Object.hasOwn(body, 'appKey'))
|
|
18
|
-
return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
|
|
19
|
-
if (body.intendedMethod !== 'preview-deploy')
|
|
20
|
-
return 'Save a Netlify pattern with intendedMethod preview-deploy.';
|
|
21
|
-
if (!observed.has(pattern))
|
|
22
|
-
return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const FAILURE = 'Netlify preview evidence could not be read completely.';
|
|
5
|
-
const COVERAGE = 'Only the 20 most recently updated pull requests, the newest 100 statuses on each exact head SHA, and the 100 most recently updated repository issue comments are checked. Context-derived URLs do not prove the application rendered. Bot comments are historical candidates, not current-head deployment proof. Missing evidence does not prove previews are absent.';
|
|
6
|
-
const SITE = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
-
const CONTEXT = new RegExp(`^netlify/(${SITE})/deploy-preview$`);
|
|
8
|
-
const PREVIEW_HOST = new RegExp(`^deploy-preview-([1-9][0-9]*)--(${SITE})\\.netlify\\.app$`);
|
|
9
|
-
const STATES = ['success', 'pending', 'failure', 'error'];
|
|
10
|
-
function record(value) {
|
|
11
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
-
? value : undefined;
|
|
13
|
-
}
|
|
14
|
-
function cleanUrl(value) {
|
|
15
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/i.test(value))
|
|
16
|
-
return;
|
|
17
|
-
try {
|
|
18
|
-
const url = new URL(value);
|
|
19
|
-
if (!url.username && !url.password && !url.port && !url.search && !url.hash)
|
|
20
|
-
return url;
|
|
21
|
-
}
|
|
22
|
-
catch { /* Discard unsafe evidence without returning it. */ }
|
|
23
|
-
}
|
|
24
|
-
function urls(pull, site) {
|
|
25
|
-
if (`deploy-preview-${pull.number}--${site}`.length > 63)
|
|
26
|
-
return;
|
|
27
|
-
return {
|
|
28
|
-
previewUrl: `https://deploy-preview-${pull.number}--${site}.netlify.app/`,
|
|
29
|
-
previewUrlPattern: `https://deploy-preview-{pr}--${site}.netlify.app`,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function validTarget(value, pull, site) {
|
|
33
|
-
if (value === null || value === undefined || value === '')
|
|
34
|
-
return true;
|
|
35
|
-
const target = cleanUrl(value);
|
|
36
|
-
if (!target)
|
|
37
|
-
return false;
|
|
38
|
-
if (target.hostname === `deploy-preview-${pull.number}--${site}.netlify.app` && target.pathname === '/')
|
|
39
|
-
return true;
|
|
40
|
-
return target.hostname === 'app.netlify.com'
|
|
41
|
-
&& new RegExp(`^/(?:sites|projects)/${site}/deploys(?:/[a-zA-Z0-9_-]+)?/?$`).test(target.pathname);
|
|
42
|
-
}
|
|
43
|
-
/** Read a bounded, repository-scoped projection; never expose comments, command errors or credentials. */
|
|
44
|
-
export async function readNetlifyPreviews(owner, repo, dependencies = {}) {
|
|
45
|
-
const previews = [];
|
|
46
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
47
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
48
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
49
|
-
}
|
|
50
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
51
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
52
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
53
|
-
});
|
|
54
|
-
return stdout;
|
|
55
|
-
});
|
|
56
|
-
const base = `/repos/${owner}/${repo}`;
|
|
57
|
-
async function get(route, limit) {
|
|
58
|
-
if (dependencies.signal?.aborted)
|
|
59
|
-
throw new Error();
|
|
60
|
-
const raw = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
61
|
-
if (dependencies.signal?.aborted)
|
|
62
|
-
throw new Error();
|
|
63
|
-
const data = JSON.parse(raw);
|
|
64
|
-
if (!Array.isArray(data))
|
|
65
|
-
throw new Error();
|
|
66
|
-
return data.slice(0, limit);
|
|
67
|
-
}
|
|
68
|
-
const [pullRead, commentRead] = await Promise.allSettled([
|
|
69
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=20`, 20),
|
|
70
|
-
get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`, 100),
|
|
71
|
-
]);
|
|
72
|
-
if (pullRead.status === 'rejected')
|
|
73
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
74
|
-
let incomplete = false;
|
|
75
|
-
const warnings = commentRead.status === 'rejected'
|
|
76
|
-
? ['Historical Netlify bot comments could not be read. Current-head status evidence is still checked.'] : [];
|
|
77
|
-
const pulls = new Map();
|
|
78
|
-
for (const item of pullRead.value) {
|
|
79
|
-
const pull = record(item);
|
|
80
|
-
const headSha = record(pull?.head)?.sha;
|
|
81
|
-
if (!pull || typeof pull.number !== 'number' || !Number.isSafeInteger(pull.number) || pull.number <= 0
|
|
82
|
-
|| (pull.state !== 'open' && pull.state !== 'closed') || typeof headSha !== 'string' || !/^[a-f0-9]{40}$/.test(headSha))
|
|
83
|
-
continue;
|
|
84
|
-
const issueUrl = `https://api.github.com${base}/issues/${pull.number}`;
|
|
85
|
-
if (!pulls.has(issueUrl))
|
|
86
|
-
pulls.set(issueUrl, { number: pull.number, state: pull.state, headSha });
|
|
87
|
-
}
|
|
88
|
-
const seen = new Set();
|
|
89
|
-
const statusesBySha = new Map();
|
|
90
|
-
for (const pull of pulls.values()) {
|
|
91
|
-
if (dependencies.signal?.aborted) {
|
|
92
|
-
incomplete = true;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
if (!statusesBySha.has(pull.headSha)) {
|
|
97
|
-
statusesBySha.set(pull.headSha, get(`${base}/commits/${pull.headSha}/statuses?per_page=100`, 100));
|
|
98
|
-
}
|
|
99
|
-
for (const item of await statusesBySha.get(pull.headSha)) {
|
|
100
|
-
const status = record(item);
|
|
101
|
-
const site = typeof status?.context === 'string' ? CONTEXT.exec(status.context)?.[1] : undefined;
|
|
102
|
-
if (!site || seen.has(`${pull.number}:${site}`))
|
|
103
|
-
continue;
|
|
104
|
-
// GitHub returns newest first: even an invalid latest entry must hide older success.
|
|
105
|
-
seen.add(`${pull.number}:${site}`);
|
|
106
|
-
const state = STATES.find((state) => state === status?.state);
|
|
107
|
-
const canonical = urls(pull, site);
|
|
108
|
-
if (!state || !canonical || !validTarget(status?.target_url, pull, site))
|
|
109
|
-
continue;
|
|
110
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
111
|
-
status: state, ...canonical, source: 'commit-status', matchesHead: true });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
incomplete = true;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!dependencies.signal?.aborted && commentRead.status === 'fulfilled') {
|
|
119
|
-
for (const item of commentRead.value) {
|
|
120
|
-
const comment = record(item);
|
|
121
|
-
const author = record(comment?.user);
|
|
122
|
-
const pull = typeof comment?.issue_url === 'string' ? pulls.get(comment.issue_url) : undefined;
|
|
123
|
-
if (!pull || author?.login !== 'netlify[bot]' || author.type !== 'Bot'
|
|
124
|
-
|| typeof comment?.body !== 'string' || comment.body.length > 65_536)
|
|
125
|
-
continue;
|
|
126
|
-
for (const match of comment.body.matchAll(/https:\/\/[^\s<>"'()[\]`]+/gi)) {
|
|
127
|
-
const url = cleanUrl(match[0].replace(/[.,;:]+$/, ''));
|
|
128
|
-
const parts = url ? PREVIEW_HOST.exec(url.hostname) : null;
|
|
129
|
-
if (!url || url.pathname !== '/' || !parts || parts[1] !== String(pull.number))
|
|
130
|
-
continue;
|
|
131
|
-
const site = parts[2];
|
|
132
|
-
const canonical = urls(pull, site);
|
|
133
|
-
if (!canonical || seen.has(`${pull.number}:${site}`))
|
|
134
|
-
continue;
|
|
135
|
-
seen.add(`${pull.number}:${site}`);
|
|
136
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
137
|
-
status: null, ...canonical, source: 'bot-comment', matchesHead: false });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (dependencies.signal?.aborted)
|
|
142
|
-
incomplete = true;
|
|
143
|
-
return { coverage: COVERAGE, previews, ...(warnings.length ? { warnings } : {}), ...(incomplete ? { error: FAILURE } : {}) };
|
|
144
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
function redirectGate(location, source) {
|
|
2
|
-
if (!location)
|
|
3
|
-
return 'redirected';
|
|
4
|
-
try {
|
|
5
|
-
const target = new URL(location, source);
|
|
6
|
-
if (target.protocol !== 'https:')
|
|
7
|
-
return 'redirected';
|
|
8
|
-
if (target.origin === 'https://vercel.com' && target.pathname === '/sso-api'
|
|
9
|
-
&& target.searchParams.has('url'))
|
|
10
|
-
return 'vercel-protection';
|
|
11
|
-
if ((target.hostname === 'vercel.com' || target.hostname.endsWith('.vercel.com'))
|
|
12
|
-
&& /^\/login(?:\/|$)/.test(target.pathname))
|
|
13
|
-
return 'vercel-protection';
|
|
14
|
-
if (target.hostname.endsWith('.cloudflareaccess.com'))
|
|
15
|
-
return 'cloudflare-access';
|
|
16
|
-
if (target.origin === source.origin && /^\/(?:login|sign-in|signin)(?:\/|$)/.test(target.pathname)) {
|
|
17
|
-
return 'app-sign-in';
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
// A malformed redirect reveals nothing about preview protection.
|
|
22
|
-
}
|
|
23
|
-
return 'redirected';
|
|
24
|
-
}
|
|
25
|
-
/** An unauthenticated HTTP check only: reachable does not prove this is the intended app. */
|
|
26
|
-
export async function probePreview(url, dependencies = {}) {
|
|
27
|
-
let target;
|
|
28
|
-
try {
|
|
29
|
-
target = new URL(url);
|
|
30
|
-
if (!isPlatformPreview(target)
|
|
31
|
-
|| /[?#\\\s]/.test(url) || !/^https:\/\//i.test(url))
|
|
32
|
-
throw new Error();
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { status: null, gate: 'unavailable', error: 'invalid-preview-url' };
|
|
36
|
-
}
|
|
37
|
-
const controller = new AbortController();
|
|
38
|
-
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
39
|
-
const signal = dependencies.signal
|
|
40
|
-
? AbortSignal.any([controller.signal, dependencies.signal])
|
|
41
|
-
: controller.signal;
|
|
42
|
-
try {
|
|
43
|
-
signal.throwIfAborted();
|
|
44
|
-
const response = dependencies.fetch ? await dependencies.fetch(target, {
|
|
45
|
-
method: 'GET',
|
|
46
|
-
headers: { accept: 'text/html' },
|
|
47
|
-
credentials: 'omit',
|
|
48
|
-
redirect: 'manual',
|
|
49
|
-
signal,
|
|
50
|
-
}) : await publicPreviewRequest(target, signal);
|
|
51
|
-
const status = response.status;
|
|
52
|
-
const gate = status === 401 && response.headers.get('x-vercel-error') === 'UNAUTHORIZED'
|
|
53
|
-
? 'vercel-protection' : status >= 300 && status < 400
|
|
54
|
-
? redirectGate(response.headers.get('location'), target)
|
|
55
|
-
: status >= 200 && status < 300 ? 'reachable' : 'unavailable';
|
|
56
|
-
// Bodies and redirect details may contain credentials; never read or return them.
|
|
57
|
-
await response.body?.cancel().catch(() => undefined);
|
|
58
|
-
return { status, gate };
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return { status: null, gate: 'unavailable', error: 'request-failed' };
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
64
|
-
clearTimeout(timeout);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
import { isPlatformPreview, publicPreviewRequest } from './public-preview-request.js';
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Register credential-shaped write fields before a message can be recorded.
|
|
2
|
-
* Do not trust the requested path: even a refused or malformed destination must
|
|
3
|
-
* keep its supplied credentials private. Values remain unchanged in memory.
|
|
4
|
-
*/
|
|
5
|
-
export function protectionWriteSecrets(input) {
|
|
6
|
-
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
7
|
-
return [];
|
|
8
|
-
const body = input.body;
|
|
9
|
-
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
10
|
-
return [];
|
|
11
|
-
return ['value', 'clientId', 'clientSecret']
|
|
12
|
-
.map((key) => body[key])
|
|
13
|
-
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
14
|
-
.sort((a, b) => b.length - a.length);
|
|
15
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { lookup } from 'node:dns/promises';
|
|
2
|
-
import { request } from 'node:https';
|
|
3
|
-
import { BlockList, isIP } from 'node:net';
|
|
4
|
-
const blockedV4 = new BlockList();
|
|
5
|
-
for (const [address, prefix] of [
|
|
6
|
-
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
7
|
-
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
8
|
-
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
|
|
9
|
-
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
10
|
-
])
|
|
11
|
-
blockedV4.addSubnet(address, prefix, 'ipv4');
|
|
12
|
-
const publicV6 = new BlockList();
|
|
13
|
-
publicV6.addSubnet('2000::', 3, 'ipv6');
|
|
14
|
-
const blockedV6 = new BlockList();
|
|
15
|
-
// Conservatively exclude special-purpose, transition and documentation prefixes.
|
|
16
|
-
for (const [address, prefix] of [
|
|
17
|
-
['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
18
|
-
])
|
|
19
|
-
blockedV6.addSubnet(address, prefix, 'ipv6');
|
|
20
|
-
export function isPlatformPreview(target) {
|
|
21
|
-
return target.protocol === 'https:' && !target.username && !target.password && !target.port
|
|
22
|
-
&& !/[?#]/.test(target.href)
|
|
23
|
-
&& /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
|
|
24
|
-
}
|
|
25
|
-
function isPublicAddress(value) {
|
|
26
|
-
if (!value || typeof value.address !== 'string' || value.address.includes('%'))
|
|
27
|
-
return false;
|
|
28
|
-
const family = isIP(value.address);
|
|
29
|
-
if (family !== value.family)
|
|
30
|
-
return false;
|
|
31
|
-
if (family === 4)
|
|
32
|
-
return !blockedV4.check(value.address, 'ipv4');
|
|
33
|
-
return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
|
|
34
|
-
}
|
|
35
|
-
/** Resolve once, validate every answer, then connect only to the pinned public addresses. */
|
|
36
|
-
export async function publicPreviewRequest(target, signal, dependencies = {}) {
|
|
37
|
-
try {
|
|
38
|
-
if (!isPlatformPreview(target))
|
|
39
|
-
throw new Error();
|
|
40
|
-
signal.throwIfAborted();
|
|
41
|
-
let abortDns = () => { };
|
|
42
|
-
const aborted = new Promise((_resolve, reject) => {
|
|
43
|
-
abortDns = () => reject(new Error());
|
|
44
|
-
signal.addEventListener('abort', abortDns, { once: true });
|
|
45
|
-
});
|
|
46
|
-
const addresses = await Promise.race([
|
|
47
|
-
(dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
|
|
48
|
-
]).finally(() => signal.removeEventListener('abort', abortDns));
|
|
49
|
-
signal.throwIfAborted();
|
|
50
|
-
if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
|
|
51
|
-
throw new Error();
|
|
52
|
-
const pinned = addresses.map(({ address, family }) => ({ address, family }));
|
|
53
|
-
return await new Promise((resolve, reject) => {
|
|
54
|
-
const options = {
|
|
55
|
-
method: 'GET', headers: { accept: 'text/html' }, signal,
|
|
56
|
-
agent: false, autoSelectFamily: true, rejectUnauthorized: true,
|
|
57
|
-
lookup(hostname, options, callback) {
|
|
58
|
-
if (hostname !== target.hostname)
|
|
59
|
-
return callback(new Error('Preview request failed'), '');
|
|
60
|
-
if (options.all)
|
|
61
|
-
callback(null, pinned.map((address) => ({ ...address })));
|
|
62
|
-
else
|
|
63
|
-
callback(null, pinned[0].address, pinned[0].family);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
const req = (dependencies.request ?? request)(target, options, (response) => {
|
|
67
|
-
response.once('error', () => { });
|
|
68
|
-
try {
|
|
69
|
-
const status = response.statusCode;
|
|
70
|
-
const headers = new Headers();
|
|
71
|
-
for (const name of ['location', 'x-vercel-error']) {
|
|
72
|
-
const value = response.headers[name];
|
|
73
|
-
if (typeof value === 'string')
|
|
74
|
-
headers.set(name, value);
|
|
75
|
-
}
|
|
76
|
-
if (!status)
|
|
77
|
-
reject(new Error());
|
|
78
|
-
else
|
|
79
|
-
resolve({ status, headers, body: null });
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
reject(new Error());
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
response.destroy();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
req.once('error', () => reject(new Error()));
|
|
89
|
-
req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
|
|
90
|
-
req.end();
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw new Error('Preview request failed');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
-
const INTERVAL_MS = 30_000;
|
|
3
|
-
const DEADLINE_MS = 8 * 60_000;
|
|
4
|
-
/** One publication deadline per setup PR, including time spent checking the preview. */
|
|
5
|
-
export function createPublicationWait(dependencies = {}) {
|
|
6
|
-
const now = dependencies.now ?? (() => performance.now());
|
|
7
|
-
const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
8
|
-
const starts = new Map();
|
|
9
|
-
return {
|
|
10
|
-
begin(pr) {
|
|
11
|
-
if (!starts.has(pr))
|
|
12
|
-
starts.set(pr, now());
|
|
13
|
-
},
|
|
14
|
-
remainingMs(pr) {
|
|
15
|
-
const start = starts.get(pr);
|
|
16
|
-
return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
|
|
17
|
-
},
|
|
18
|
-
async wait(pr, signal) {
|
|
19
|
-
const start = starts.get(pr);
|
|
20
|
-
if (start === undefined)
|
|
21
|
-
return null;
|
|
22
|
-
const result = (status) => {
|
|
23
|
-
const elapsed = Math.max(0, now() - start);
|
|
24
|
-
return {
|
|
25
|
-
status,
|
|
26
|
-
waited_seconds: Math.floor(elapsed / 1000),
|
|
27
|
-
remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
if (signal?.aborted)
|
|
31
|
-
return result('cancelled');
|
|
32
|
-
const remaining = DEADLINE_MS - (now() - start);
|
|
33
|
-
if (remaining <= 0)
|
|
34
|
-
return result('timed-out');
|
|
35
|
-
try {
|
|
36
|
-
await pause(Math.min(INTERVAL_MS, remaining), signal);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return result('cancelled');
|
|
40
|
-
}
|
|
41
|
-
if (signal?.aborted)
|
|
42
|
-
return result('cancelled');
|
|
43
|
-
return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|