@impeccable/reviews 0.1.17 → 0.1.19

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/README.md CHANGED
@@ -15,7 +15,7 @@ npx @impeccable/reviews
15
15
 
16
16
  `npm install -g @impeccable/reviews` puts the `impeccable-reviews` command on your PATH instead.
17
17
 
18
- Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
18
+ Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the account or team and the project or site it used on each host whose preview it opened, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
19
19
 
20
20
  Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (every question can be skipped); on the run screen, Esc twice (or Ctrl+C) stops the run. A screen taller than the terminal scrolls with ↑↓ and PgUp PgDn (↑↓ still move among a question's choices). The screens are black and white, with emphasis by intensity: greys on a terminal that takes true colour and reports its background, bold and dim elsewhere, and under `NO_COLOR`. The mark at the top is the logo's own image in a terminal that shows images (iTerm2, kitty, Ghostty, WezTerm; never under tmux), a square with its slash beside the letter-spaced wordmark elsewhere, and plain text on a dumb terminal.
21
21
 
@@ -104,9 +104,9 @@ const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.ever
104
104
  * a retry, and two failed retries end it. The new sign-in asks for the scopes it held as well, so whatever else the
105
105
  * person uses cf for goes on working.
106
106
  */
107
- async function reach(deps) {
107
+ async function reach(deps, read) {
108
108
  const through = (s) => ({ way: throughCf(deps.cf, s.accounts, deps.signal) });
109
- let status = await cfStatus(deps.cf, deps.signal);
109
+ let status = read;
110
110
  if (granted(status))
111
111
  return through(status);
112
112
  if (status.cli === 'missing' || status.cli === 'unknown')
@@ -196,6 +196,8 @@ async function covering(way, url) {
196
196
  return unread ? stop(way, unread, '') : { reason: `No Access application ${way.who} reaches covers ${url.host}.` };
197
197
  return { apps };
198
198
  }
199
+ /** Why no token is made where several applications cover the address. */
200
+ const unclear = (count, host) => `${count} Access applications cover ${host}, so which one's policy should allow the token is not clear.`;
199
201
  /**
200
202
  * As the person, the way they reach Cloudflare's API: on the one application in front of the address, the service
201
203
  * token, then a Service Auth policy that allows it. An application on reusable policies (every one the dashboard makes
@@ -208,7 +210,7 @@ async function create(way, deps, found) {
208
210
  const api = way.call;
209
211
  const url = new URL(deps.url);
210
212
  if (found.length > 1)
211
- return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
213
+ return { reason: unclear(found.length, url.host) };
212
214
  const { account, app } = found[0];
213
215
  const base = `/accounts/${account}/access`;
214
216
  let links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
@@ -313,10 +315,6 @@ export async function passCloudflareAccess(deps) {
313
315
  covered ||= 'apps' in found;
314
316
  return found;
315
317
  };
316
- const build = async (way) => {
317
- const found = await find(way);
318
- return 'apps' in found ? create(way, deps, found.apps) : found;
319
- };
320
318
  /** A token that opened the address, saved for reviews. */
321
319
  const keep = async (token, how, made = {}) => {
322
320
  const failed = await deps.save(token);
@@ -325,16 +323,37 @@ export async function passCloudflareAccess(deps) {
325
323
  /** Why the run's own way ended without a token that opens the address, for the paste screen's skip, and how to make one by hand there. */
326
324
  let stopped;
327
325
  let manual;
328
- if (!(await deps.consent())) {
329
- stopped = 'The person skipped creating a service token.';
330
- // cf signed in here, with whatever scopes, says whether an application of the person's covers the address, since reading takes no write; a skip opens no sign-in for it.
331
- const status = await cfStatus(deps.cf, deps.signal);
332
- if (status.cli === 'signed-in')
333
- await find(throughCf(deps.cf, status.accounts, deps.signal));
334
- }
326
+ // cf signed in here, with whatever scopes, reads the Access application in front of the address before anything is asked, since reading takes no write
327
+ // (#357): none covering it asks nothing, and the consent names the one that does. Signed out, or refused the read, the consent comes first, as its yes opens
328
+ // the sign-in or the API token screen that finding it takes.
329
+ const status = await cfStatus(deps.cf, deps.signal);
330
+ const reads = status.cli === 'signed-in' && (!status.scopes || status.scopes.some((scope) => /^access\.(read|write)$/.test(scope)));
331
+ const early = reads ? await find(throughCf(deps.cf, status.accounts, deps.signal)) : undefined;
332
+ const apps = early && 'apps' in early ? early.apps : undefined;
333
+ const named = apps?.length === 1 ? apps[0] : undefined;
334
+ /** An application as the consent names it. */
335
+ const described = ({ account, app }) => ({ id: String(app.id), application: typeof app.name === 'string' && app.name ? app.name : url.host, account });
336
+ const SKIPPED = 'The person skipped creating a service token.';
337
+ if (early && 'reason' in early)
338
+ stopped = early.reason;
339
+ else if (apps && apps.length > 1)
340
+ stopped = unclear(apps.length, url.host);
341
+ else if (!(await deps.consent(named && described(named))))
342
+ stopped = SKIPPED;
335
343
  else {
336
- const reached = await reach(deps);
337
- let made = 'way' in reached ? await build(reached.way) : 'stopped' in reached ? { reason: reached.stopped } : reached;
344
+ /** Read anew, by a new sign-in or the API token: an application other than the one the yes named is asked about before anything is made on it. */
345
+ const rebuild = async (way) => {
346
+ const found = await find(way);
347
+ if (!('apps' in found))
348
+ return found;
349
+ const target = found.apps.length === 1 ? found.apps[0] : undefined;
350
+ if (named && target && (String(target.app.id) !== String(named.app.id) || target.account !== named.account) && !(await deps.consent(described(target))))
351
+ return { reason: SKIPPED };
352
+ return create(way, deps, found.apps);
353
+ };
354
+ const reached = await reach(deps, status);
355
+ // The sign-in that read the applications makes the token on the one it found, or answers the same refusal, without reading them again; a new sign-in reads its accounts.
356
+ let made = 'way' in reached ? (early && granted(status) ? ('apps' in early ? await create(reached.way, deps, early.apps) : early) : await rebuild(reached.way)) : 'stopped' in reached ? { reason: reached.stopped } : reached;
338
357
  let apiToken = false;
339
358
  if ('fallback' in made) {
340
359
  // Where cf's sign-in cannot make the token, an API token the person makes for the run can.
@@ -344,7 +363,7 @@ export async function passCloudflareAccess(deps) {
344
363
  else {
345
364
  deps.remember(key);
346
365
  apiToken = true;
347
- made = await build(withApiToken(key, deps));
366
+ made = await rebuild(withApiToken(key, deps));
348
367
  }
349
368
  }
350
369
  if (!('token' in made)) {
@@ -306,8 +306,9 @@ export async function cfStatus(cf, signal) {
306
306
  if (status?.authenticated !== true)
307
307
  return { cli: 'unknown', accounts: [] };
308
308
  const scopes = Array.isArray(status.scopes) ? status.scopes.filter((s) => typeof s === 'string') : undefined;
309
- const accounts = list(status.accounts).map((a) => a.id).filter((id) => typeof id === 'string' && /^[0-9a-f]{32}$/.test(id));
310
- return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts };
309
+ const listed = list(status.accounts).filter((a) => typeof a.id === 'string' && /^[0-9a-f]{32}$/.test(a.id));
310
+ const names = Object.fromEntries(listed.flatMap((a) => (typeof a.name === 'string' && a.name && a.name.length <= 100 ? [[a.id, a.name]] : [])));
311
+ return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts: listed.map((a) => a.id), ...(Object.keys(names).length ? { names } : {}) };
311
312
  }
312
313
  /** What a Pages deployment's latest stage says of its build: success once it deployed, and building until a stage failed or it was cancelled or skipped. */
313
314
  const stageState = (stage) => stage?.status === 'failure' ? 'failure' : stage?.status === 'canceled' || stage?.status === 'skipped' ? 'canceled' : stage?.status === 'success' && stage.name === 'deploy' ? 'success' : 'building';
@@ -322,7 +323,7 @@ const PAGES = 4;
322
323
  * built from another repository is not this one's.
323
324
  */
324
325
  async function fromCf(named, cf, repository, settings, signal) {
325
- const { cli, scopes, accounts } = await cfStatus(cf, signal);
326
+ const { cli, scopes, accounts, names } = await cfStatus(cf, signal);
326
327
  const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
327
328
  if (cli !== 'signed-in' || !pages)
328
329
  return { cli, rows: [], failed: cli === 'unknown' };
@@ -361,7 +362,8 @@ async function fromCf(named, cf, repository, settings, signal) {
361
362
  subdomain = typeof ours.project.subdomain === 'string' && new RegExp(`^${LABEL}\\.pages\\.dev$`).test(ours.project.subdomain) ? ours.project.subdomain : undefined;
362
363
  }
363
364
  }
364
- const settled = { ...(pullRequestPreviews ? { pullRequestPreviews } : {}), ...(account ? { account } : {}), ...(subdomain ? { subdomain } : {}) };
365
+ const accountName = account ? names?.[account] : undefined;
366
+ const settled = { ...(pullRequestPreviews ? { pullRequestPreviews } : {}), ...(account ? { account } : {}), ...(accountName ? { accountName } : {}), ...(subdomain ? { subdomain } : {}) };
365
367
  if (!project)
366
368
  return { cli, rows: [], failed: false, ...settled };
367
369
  const ours = (d) => {
@@ -450,6 +452,9 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
450
452
  : cf.cli !== 'signed-in' ? `Which Cloudflare Pages project builds ${where} cannot be told: cf here is not signed in with Pages access, and only a project's settings name the folder it builds (its root directory).`
451
453
  : (cf.matching?.length ?? 0) > 1 ? `Several Cloudflare Pages projects build ${where} (${cf.matching.join(', ')}), so which one is this app's cannot be told.`
452
454
  : !cf.project ? `No Cloudflare Pages project of this repository builds ${where} (its root directory), as far as cf here can see.` : undefined;
455
+ // The name of the account the project was found in goes to the last screen alone, never into the evidence (#360).
456
+ if (cf.accountName && !unmatched)
457
+ deps.onAccountName?.(cf.accountName);
453
458
  if (unmatched)
454
459
  github.previews = [];
455
460
  // A preview's address names the project's own host, which a taken name suffixes (crewline-x1y.pages.dev), not always its name.
@@ -81,6 +81,10 @@ const previewsOff = (id, name, where) => ({
81
81
  skipLabel: 'Skip',
82
82
  },
83
83
  });
84
+ /** The consent to a Vercel bypass secret, saying whose previews Vercel's login keeps and where the secret is revoked. */
85
+ const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection.`;
86
+ /** The consent to a Cloudflare service token, saying which Access application keeps whose previews, and whose policy allows the token. */
87
+ const accessHint = (keeps, policy) => `Impeccable reviews each pull request on its preview, and ${keeps} behind a login.\n\nCloudflare'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 ${policy} policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.`;
84
88
  /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, the consent to turning a host's pull-request previews on, 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, and to making a site behind Netlify's team login public. */
85
89
  export const HOST_SCREENS = {
86
90
  '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' },
@@ -138,7 +142,7 @@ export const HOST_SCREENS = {
138
142
  id: 'vercel-bypass',
139
143
  kind: 'confirm',
140
144
  title: 'Your Vercel previews are protected',
141
- hint: 'Impeccable reviews each pull request on its preview,\nand Vercel keeps yours behind a login.\n\nVercel\'s way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in the project\'s Settings → Deployment Protection.',
145
+ hint: bypassHint('yours', "the project's"),
142
146
  prompt: 'Create the secret for Impeccable?',
143
147
  confirmLabel: 'Create',
144
148
  skipLabel: 'Skip',
@@ -193,7 +197,7 @@ export const HOST_SCREENS = {
193
197
  id: 'cloudflare-access',
194
198
  kind: 'confirm',
195
199
  title: 'Your Cloudflare previews are protected',
196
- hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login.\n\nCloudflare\'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.',
200
+ hint: accessHint('Cloudflare Access keeps yours', "your Access application's"),
197
201
  prompt: 'Create the token for Impeccable?',
198
202
  confirmLabel: 'Create',
199
203
  skipLabel: 'Skip',
@@ -224,6 +228,17 @@ export function sentTo(screen, url) {
224
228
  const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
225
229
  return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
226
230
  }
231
+ /** The consent to a Vercel bypass secret, naming the project that holds it and, on a repository with several apps, the app whose previews it opens (#358). */
232
+ export function vercelBypassConsent(project, app) {
233
+ const screen = HOST_SCREENS['vercel-bypass'];
234
+ if (!project)
235
+ return screen;
236
+ return { ...screen, hint: bypassHint(app ? `${app}'s previews, built by the Vercel project ${project},` : `the previews of the Vercel project ${project}`, `${project}'s`) };
237
+ }
238
+ /** The consent to a service token, asked once the Access application in front of the preview was found (#357): that application, its Cloudflare account, and whose previews at which host it keeps. */
239
+ export function cloudflareAccessConsent(found, host, app) {
240
+ return { ...HOST_SCREENS['cloudflare-access'], hint: accessHint(`the Access application "${found.application}", in the Cloudflare account ${found.account}, keeps ${app ? `${app}'s previews` : 'yours'} (${host})`, `"${found.application}"'s`) };
241
+ }
227
242
  /** The one consent to making the Access application's own policies reusable, which the run's way through cf needs to add the token's policy (#327): the application named. */
228
243
  export function cloudflareAccessReusable(application) {
229
244
  return {
package/dist/hosted.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
4
- import { basicCredentials } from './netlify-password.js';
5
4
  import { gitEnvironment } from './sandbox.js';
6
5
  import { BYPASS_HEADER } from './vercel-bypass.js';
7
6
  /**
@@ -138,6 +137,24 @@ export function netlifySiteOf(url) {
138
137
  const { hostname } = new URL(url);
139
138
  return NETLIFY.exec(hostname)?.[1] ?? LIVE.netlify.exec(hostname)?.[1] ?? null;
140
139
  }
140
+ /**
141
+ * The project and team a Vercel deployment's own address names, `<project>-<hash>-<team>.vercel.app` (Vercel's generated
142
+ * address, its hash nine letters and digits), for the last screen (#360): only where one split fits, and that split's
143
+ * project is `project` where a host tool named it; null otherwise, a project's own address included, and any address
144
+ * with `-git-` in it, which a branch's alias `<project>-git-<branch>-<team>` has, so a branch's word is never a hash.
145
+ */
146
+ export function vercelDeploymentOf(url, project) {
147
+ if (!URL.canParse(url))
148
+ return null;
149
+ const parsed = new URL(url);
150
+ if (!plainHttps(parsed) || !LIVE.vercel.test(parsed.hostname) || parsed.hostname.includes('-git-'))
151
+ return null;
152
+ const parts = parsed.hostname.slice(0, -'.vercel.app'.length).split('-');
153
+ const splits = parts
154
+ .flatMap((part, i) => (i > 0 && i < parts.length - 1 && /^[a-z0-9]{9}$/.test(part) ? [{ project: parts.slice(0, i).join('-'), team: parts.slice(i + 1).join('-') }] : []))
155
+ .filter((split) => project === undefined || split.project === project.toLowerCase());
156
+ return splits.length === 1 ? splits[0] : null;
157
+ }
141
158
  /** 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. A live address stands for none. */
142
159
  function patternOf(hostname) {
143
160
  const site = NETLIFY.exec(hostname)?.[1];
@@ -169,17 +186,56 @@ async function checkoutHolds(root, words) {
169
186
  return false;
170
187
  }
171
188
  }
172
- /** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
189
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
190
+ /** The address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
191
+ const wholeOrigin = (origin) => `(^|[^A-Za-z0-9.-])${escapeRegExp(origin)}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`;
192
+ /** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin, whole. */
173
193
  export async function checkoutNames(root, origin, folder = '') {
174
- const escaped = origin.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
175
194
  try {
176
- await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', `(^|[^A-Za-z0-9.-])${escaped}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`, '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
195
+ await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', wholeOrigin(origin), '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
177
196
  return true;
178
197
  }
179
198
  catch {
180
199
  return false;
181
200
  }
182
201
  }
202
+ /** Whether a line names an app's name or folder whole, not inside a longer name, a host name or a path, such as `apps/web` in `archived/apps/web`. */
203
+ const namesWhole = (line, words) => new RegExp(`(^|[^\\p{L}\\p{N}_./-])${escapeRegExp(words)}(?![\\p{L}\\p{N}_-]|[./][\\p{L}\\p{N}])`, 'iu').test(line);
204
+ /**
205
+ * Whether a line of the checkout's root README names the address's origin, whole, beside one app, by its name or its
206
+ * folder, and no other app: a row of a table of the apps' live addresses, or an item of such a list (#367). The
207
+ * addresses on the line are left out of the match, so a host name made of an app's name names no app.
208
+ */
209
+ export async function readmeNamesApp(root, origin, app, others) {
210
+ let lines;
211
+ try {
212
+ lines = (await execFileAsync('git', ['grep', '-h', '--ignore-case', '--extended-regexp', '-e', wholeOrigin(origin), '--', ':(glob,icase)README*'], { cwd: root, timeout: 30_000, env: gitEnvironment() })).stdout;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ // Each name or folder with every app it belongs to, so a name two apps share names both; the longest first, each taken
218
+ // out once found, so "Web" inside "Web admin" is not that app named.
219
+ const names = new Map();
220
+ for (const each of [app, ...others])
221
+ for (const words of [each.name, each.folder])
222
+ if (words)
223
+ names.set(words.toLowerCase(), [...(names.get(words.toLowerCase()) ?? []), each]);
224
+ const longestFirst = [...names].sort(([x], [y]) => y.length - x.length);
225
+ return lines.split('\n').some((line) => {
226
+ // The addresses end where a table cell or a tag does; a path's parentheses, and a link's closing one, go with them.
227
+ let text = line.replace(/https?:\/\/[^\s|<>[\]"'`]+/gi, ' ');
228
+ const named = new Set();
229
+ for (const [words, apps] of longestFirst) {
230
+ if (!namesWhole(text, words))
231
+ continue;
232
+ for (const each of apps)
233
+ named.add(each);
234
+ text = text.replace(new RegExp(escapeRegExp(words), 'giu'), ' ');
235
+ }
236
+ return named.size === 1 && named.has(app);
237
+ });
238
+ }
183
239
  /** What a visitor never sees of a page: comments, scripts, styles and templates. */
184
240
  const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
185
241
  /** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
@@ -223,36 +279,43 @@ async function pageText(res, max = 1_000_000) {
223
279
  }
224
280
  return Buffer.concat(chunks).toString('utf8');
225
281
  }
282
+ /** Whether a page is a password form and nothing else: one form whose only field a person fills is a password, as Netlify's lock is. */
283
+ function passwordForm(html) {
284
+ const seen = html.replace(UNSEEN, ' ');
285
+ 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));
286
+ return (seen.match(/<form\b/gi) ?? []).length === 1 && fields.join() === 'password';
287
+ }
226
288
  /**
227
- * Whether Netlify's own password form opens the address: the password posted as that form posts it (a `password` field
228
- * to the address itself), then the page read with the session cookie Netlify set. Says whether a password is right
229
- * where the header did not open the address; a review cannot fill a form, so this proves nothing for one.
289
+ * Signs in with Netlify's site password the way its form does, and the way reviews do (pristine's site-password.ts):
290
+ * a stranger's knock on the address's own origin, then, only where the answer is the lock's password form, the
291
+ * password posted there as its one field. Netlify answers a redirect back with a cookie for that host alone, which
292
+ * opens every page of it; a wrong password gets the form again and no cookie. The session's cookies, as a request
293
+ * sends them; empty where nothing stood in front or the form did not take the password.
230
294
  */
231
- async function formOpens(target, password, phrase, open, signal) {
295
+ async function siteSession(target, password, open, signal) {
296
+ const front = new URL('/', target.origin);
232
297
  try {
233
- 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() });
234
- await posted.body?.cancel().catch(() => { });
235
- const cookies = posted.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).filter((pair) => pair.includes('='));
236
- if (posted.status >= 400 || !cookies.length)
237
- return false;
238
- const res = await open(target, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', cookie: cookies.join('; ') } });
239
- if (res.status < 200 || res.status >= 300) {
240
- await res.body?.cancel().catch(() => { });
241
- return false;
298
+ const knock = await open(front, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html' } });
299
+ if ((knock.status !== 401 && knock.status !== 403) || !passwordForm(await pageText(knock, 64_000))) {
300
+ await knock.body?.cancel().catch(() => { });
301
+ return '';
242
302
  }
243
- return visible(await pageText(res)).includes(plain(phrase));
303
+ const posted = await open(front, { method: 'POST', redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password }).toString() });
304
+ await posted.body?.cancel().catch(() => { });
305
+ if (posted.status >= 400)
306
+ return '';
307
+ return posted.headers.getSetCookie().map((cookie) => cookie.split(';')[0].trim()).filter((pair) => /^[^\s;,=]+=[^\s;,]*$/.test(pair)).join('; ');
244
308
  }
245
309
  catch {
246
- return false;
310
+ return '';
247
311
  }
248
312
  }
249
313
  /**
250
314
  * Opens a preview address the way a visitor does, signed in nowhere, and proves it: it answers, and its page shows
251
315
  * `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
252
316
  * Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
253
- * `password`, a Netlify site password, a Netlify address is opened with it as basic credentials in the Authorization
254
- * header, the one way reviews can send it, and with `form` posted to Netlify's form too where the header did not get past
255
- * the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
317
+ * `password`, a Netlify site password, a Netlify address is opened signed in with it on Netlify's own form, as reviews
318
+ * sign in, the password posted only where the address's origin answers with that form; with `access`, a
256
319
  * Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
257
320
  * to another host. With `live`, the address is a live app's rather than a preview's: any https address is opened, a
258
321
  * host's lock is told by its domain, or by the host named in `live` for a custom domain the host tool answered as the
@@ -276,10 +339,12 @@ export async function provePreview(url, words, root, deps = {}) {
276
339
  const opened = deps.bypass && host === 'vercel';
277
340
  const withPassword = deps.password !== undefined && host === 'netlify';
278
341
  const withToken = deps.access !== undefined && host === 'cloudflare';
342
+ // Behind Netlify's site password: the session its form gives, carried on every hop of the address's own origin.
343
+ const session = withPassword ? await siteSession(new URL(url), deps.password, open, signal) : '';
279
344
  const headers = {
280
345
  accept: 'text/html',
281
346
  ...(opened ? { [BYPASS_HEADER]: deps.bypass } : {}),
282
- ...(withPassword ? { authorization: basicCredentials(deps.password) } : {}),
347
+ ...(session ? { cookie: session } : {}),
283
348
  ...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
284
349
  };
285
350
  // Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
@@ -307,10 +372,6 @@ export async function provePreview(url, words, root, deps = {}) {
307
372
  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)));
308
373
  if (!gate || !named)
309
374
  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);
310
- // A password the header did not get past: Netlify's own form says whether it is the site's password at all.
311
- if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
312
- 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 };
313
- }
314
375
  return { ...unproven(`The address asked for ${lock}'s login or password${still}.`, res.status), gate, ...(host === 'netlify' && NETLIFY_TEAM_LOGIN.test(page) ? { teamLogin: true } : {}) };
315
376
  }
316
377
  if (res.status < 200 || res.status >= 300) {
package/dist/main.js CHANGED
@@ -290,6 +290,7 @@ export async function main(argv) {
290
290
  { label: 'Web page', url: page },
291
291
  ],
292
292
  todo: end.todo,
293
+ ...(report.hosts?.length ? { used: report.hosts } : {}),
293
294
  warnings: [...removed, ...left, ...(await unopened(Boolean(testPr))), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
294
295
  }, hand);
295
296
  // The web page has the report; when it missed it, or the run fell short of all set up, whose why it holds, the report stays here as well.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Getting past Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password"
3
- * on credit-based Pro) the one way a request can carry a password: as basic credentials in the Authorization header.
4
- * Netlify's password page asks for a password alone, no username, so the credentials carry an empty name. The Netlify
3
+ * on credit-based Pro) the way Netlify takes it: on its own form, which answers a session cookie for the preview's host
4
+ * (impeccable-reviews#374); no request header carries it. Reviews sign in the same way, before a preview's first page,
5
+ * with the password saved as the lock's key (pristine's `protection: { method: "password" }`). The Netlify
5
6
  * CLI on this machine, signed in as the person, reads the site's settings in the command's own process: what
6
7
  * `netlify api` prints is those settings, the password among them, so the agent's shell never runs it (fence.ts), and
7
8
  * no password goes back to the model. The person consents once, on the tool's screen, before the password is stored;
@@ -10,10 +11,6 @@
10
11
  * Netlify's team login (a private site) has no way in for tools, so on the person's yes the run makes the site public
11
12
  * through the CLI; where Netlify refuses, the person changes Visitor access on Netlify's page and the run tries again.
12
13
  */
13
- /** The header reviews send the password in. */
14
- export const PASSWORD_HEADER = 'Authorization';
15
- /** The password as the header carries it: basic credentials with an empty username, since Netlify asks for none. */
16
- export const basicCredentials = (password) => `Basic ${Buffer.from(`:${password}`).toString('base64')}`;
17
14
  /** The CLI's read of a site by the address every deploy preview of it carries (`site_id` takes the site's default domain), an authenticated call of Netlify's API. */
18
15
  export const readSiteCall = (site) => ['api', 'getSite', '--data', JSON.stringify({ site_id: `${site}.netlify.app` })];
19
16
  /**
@@ -27,7 +24,7 @@ const record = (value) => (value !== null && typeof value === 'object' && !Array
27
24
  /** A site's name and id as Netlify makes them. */
28
25
  const NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
29
26
  const ID = /^[A-Za-z0-9-]{1,64}$/;
30
- /** A password as the header can carry it: one line, of a length a person would type. */
27
+ /** A password as the form takes it: one line, of a length a person would type. */
31
28
  const password = (value) => (typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\r\n\0]/.test(value) ? value : null);
32
29
  /** The site's protection, read through the CLI, which is what makes the site the person's; null when it could not say: signed out, no site of that name in a team of the person's, or a site read back under another name. */
33
30
  export async function readSite(run, site) {
@@ -63,12 +60,11 @@ export async function passNetlifyLock(deps) {
63
60
  const from = site ? { site: site.name } : {};
64
61
  const finish = async (password, how) => {
65
62
  deps.remember(password);
66
- deps.remember(basicCredentials(password));
67
- // Netlify's form, which an app's own password form looks like, gets the password only on a site whose settings show one.
68
- const proof = await deps.prove(password, site !== null);
69
- // locked: Netlify's lock stood all the same, and its form did not take the password either. Any other failure is the proof's to say: the form took it and the header did not, or the page past the lock is another, or did not answer.
63
+ // Netlify's form, which an app's own password form looks like, gets the password only on a site whose settings show one: the CLI read this one as the person's.
64
+ const proof = await deps.prove(password);
65
+ // locked: Netlify's lock stood all the same, so its form did not take the password. Any other failure is the proof's to say: the page past the lock is another, or did not answer.
70
66
  if (!proof.proven)
71
- return { reason: proof.reason ?? 'The address did not open with the password.', locked: proof.gate !== undefined && !proof.formOpened };
67
+ return { reason: proof.reason ?? 'The address did not open with the password.', locked: proof.gate !== undefined };
72
68
  const failed = await deps.save(password);
73
69
  return failed ? { status: 'save-failed', reason: failed, protection: 'password', ...from } : { status: 'saved', password: how, protection: 'password', ...from, value: password };
74
70
  };
@@ -88,7 +84,7 @@ export async function passNetlifyLock(deps) {
88
84
  return done;
89
85
  // The site's own password, as Netlify gives it: nothing typed would be righter. Where the lock stood all the same, the page asks for the app's own sign-in past it,
90
86
  // which a page a visitor opens gets past, or Netlify did not answer its form; else the proof's reason says what stood in the way.
91
- return { status: 'refused', reason: done.locked ? "Netlify's own site password opened the address neither in the header nor on Netlify's form: the page asks for the app's own sign-in past the lock, or Netlify did not answer. Prove a page a visitor opens without signing in, and call this tool again." : done.reason, protection: 'password', ...from };
87
+ return { status: 'refused', reason: done.locked ? "Netlify's own site password did not open the address on Netlify's form: the page asks for the app's own sign-in past the lock, or Netlify did not answer. Prove a page a visitor opens without signing in, and call this tool again." : done.reason, protection: 'password', ...from };
92
88
  }
93
89
  }
94
90
  else {
@@ -133,7 +129,7 @@ async function openToVisitors(deps, site) {
133
129
  return read;
134
130
  // A site read as public, or one the CLI cannot read once the person changed it on Netlify's page: the preview says.
135
131
  if (read ? !read.teamLogin : tries > 0) {
136
- const proof = await deps.prove(undefined, false);
132
+ const proof = await deps.prove(undefined);
137
133
  if (proof.proven)
138
134
  return { status: 'opened', madePublic: ranPublic ? 'run' : 'person', ...from };
139
135
  // Past the login, a page that is not the app's: the proof says why.
package/dist/netlify.js CHANGED
@@ -145,6 +145,7 @@ async function netlifySites(netlify) {
145
145
  async function fromCli(owner, repo, netlify, only, directory) {
146
146
  const { cli } = await netlifyStatus(netlify);
147
147
  const addresses = new Map();
148
+ const teams = {};
148
149
  if (cli !== 'signed-in')
149
150
  return { cli, sites: [], addresses, previews: [], failed: cli === 'unknown' };
150
151
  const slug = `${owner}/${repo}`.toLowerCase();
@@ -162,6 +163,8 @@ async function fromCli(owner, repo, netlify, only, directory) {
162
163
  if (directory !== undefined && folderOf(record(site.build_settings)?.base) !== directory)
163
164
  continue;
164
165
  sites.push(site.name);
166
+ if (typeof site.account_name === 'string' && site.account_name && site.account_name.length <= 100)
167
+ teams[site.name] = site.account_name;
165
168
  const fact = netlifyPreviewsFact(site);
166
169
  if (fact)
167
170
  facts.push(fact);
@@ -186,7 +189,7 @@ async function fromCli(owner, repo, netlify, only, directory) {
186
189
  failed = true;
187
190
  }
188
191
  const pullRequestPreviews = pickPreviewsFact(facts);
189
- return { cli: 'signed-in', sites, addresses, previews, failed, ...(pullRequestPreviews ? { pullRequestPreviews } : {}) };
192
+ return { cli: 'signed-in', sites, addresses, ...(Object.keys(teams).length ? { teams } : {}), previews, failed, ...(pullRequestPreviews ? { pullRequestPreviews } : {}) };
190
193
  }
191
194
  /** The site whose address `production` is: the newest preview's, else the first the evidence names. */
192
195
  export const liveSite = (evidence) => evidence.previews[0]?.site ?? evidence.sites[0];
@@ -204,7 +207,10 @@ export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
204
207
  : !cli.sites.length && !cli.failed ? `No Netlify site of this repository builds ${where} (its base directory), as far as the Netlify CLI here can see.` : undefined;
205
208
  const github = deps.directory === undefined ? read : { ...read, previews: unmatched ? [] : read.previews.filter((p) => cli.sites.includes(p.site)) };
206
209
  if (unmatched)
207
- Object.assign(cli, { sites: [], previews: [], addresses: new Map(), pullRequestPreviews: undefined });
210
+ Object.assign(cli, { sites: [], previews: [], addresses: new Map(), teams: undefined, pullRequestPreviews: undefined });
211
+ // Each site's team goes to the last screen alone, never into the evidence (#360).
212
+ if (cli.teams)
213
+ deps.onTeams?.(cli.teams);
208
214
  // A deploy of an older commit than its pull request's head says nothing of the head's own preview: a commit status, which names the head, outranks it, and a wait for that pull request's preview ignores it.
209
215
  const older = (p) => p.from === 'netlify cli' && Boolean(p.sha) && github.heads.has(p.pullRequest) && github.heads.get(p.pullRequest) !== p.sha;
210
216
  // Of a pull request's status and the CLI's deploy of the same commit the later change is the truer, a status winning a tie; an older commit's deploy comes next, a bot's comment, which names no commit and changes no state, last.
@@ -11,15 +11,15 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
11
11
 
12
12
  - **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`; live-app-address says how it becomes the before, in step 2's write.
13
13
  - **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
14
- - **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Cloudflare address answered.
14
+ - **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews says instead, and say under "What is not verified" what the Cloudflare address answered.
15
15
  - **No pull-request previews**: the checkout is set up for Cloudflare and nothing is listed, or a project's settings build none. The aim holds: find out why from the answer, in this order, and for each cause ask once with the tool that fixes it, before anything is pushed and never a second time; with a preview listed, after step 1's proof and preview-protection's steps, so the person sees the lock's question and this one back to back, at most one each. Each tool shows its one screen and does the change as the person, or opens the page where it cannot. After `enabled` or `connected`, nothing is listed yet, so the test drive's build is the first preview: take pull-requests' step 4 the Cloudflare way and save the method on its alias as step 2 says, no setup pull request.
16
16
  1. **Previews off** (`pullRequestPreviews` with `enabled: false` and `cause` `previews` or `deployments`, with its `reason`): cf, signed in, found the Pages project built from this repository, and its settings build no previews of pull requests. impeccable_enable_previews (`host: "cloudflare"`) turns the preview branch control on; deployments off altogether is the person's change, on the page it opens.
17
17
  2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
18
18
  3. **A Worker, not a Pages project** (`worker` in the answer, and no `pullRequestPreviews`): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), which gives pull requests no preview, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
19
19
  4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
20
- - **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews or static-site says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty.
20
+ - **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty.
21
21
  - **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
22
- - **Nothing listed**, and neither `pullRequestPreviews` nor `worker` nor a project the checkout names: Cloudflare's previews are not on record, so set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
22
+ - **Nothing listed**, and neither `pullRequestPreviews` nor `worker` nor a project the checkout names: Cloudflare's previews are not on record, so set the preview up as server-previews says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
23
23
  - **The pull requests**: no setup pull request; the test drive branches from the default branch, and Cloudflare builds every pushed branch, so pull-requests' step 4 goes the Vercel way with impeccable_cloudflare_previews in place of impeccable_hosted_previews: with `branch: "impeccable/test-drive"` it waits up to five minutes for the check run on the pushed head, under a status line with its timer, and the test drive's page is proven on the alias it lists before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the build Cloudflare runs (the Pages project's build command, which the checkout's `package.json` `build` script usually is), so a page that does not build is never pushed.
24
24
  - The evidence is read only: link, deploy and change nothing on Cloudflare beyond what impeccable_enable_previews and impeccable_cloudflare_connect do on the person's yes. The Cloudflare sign-ins are those tools' own, on the person's yes. Cloudflare does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern, the branch's alias: save the pattern the proof answered, and no other.
25
25
  - The report names the address that answered with this app under "What we found" (and the Worker, where Cloudflare builds one), and the pattern saved under "What was configured, and by whom", with the pull-request previews turned on when they were (`by` says whether the run or the person did), the Pages project created or connected (`created` says which; name its `subdomain` address) and the access given to Cloudflare's GitHub app, since the account changed. The test drive's own preview, which Cloudflare builds after the run, goes under "What is not verified".
@@ -22,7 +22,6 @@ A rerun: the setup pull request an earlier run opened has a preview job that fai
22
22
  | setup | `runtime` in the state; correct `runtimeVersion`. Nothing was installed yet. |
23
23
  | install | The lockfile and the package manager's pinned version. |
24
24
  | prepare, build | The script's name, the package runner and the app's folder. |
25
- | upload | The real build output and its entry page; correct `outputDir`. |
26
25
  | seed | The migration or seed command and the throwaway database. |
27
26
  | start | `startCommand`, `port`, `env` and readiness. |
28
27
  | tunnel | A repository already on Impeccable's tunnel: the app refusing the forwarded Host, allowed in the framework's host check for previews only (Rails `config.hosts`, Django `ALLOWED_HOSTS`, Vite `preview.allowedHosts`), not Impeccable's own access gate: a 403 on the bare link alone is not a broken preview. |
@@ -1,6 +1,6 @@
1
1
  ### When this applies
2
2
 
3
- Every run that saves a preview method, for the state's `before` item: the address of the live app, which every review then shows beside the change. The item reads `optional` until an address is saved, so the checkup never waits on it; the run sets it all the same, from what the host or the repository already says, and asks nobody: the host's own address of the connected project changes nothing of the person's. Where Impeccable builds the previews itself (static-site), its build of the default branch is the before and the item reads done on its own: set nothing.
3
+ Every run that saves a preview method, for the state's `before` item: the address of the live app, which every review then shows beside the change. The item reads `optional` until an address is saved, so the checkup never waits on it; the run sets it all the same, from what the host or the repository already says, and asks nobody: the host's own address of the connected project changes nothing of the person's.
4
4
 
5
5
  ### Steps
6
6