@impeccable/reviews 0.1.0 → 0.1.2
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 +29 -8
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +321 -0
- package/dist/cloudflare.js +291 -0
- package/dist/fence.js +309 -46
- package/dist/focus.js +49 -21
- package/dist/host-questions.js +47 -1
- package/dist/hosted.js +95 -16
- package/dist/log.js +6 -1
- package/dist/login.js +9 -7
- package/dist/main.js +86 -27
- package/dist/netlify-password.js +89 -0
- package/dist/netlify.js +4 -1
- 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 +2 -2
- package/dist/playbook/cases/preview-protection.md +13 -11
- package/dist/playbook/cases/vercel-setup.md +7 -5
- package/dist/playbook.js +4 -3
- package/dist/preflight.js +56 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/scan.js +111 -0
- package/dist/tools.js +337 -63
- package/dist/ui/bridge.js +6 -3
- package/dist/ui/look.js +21 -7
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +31 -25
- package/dist/vercel.js +77 -0
- package/package.json +3 -2
package/dist/focus.js
CHANGED
|
@@ -6,54 +6,82 @@
|
|
|
6
6
|
*/
|
|
7
7
|
/** The state items and checkup checks this setup works on. */
|
|
8
8
|
const ITEMS = new Set(['scan', 'preview', 'signin']);
|
|
9
|
-
/** The
|
|
10
|
-
const LOCK = { item: 'preview-protection', card: 'protection',
|
|
9
|
+
/** The locks in front of the previews this setup gets past, Vercel's login, Netlify's password and Cloudflare Access, by the host the state's item, card and check name. Where the lock is one of theirs they are part of the setup; for any other they are left alone. */
|
|
10
|
+
const LOCK = { item: 'preview-protection', card: 'protection', hosts: ['Vercel', 'Netlify', 'Cloudflare Access'] };
|
|
11
|
+
/** The apps question, as the state's item and card and the write name it: part of the setup only on a repository whose scan proposed several apps, which is where the state has the item (the monorepo case). */
|
|
12
|
+
const APPS = { item: 'apps', card: 'apps', write: 'apps' };
|
|
11
13
|
/** The setup page's cards this setup covers, by their key in the state's `steps`: every other card is not part of it. */
|
|
12
14
|
const CARDS = new Set(['installed', 'preview', 'signin', 'done']);
|
|
13
15
|
/** The writes this setup makes, by their key in the instructions' `endpoints` and `writes`; `previewUrlPattern` saves the address of a host's previews that the host does not report to GitHub. */
|
|
14
16
|
const WRITES = new Set(['preview', 'testAccounts', 'connectionCheck', 'latestPullCheck', 'previewUrlPattern']);
|
|
15
17
|
/** What the model reads of the instructions; `steps`, `rules`, `endpoints` and `writes` are trimmed further. */
|
|
16
18
|
const INSTRUCTIONS = ['product', 'goal', 'signIn', 'buildSettings', 'endpoints', 'writes', 'steps', 'rules'];
|
|
17
|
-
/**
|
|
18
|
-
const
|
|
19
|
+
/** The words of a step or rule about a card this setup leaves alone: the design contract or profile, personas, review modules and settings, exclusions, preview protection (which the case reads from the state's item), preview credentials, the address pattern; and several apps, except in the monorepo case. */
|
|
20
|
+
const OTHER_CARD_WORDS = [String.raw `\bprofile\b`, String.raw `\bcontract\b`, 'persona', String.raw `\bmodules?\b`, 'exclu', 'protect', 'bypass', 'Cloudflare Access', 'review.settings', 'reviewSettings', 'preview-auth', 'previewAuth', 'pattern'];
|
|
21
|
+
const otherCards = (apps) => new RegExp([...(apps ? [] : [String.raw `\bapps\b`]), ...OTHER_CARD_WORDS].join('|'), 'i');
|
|
19
22
|
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
20
23
|
const only = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
|
|
21
24
|
const without = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const withApps = (keys, key, apps) => (apps ? new Set([...keys, key]) : keys);
|
|
26
|
+
const records = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
|
|
27
|
+
const reposOf = (answer) => records(record(answer)?.repos);
|
|
28
|
+
/** A repository of the state or the checkup whose scan proposed several apps: it has the apps item, or the apps check, to answer or answered. */
|
|
29
|
+
const hasApps = (repo) => Boolean(record(repo.items)?.[APPS.item]) || records(repo.checks).some((check) => check.key === APPS.item);
|
|
30
|
+
/** Whether a repository in the state or the checkup has several apps. */
|
|
31
|
+
export function severalApps(answer) {
|
|
32
|
+
return reposOf(answer).some(hasApps);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether the state or the checkup shows a card of this setup `not-selected` on `repo`, a repository with several apps.
|
|
36
|
+
* The command's token covers the apps there were when the person signed in, so this is an app the scan proposed later.
|
|
37
|
+
*/
|
|
38
|
+
export function appsLeftOut(answer, repo) {
|
|
39
|
+
const out = (status) => status === 'not-selected';
|
|
40
|
+
return reposOf(answer).some((r) => String(r.name).toLowerCase() === repo.toLowerCase() && hasApps(r)
|
|
41
|
+
&& (Object.entries(record(r.items) ?? {}).some(([key, item]) => ITEMS.has(key) && (out(record(item)?.status) || records(record(item)?.perApp).some((app) => out(app.status))))
|
|
42
|
+
|| records(r.checks).some((check) => ITEMS.has(String(check.key)) && out(check.state))));
|
|
43
|
+
}
|
|
44
|
+
/** GET /api/setup/instructions, trimmed to this setup; with `apps`, for a state with several apps, the apps write and the lines about several apps stay. */
|
|
45
|
+
export function focusInstructions(instructions, { apps = false } = {}) {
|
|
24
46
|
const all = record(instructions);
|
|
25
47
|
if (!all)
|
|
26
48
|
return instructions;
|
|
49
|
+
const writes = withApps(WRITES, APPS.write, apps);
|
|
50
|
+
const other = otherCards(apps);
|
|
27
51
|
const trim = (key, value) => {
|
|
28
52
|
if (key === 'endpoints' || key === 'writes')
|
|
29
|
-
return record(value) ? only(record(value),
|
|
53
|
+
return record(value) ? only(record(value), writes) : value;
|
|
30
54
|
if (key === 'steps' || key === 'rules')
|
|
31
|
-
return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !
|
|
55
|
+
return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !other.test(line)) : value;
|
|
32
56
|
return value;
|
|
33
57
|
};
|
|
34
58
|
return Object.fromEntries(INSTRUCTIONS.filter((key) => key in all).map((key) => [key, trim(key, all[key])]));
|
|
35
59
|
}
|
|
36
|
-
/** Whether a state item is
|
|
37
|
-
const
|
|
38
|
-
/** GET /api/setup/state, trimmed to this setup: each repository's scan, preview and sign-in, the lock in front of its previews when it is
|
|
60
|
+
/** Whether a state item is a lock this setup gets past. */
|
|
61
|
+
const hostLock = (item) => LOCK.hosts.includes(String(record(item)?.host));
|
|
62
|
+
/** GET /api/setup/state, trimmed to this setup: each repository's scan, preview and sign-in, its apps item where the scan proposed several apps, the lock in front of its previews when it is one this setup gets past, and the titles of the cards left alone. */
|
|
39
63
|
export function focusState(state) {
|
|
40
64
|
const all = record(state);
|
|
41
65
|
if (!all)
|
|
42
66
|
return state;
|
|
43
67
|
const steps = Array.isArray(all.steps) ? all.steps.map(record) : [];
|
|
44
|
-
// The lock's card is part of the setup only when every lock the scan read is
|
|
68
|
+
// The lock's card is part of the setup only when every lock the scan read is one this setup gets past: another host's keeps it among the cards left alone.
|
|
45
69
|
const locks = Array.isArray(all.repos) ? all.repos.map((r) => record(record(r)?.items)?.[LOCK.item]).filter(Boolean) : [];
|
|
46
|
-
const
|
|
47
|
-
const
|
|
70
|
+
const hostsOnly = locks.length > 0 && locks.every(hostLock);
|
|
71
|
+
const apps = severalApps(all);
|
|
72
|
+
const cards = withApps(CARDS, APPS.card, apps);
|
|
73
|
+
const items = withApps(ITEMS, APPS.item, apps);
|
|
74
|
+
const writes = withApps(WRITES, APPS.write, apps);
|
|
75
|
+
const outside = steps.filter((step) => step && !cards.has(String(step.key)) && !(hostsOnly && step.key === LOCK.card) && typeof step.title === 'string').map((step) => step.title);
|
|
48
76
|
const repo = (value) => {
|
|
49
77
|
const r = record(value);
|
|
50
78
|
if (!r)
|
|
51
79
|
return value;
|
|
52
|
-
const
|
|
80
|
+
const own = record(r.items);
|
|
53
81
|
return {
|
|
54
82
|
...r,
|
|
55
|
-
...(
|
|
56
|
-
...(record(r.endpoints) ? { endpoints: only(record(r.endpoints),
|
|
83
|
+
...(own ? { items: Object.fromEntries(Object.entries(own).filter(([key, item]) => items.has(key) || (key === LOCK.item && hostLock(item)))) } : {}),
|
|
84
|
+
...(record(r.endpoints) ? { endpoints: only(record(r.endpoints), writes) } : {}),
|
|
57
85
|
};
|
|
58
86
|
};
|
|
59
87
|
return {
|
|
@@ -64,9 +92,9 @@ export function focusState(state) {
|
|
|
64
92
|
}
|
|
65
93
|
/**
|
|
66
94
|
* GET /api/setup/checkup, trimmed the same way: each repository's checks of the
|
|
67
|
-
* scan, the preview
|
|
68
|
-
*
|
|
69
|
-
* they go.
|
|
95
|
+
* scan, the preview, the sign-in and the apps (a check only a repository with
|
|
96
|
+
* several apps has), the lists with only those checks' entries, and the status
|
|
97
|
+
* those lists make. Its summary and advice counted every card, so they go.
|
|
70
98
|
*/
|
|
71
99
|
export function focusCheckup(checkup) {
|
|
72
100
|
const all = record(checkup);
|
|
@@ -80,7 +108,7 @@ export function focusCheckup(checkup) {
|
|
|
80
108
|
if (!r || !Array.isArray(r.checks))
|
|
81
109
|
return value;
|
|
82
110
|
// The lock's check names its host in what it says while it asks for a key; once the key is saved it says nothing of the host, and the lists no longer carry it.
|
|
83
|
-
const checks = r.checks.filter((c) => ITEMS.has(String(record(c)?.key)) || (record(c)?.key === LOCK.item && new RegExp(`\\b${LOCK.
|
|
111
|
+
const checks = r.checks.filter((c) => ITEMS.has(String(record(c)?.key)) || record(c)?.key === APPS.item || (record(c)?.key === LOCK.item && new RegExp(`\\b(${LOCK.hosts.join('|')})\\b`).test(String(record(c)?.says))));
|
|
84
112
|
for (const says of checks.map((c) => record(c)?.says))
|
|
85
113
|
if (typeof r.name === 'string' && typeof says === 'string' && says)
|
|
86
114
|
kept.push({ repo: r.name, says });
|
package/dist/host-questions.js
CHANGED
|
@@ -19,7 +19,9 @@ export const HOST_QUESTIONS = {
|
|
|
19
19
|
},
|
|
20
20
|
'netlify-team': { kind: 'single', title: 'Netlify team', prompt: 'Which Netlify team should own the new site?' },
|
|
21
21
|
};
|
|
22
|
-
/**
|
|
22
|
+
/** Where a person reads or changes a site's password in Netlify, as Netlify's docs name the page on every plan. */
|
|
23
|
+
export const NETLIFY_PASSWORD_WHERE = 'Project configuration → General → Visitor access';
|
|
24
|
+
/** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one. */
|
|
23
25
|
export const HOST_SCREENS = {
|
|
24
26
|
'netlify-signin-retry': { id: 'netlify-signin-retry', kind: 'confirm', title: 'Sign in to Netlify', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
|
|
25
27
|
'netlify-access': {
|
|
@@ -61,6 +63,50 @@ export const HOST_SCREENS = {
|
|
|
61
63
|
prompt: 'Vercel keeps your previews behind a login, and the Vercel CLI here could not make a bypass secret for them: that takes your Vercel sign-in, this folder linked to the project, and the Member role. Make one yourself: your project → Settings → Deployment Protection → Protection Bypass for Automation → Create, named "Impeccable reviews". It is stored encrypted and used only to open previews.',
|
|
62
64
|
hint: 'Paste the secret',
|
|
63
65
|
},
|
|
66
|
+
// Netlify's site password in front of the previews: the one consent to storing the password the run reads from the site as the person, and the paste when it cannot read it.
|
|
67
|
+
'netlify-password': {
|
|
68
|
+
id: 'netlify-password',
|
|
69
|
+
kind: 'confirm',
|
|
70
|
+
title: 'Your Netlify previews are protected',
|
|
71
|
+
hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password. The run reads that password from your site as you and stores it encrypted, used only to open previews. Change it any time in ${NETLIFY_PASSWORD_WHERE}.`,
|
|
72
|
+
prompt: 'Store the site password for Impeccable?',
|
|
73
|
+
confirmLabel: 'Store',
|
|
74
|
+
skipLabel: 'Skip',
|
|
75
|
+
},
|
|
76
|
+
'netlify-password-key': {
|
|
77
|
+
id: 'netlify-password-key',
|
|
78
|
+
kind: 'text',
|
|
79
|
+
sensitive: true,
|
|
80
|
+
title: 'Your Netlify previews are protected',
|
|
81
|
+
prompt: `Netlify keeps your previews behind the site password, and the run could not read it: the Netlify CLI here is signed out, the site is in none of your teams, or Netlify does not give the password back. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
|
|
82
|
+
hint: 'Paste the password',
|
|
83
|
+
},
|
|
84
|
+
// Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person through cf, the retry of cf's sign-in, the API token where that sign-in cannot create it, and the paste where the run does not create one.
|
|
85
|
+
'cloudflare-access': {
|
|
86
|
+
id: 'cloudflare-access',
|
|
87
|
+
kind: 'confirm',
|
|
88
|
+
title: 'Your Cloudflare previews are protected',
|
|
89
|
+
hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
|
|
90
|
+
prompt: 'Create the token for Impeccable?',
|
|
91
|
+
confirmLabel: 'Create',
|
|
92
|
+
skipLabel: 'Skip',
|
|
93
|
+
},
|
|
94
|
+
'cloudflare-signin-retry': { id: 'cloudflare-signin-retry', kind: 'confirm', title: 'Sign in to Cloudflare', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
|
|
95
|
+
'cloudflare-access-api': {
|
|
96
|
+
id: 'cloudflare-access-api',
|
|
97
|
+
kind: 'text',
|
|
98
|
+
sensitive: true,
|
|
99
|
+
title: 'One Cloudflare API token',
|
|
100
|
+
prompt: 'The Cloudflare CLI here could not create the token, so creating it takes a Cloudflare API token, used for this run only and stored nowhere. Make one at dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with two permissions: Access: Apps and Policies → Edit, and Access: Service Tokens → Edit.',
|
|
101
|
+
hint: 'Paste the API token',
|
|
102
|
+
},
|
|
103
|
+
'cloudflare-access-key': {
|
|
104
|
+
id: 'cloudflare-access-key',
|
|
105
|
+
kind: 'account',
|
|
106
|
+
fields: ['Client ID', 'Client secret'],
|
|
107
|
+
title: 'Your Cloudflare previews are protected',
|
|
108
|
+
prompt: 'Cloudflare Access keeps your previews behind a login. Paste a service token for Impeccable, or make one: Zero Trust → Access controls → Service credentials → Service Tokens → Create Service Token, named "Impeccable reviews", then allow it in your Access application with a Service Auth policy. It is stored encrypted and used only to open previews.',
|
|
109
|
+
},
|
|
64
110
|
};
|
|
65
111
|
/** The ids whose screens the tool puts up itself, when it needs the person, and takes down: the web page follows those, not the model's call. */
|
|
66
112
|
export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);
|
package/dist/hosted.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
|
|
4
|
+
import { basicCredentials } from './netlify-password.js';
|
|
5
|
+
import { gitEnvironment } from './sandbox.js';
|
|
3
6
|
import { BYPASS_HEADER } from './vercel-bypass.js';
|
|
4
7
|
/**
|
|
5
8
|
* Previews a host already deploys: what GitHub has on record of the
|
|
@@ -80,19 +83,34 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
|
|
|
80
83
|
const HOSTS = {
|
|
81
84
|
vercel: { name: 'Vercel', gate: 'vercel-protection', login: /(^|\.)vercel\.com$/ },
|
|
82
85
|
netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/ },
|
|
86
|
+
cloudflare: { name: 'Cloudflare Pages', gate: 'cloudflare-access', login: /(^|\.)cloudflareaccess\.com$/ },
|
|
83
87
|
};
|
|
84
88
|
const NETLIFY = /^(?=[^.]{1,63}\.netlify\.app$)deploy-preview-[1-9]\d{0,6}--([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/;
|
|
85
|
-
/** A
|
|
89
|
+
/** A Pages preview: one label (the deployment's id, or the branch's alias), then the project, under pages.dev. The project's own address, with no label, is its production. */
|
|
90
|
+
const PAGES = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.pages\.dev$/;
|
|
91
|
+
/** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview. */
|
|
86
92
|
const hostOf = (url) => {
|
|
87
93
|
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash)
|
|
88
94
|
return null;
|
|
89
|
-
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.vercel\.app$/.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : null;
|
|
95
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.vercel\.app$/.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) ? 'cloudflare' : null;
|
|
90
96
|
};
|
|
91
|
-
/** The host a preview address belongs to, by name; null for an address of
|
|
97
|
+
/** The host a preview address belongs to, by name; null for an address of none. */
|
|
92
98
|
export function previewHostName(url) {
|
|
93
99
|
const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
|
|
94
100
|
return host ? HOSTS[host].name : null;
|
|
95
101
|
}
|
|
102
|
+
/** The Netlify site a deploy preview address belongs to, by name; null for any other address. */
|
|
103
|
+
export function netlifySiteOf(url) {
|
|
104
|
+
return URL.canParse(url) && hostOf(new URL(url)) === 'netlify' ? NETLIFY.exec(new URL(url).hostname)[1] : null;
|
|
105
|
+
}
|
|
106
|
+
/** The address pattern a proven preview stands for, where the host reports none to GitHub: Netlify's by pull request, Cloudflare's by the branch's alias. */
|
|
107
|
+
function patternOf(host, hostname) {
|
|
108
|
+
if (host === 'netlify')
|
|
109
|
+
return { pattern: `https://deploy-preview-{pr}--${NETLIFY.exec(hostname)[1]}.netlify.app` };
|
|
110
|
+
if (host === 'cloudflare')
|
|
111
|
+
return { pattern: `https://{branch}.${PAGES.exec(hostname)[1]}.pages.dev` };
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
96
114
|
const ENTITIES = {
|
|
97
115
|
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
|
|
98
116
|
hellip: '…', middot: '·', bull: '•', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', times: '×',
|
|
@@ -107,15 +125,33 @@ function plain(text) {
|
|
|
107
125
|
/** Whether a file the checkout tracks holds the words; .env files are never read. */
|
|
108
126
|
async function inCheckout(root, words) {
|
|
109
127
|
try {
|
|
110
|
-
await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000 });
|
|
128
|
+
await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
|
|
111
129
|
return true;
|
|
112
130
|
}
|
|
113
131
|
catch {
|
|
114
132
|
return false;
|
|
115
133
|
}
|
|
116
134
|
}
|
|
135
|
+
/** What a visitor never sees of a page: comments, scripts, styles and templates. */
|
|
136
|
+
const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
117
137
|
/** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
|
|
118
|
-
const visible = (html) => plain(html.replace(
|
|
138
|
+
const visible = (html) => plain(html.replace(UNSEEN, ' ').replace(/<[^>]*>/g, ' '));
|
|
139
|
+
/**
|
|
140
|
+
* Netlify's own lock, never the status alone, as Netlify documents it: the basic challenge (`WWW-Authenticate: Basic`)
|
|
141
|
+
* its Basic-Auth rules send, which "trigger the built-in basic browser authentication"
|
|
142
|
+
* (https://docs.netlify.com/manage/security/secure-access-to-sites/basic-authentication-with-custom-http-headers/); the
|
|
143
|
+
* "generic password prompt" of its Password Protection, where a visitor enters a shared password and nothing else
|
|
144
|
+
* (https://docs.netlify.com/manage/security/secure-access-to-sites/password-protection/); or the "Netlify-branded page"
|
|
145
|
+
* its team login shows a visitor (https://docs.netlify.com/manage/security/secure-access-to-sites/project-visibility/),
|
|
146
|
+
* whose title names Netlify other than in the site's own address: a mention in an app's own page, such as a footer, is
|
|
147
|
+
* not one.
|
|
148
|
+
*/
|
|
149
|
+
function netlifyLock(challenge, html) {
|
|
150
|
+
const seen = html.replace(UNSEEN, ' ');
|
|
151
|
+
const fields = [...seen.matchAll(/<input\b[^>]*>/gi)].map(([tag]) => /\stype\s*=\s*["']?([\w-]+)/i.exec(tag)?.[1]?.toLowerCase() ?? 'text').filter((type) => !['hidden', 'submit', 'button', 'reset', 'image'].includes(type));
|
|
152
|
+
const title = plain(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(seen)?.[1] ?? '');
|
|
153
|
+
return /^basic\b/i.test(challenge ?? '') || (/<form\b/i.test(seen) && fields.join() === 'password') || /\bnetlify\b/.test(title.replace(/\S*\.netlify\.app\S*/g, ' '));
|
|
154
|
+
}
|
|
119
155
|
/** The first `max` bytes of a page, as text. */
|
|
120
156
|
async function pageText(res, max = 1_000_000) {
|
|
121
157
|
const reader = res.body?.getReader();
|
|
@@ -136,16 +172,44 @@ async function pageText(res, max = 1_000_000) {
|
|
|
136
172
|
}
|
|
137
173
|
return Buffer.concat(chunks).toString('utf8');
|
|
138
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether Netlify's own password form opens the address: the password posted as that form posts it (a `password` field
|
|
177
|
+
* to the address itself), then the page read with the session cookie Netlify set. Says whether a password is right
|
|
178
|
+
* where the header did not open the address; a review cannot fill a form, so this proves nothing for one.
|
|
179
|
+
*/
|
|
180
|
+
async function formOpens(target, password, phrase, open, signal) {
|
|
181
|
+
try {
|
|
182
|
+
const posted = await open(target, { method: 'POST', redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password }).toString() });
|
|
183
|
+
await posted.body?.cancel().catch(() => { });
|
|
184
|
+
const cookies = posted.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).filter((pair) => pair.includes('='));
|
|
185
|
+
if (posted.status >= 400 || !cookies.length)
|
|
186
|
+
return false;
|
|
187
|
+
const res = await open(target, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', cookie: cookies.join('; ') } });
|
|
188
|
+
if (res.status < 200 || res.status >= 300) {
|
|
189
|
+
await res.body?.cancel().catch(() => { });
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return visible(await pageText(res)).includes(plain(phrase));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
139
198
|
/**
|
|
140
199
|
* Opens a preview address the way a visitor does, signed in nowhere, and proves it: it answers, and its page shows
|
|
141
200
|
* `words` that a file of the checkout at `root` holds, so the page is this app's and not another site's. With `bypass`, a
|
|
142
|
-
* Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do;
|
|
201
|
+
* Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
|
|
202
|
+
* `password`, a Netlify site password, a Netlify address is opened with it as basic credentials in the Authorization
|
|
203
|
+
* header, the one way reviews can send it, and with `form` posted to Netlify's form too where the header did not get past
|
|
204
|
+
* the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
|
|
205
|
+
* Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
|
|
206
|
+
* to another host.
|
|
143
207
|
*/
|
|
144
208
|
export async function provePreview(url, words, root, deps = {}) {
|
|
145
209
|
const unproven = (reason, status = null) => ({ proven: false, status, reason });
|
|
146
210
|
const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
|
|
147
211
|
if (!host)
|
|
148
|
-
return unproven('Only a Vercel preview address, https://<name>.vercel.app,
|
|
212
|
+
return unproven('Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, or a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, with no query, is opened.');
|
|
149
213
|
const { name, gate, login } = HOSTS[host];
|
|
150
214
|
const phrase = words.trim();
|
|
151
215
|
if (phrase.length < 3 || phrase.length > 120 || /[\r\n]/.test(phrase))
|
|
@@ -153,14 +217,22 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
153
217
|
if (!(await inCheckout(root, phrase)))
|
|
154
218
|
return unproven("No file of this checkout holds those words: give words the app's pages show, from its source.");
|
|
155
219
|
const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(deps.signal ? [deps.signal] : [])]);
|
|
220
|
+
const open = deps.fetch ?? fetch;
|
|
156
221
|
const opened = deps.bypass && host === 'vercel';
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const
|
|
222
|
+
const withPassword = deps.password !== undefined && host === 'netlify';
|
|
223
|
+
const withToken = deps.access !== undefined && host === 'cloudflare';
|
|
224
|
+
const headers = {
|
|
225
|
+
accept: 'text/html',
|
|
226
|
+
...(opened ? { [BYPASS_HEADER]: deps.bypass } : {}),
|
|
227
|
+
...(withPassword ? { authorization: basicCredentials(deps.password) } : {}),
|
|
228
|
+
...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
|
|
229
|
+
};
|
|
230
|
+
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
|
|
231
|
+
const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
|
|
160
232
|
let target = new URL(url);
|
|
161
233
|
try {
|
|
162
234
|
for (let hop = 0; hop < 4; hop++) {
|
|
163
|
-
const res = await (
|
|
235
|
+
const res = await open(target, { redirect: 'manual', headers, credentials: 'omit', signal });
|
|
164
236
|
if (res.status >= 300 && res.status < 400) {
|
|
165
237
|
await res.body?.cancel().catch(() => { });
|
|
166
238
|
const location = res.headers.get('location') ?? '';
|
|
@@ -172,19 +244,26 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
172
244
|
target = next;
|
|
173
245
|
continue;
|
|
174
246
|
}
|
|
175
|
-
if (res.status === 401 || (host
|
|
247
|
+
if (res.status === 401 || (host !== 'vercel' && res.status === 403)) {
|
|
176
248
|
// Vercel's own page names Vercel; any other 401 there is the app's own sign-in, which a page a visitor opens without signing in gets past.
|
|
177
|
-
//
|
|
249
|
+
// Netlify's lock shows itself; the app's own 401 or 403 does not, so it gets no site password.
|
|
250
|
+
// Cloudflare Access names itself on the 401 or 403 it answers a request it will not let in (a service token it does not admit), where a browser gets its login.
|
|
178
251
|
const page = await pageText(res, 64_000);
|
|
179
|
-
|
|
180
|
-
|
|
252
|
+
const named = (host === 'netlify' && netlifyLock(res.headers.get('www-authenticate'), page)) || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
|
|
253
|
+
if (!named)
|
|
254
|
+
return unproven(res.status === 401 ? "The address asked for the app's own sign-in: try a page a visitor opens without signing in." : `The address answered ${res.status}.`, res.status);
|
|
255
|
+
// A password the header did not get past: Netlify's own form says whether it is the site's password at all.
|
|
256
|
+
if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
|
|
257
|
+
return { ...unproven("The password opens the address on Netlify's own form, but not in the Authorization header, the one way reviews can send it.", res.status), gate, formOpened: true };
|
|
258
|
+
}
|
|
259
|
+
return { ...unproven(`The address asked for ${name}'s login or password${still}.`, res.status), gate };
|
|
181
260
|
}
|
|
182
261
|
if (res.status < 200 || res.status >= 300) {
|
|
183
262
|
await res.body?.cancel().catch(() => { });
|
|
184
263
|
return unproven(`The address answered ${res.status}.`, res.status);
|
|
185
264
|
}
|
|
186
265
|
return visible(await pageText(res)).includes(plain(phrase))
|
|
187
|
-
? { proven: true, status: res.status, ...(host
|
|
266
|
+
? { proven: true, status: res.status, ...patternOf(host, new URL(url).hostname) }
|
|
188
267
|
: unproven('The page does not show those words: it is another app, or another page.', res.status);
|
|
189
268
|
}
|
|
190
269
|
return unproven('The address redirected too many times.');
|
package/dist/log.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { appendFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
/** An error whose message is written for the person at the terminal. */
|
|
4
|
+
/** An error whose message is written for the person at the terminal; `step` is the onboarding step it ends, which the command reports (report.ts). */
|
|
5
5
|
export class UserError extends Error {
|
|
6
|
+
step;
|
|
7
|
+
constructor(message, step) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.step = step;
|
|
10
|
+
}
|
|
6
11
|
}
|
|
7
12
|
const TOKEN = /\b(pst|phr)_[A-Za-z0-9._~+/=-]+/g;
|
|
8
13
|
const NAMES = 'access_token|run_token|code_verifier|code|authorization|x-api-key';
|
package/dist/login.js
CHANGED
|
@@ -130,7 +130,7 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
130
130
|
},
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
-
throw new UserError(`Ports ${ports.join(', ')} are all taken on this machine, and sign-in needs one. Close what uses one of them and run it again
|
|
133
|
+
throw new UserError(`Ports ${ports.join(', ')} are all taken on this machine, and sign-in needs one. Close what uses one of them and run it again.`, { event: 'setup_command_sign_in_ended', outcome: 'ports_busy' });
|
|
134
134
|
}
|
|
135
135
|
/** The code from what the person pasted: the whole callback address, or the code alone. Null when it is neither, or from another sign-in. */
|
|
136
136
|
export function parsePasted(text, state) {
|
|
@@ -215,18 +215,20 @@ export async function exchangeCode(host, input, log) {
|
|
|
215
215
|
}
|
|
216
216
|
/**
|
|
217
217
|
* The browser sign-in: callback server, PKCE and state, the paste fallback,
|
|
218
|
-
* then the token exchange, once per code. `
|
|
218
|
+
* then the token exchange, once per code. `repo` is the checkout's owner/name:
|
|
219
|
+
* the consent page names it, so the person approves and picks nothing (an
|
|
220
|
+
* older Impeccable ignores it and asks). `check` stops a token the command
|
|
219
221
|
* cannot use; otherwise the tab that brought the code goes on to the page
|
|
220
222
|
* `landing` resolves to (the web page that mirrors the run, once the command
|
|
221
223
|
* has what it needs to show a run there), or says why it will not.
|
|
222
224
|
*/
|
|
223
|
-
export async function signIn(host, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
|
|
225
|
+
export async function signIn(host, repo, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
|
|
224
226
|
const state = randomBytes(24).toString('base64url');
|
|
225
227
|
const { verifier, challenge } = pkcePair();
|
|
226
228
|
log.secret(verifier);
|
|
227
229
|
const server = await startCallbackServer(state);
|
|
228
230
|
const authorize = new URL(`${host}/app/cli/authorize`);
|
|
229
|
-
authorize.search = new URLSearchParams({ redirect_uri: server.redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
|
|
231
|
+
authorize.search = new URLSearchParams({ redirect_uri: server.redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256', repo }).toString();
|
|
230
232
|
log.write(`callback server at ${server.redirectUri}`);
|
|
231
233
|
prompt.show(authorize.href);
|
|
232
234
|
openBrowser(authorize.href);
|
|
@@ -235,8 +237,8 @@ export async function signIn(host, log, prompt, check = (t) => t, landing = asyn
|
|
|
235
237
|
try {
|
|
236
238
|
const code = await new Promise((resolve, reject) => {
|
|
237
239
|
server.code.then(resolve, reject);
|
|
238
|
-
stopPaste = prompt.listen(state, resolve, () => reject(new UserError('Sign-in cancelled.')));
|
|
239
|
-
timer = setTimeout(() => reject(new UserError('Sign-in timed out after 30 minutes. Run impeccable-reviews again.')), LOGIN_TIMEOUT_MS);
|
|
240
|
+
stopPaste = prompt.listen(state, resolve, () => reject(new UserError('Sign-in cancelled.', { event: 'setup_command_sign_in_ended', outcome: 'cancelled' })));
|
|
241
|
+
timer = setTimeout(() => reject(new UserError('Sign-in timed out after 30 minutes. Run impeccable-reviews again.', { event: 'setup_command_sign_in_ended', outcome: 'timed_out' })), LOGIN_TIMEOUT_MS);
|
|
240
242
|
});
|
|
241
243
|
log.secret(code);
|
|
242
244
|
const token = check(await exchangeCode(host, { code, verifier, redirectUri: server.redirectUri }, log));
|
|
@@ -261,7 +263,7 @@ export function setupAuth(first, signInAgain, now = Date.now) {
|
|
|
261
263
|
const until = (t) => now() + (Number.isFinite(t.expires_in) ? t.expires_in : 3600) * 1000;
|
|
262
264
|
let current = { token: first.access_token, expiresAt: until(first) };
|
|
263
265
|
let pending;
|
|
264
|
-
const renew = (pi) => (pending ??= signInAgain(pi)
|
|
266
|
+
const renew = (pi, why = 'ran-out') => (pending ??= signInAgain(pi, why)
|
|
265
267
|
.then((t) => {
|
|
266
268
|
current = { token: t.access_token, expiresAt: until(t) };
|
|
267
269
|
return t.access_token;
|