@impeccable/reviews 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host-questions.js +12 -0
- package/dist/hosted.js +28 -24
- package/dist/netlify-password.js +10 -14
- package/dist/playbook/cases/preview-protection.md +6 -6
- package/dist/tools.js +91 -33
- package/package.json +1 -1
package/dist/host-questions.js
CHANGED
|
@@ -228,6 +228,18 @@ export function sentTo(screen, url) {
|
|
|
228
228
|
const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
|
|
229
229
|
return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
|
|
230
230
|
}
|
|
231
|
+
/** On a second Netlify site behind a password, the offer to use the one the person typed for another site of this run (#384); on Skip, the paste screen. */
|
|
232
|
+
export function netlifySamePassword(site, other) {
|
|
233
|
+
return {
|
|
234
|
+
id: 'netlify-password-same',
|
|
235
|
+
kind: 'confirm',
|
|
236
|
+
title: 'Your Netlify previews are protected',
|
|
237
|
+
hint: `The previews of the Netlify site ${site} are behind its site password too. The run tries the password you typed for ${other} on ${site}'s preview first, and saves it for ${site} only if it opens.`,
|
|
238
|
+
prompt: `Use the same password for ${site}?`,
|
|
239
|
+
confirmLabel: 'Use it',
|
|
240
|
+
skipLabel: 'Type another',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
231
243
|
/** 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
244
|
export function vercelBypassConsent(project, app) {
|
|
233
245
|
const screen = HOST_SCREENS['vercel-bypass'];
|
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
|
/**
|
|
@@ -280,36 +279,43 @@ async function pageText(res, max = 1_000_000) {
|
|
|
280
279
|
}
|
|
281
280
|
return Buffer.concat(chunks).toString('utf8');
|
|
282
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
|
+
}
|
|
283
288
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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.
|
|
287
294
|
*/
|
|
288
|
-
async function
|
|
295
|
+
async function siteSession(target, password, open, signal) {
|
|
296
|
+
const front = new URL('/', target.origin);
|
|
289
297
|
try {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return false;
|
|
295
|
-
const res = await open(target, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', cookie: cookies.join('; ') } });
|
|
296
|
-
if (res.status < 200 || res.status >= 300) {
|
|
297
|
-
await res.body?.cancel().catch(() => { });
|
|
298
|
-
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 '';
|
|
299
302
|
}
|
|
300
|
-
|
|
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('; ');
|
|
301
308
|
}
|
|
302
309
|
catch {
|
|
303
|
-
return
|
|
310
|
+
return '';
|
|
304
311
|
}
|
|
305
312
|
}
|
|
306
313
|
/**
|
|
307
314
|
* Opens a preview address the way a visitor does, signed in nowhere, and proves it: it answers, and its page shows
|
|
308
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
|
|
309
316
|
* Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
|
|
310
|
-
* `password`, a Netlify site password, a Netlify address is opened with it
|
|
311
|
-
*
|
|
312
|
-
* 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
|
|
313
319
|
* Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
|
|
314
320
|
* to another host. With `live`, the address is a live app's rather than a preview's: any https address is opened, a
|
|
315
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
|
|
@@ -333,10 +339,12 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
333
339
|
const opened = deps.bypass && host === 'vercel';
|
|
334
340
|
const withPassword = deps.password !== undefined && host === 'netlify';
|
|
335
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) : '';
|
|
336
344
|
const headers = {
|
|
337
345
|
accept: 'text/html',
|
|
338
346
|
...(opened ? { [BYPASS_HEADER]: deps.bypass } : {}),
|
|
339
|
-
...(
|
|
347
|
+
...(session ? { cookie: session } : {}),
|
|
340
348
|
...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
|
|
341
349
|
};
|
|
342
350
|
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
|
|
@@ -364,10 +372,6 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
364
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)));
|
|
365
373
|
if (!gate || !named)
|
|
366
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);
|
|
367
|
-
// A password the header did not get past: Netlify's own form says whether it is the site's password at all.
|
|
368
|
-
if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
|
|
369
|
-
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 };
|
|
370
|
-
}
|
|
371
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 } : {}) };
|
|
372
376
|
}
|
|
373
377
|
if (res.status < 200 || res.status >= 300) {
|
package/dist/netlify-password.js
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
impeccable_prove_preview answered a `gate`: the host's own lock stands in front of a preview the host made, and a visitor gets its login or its password page. Three locks have a way in for a tool, each with a tool of its own that works as the person in the command's own process, so no key ever reaches you: Vercel's Deployment Protection (`gate: "vercel-protection"`: Vercel Authentication, or Password Protection, which shows the same gate), whose way in is a bypass secret of the project, sent in a header, which every plan has; Netlify's site password (`gate: "netlify-protection"`),
|
|
3
|
+
impeccable_prove_preview answered a `gate`: the host's own lock stands in front of a preview the host made, and a visitor gets its login or its password page. Three locks have a way in for a tool, each with a tool of its own that works as the person in the command's own process, so no key ever reaches you: Vercel's Deployment Protection (`gate: "vercel-protection"`: Vercel Authentication, or Password Protection, which shows the same gate), whose way in is a bypass secret of the project, sent in a header, which every plan has; Netlify's site password (`gate: "netlify-protection"`), which reviews type into Netlify's own password form on that site's previews alone, for the session it gives, as a visitor does (no header carries it, so a Netlify site has no bypass key); and Cloudflare Access in front of a Pages preview (`gate: "cloudflare-access"`), whose way in is a service token that a Service Auth policy of the Access application allows, sent in two headers. Netlify's team login, which the same gate answers for, has none, so the tool makes the site public on the person's yes. A project the Vercel CLI creates starts protected, so vercel-setup's new project ends here as well, and so does netlify-setup's new site on a team that keeps new sites private: these steps come between the proof and the save of the method, in vercel-previews', netlify-previews' or cloudflare-previews' step 2 or vercel-setup's or netlify-setup's step 5, before the test drive's pull request opens (on Netlify, before its draft is marked ready), and end with the preview opened.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -10,17 +10,17 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
|
|
|
10
10
|
- `login` (Netlify): Netlify kept its team login in front through the retries; `reason` says so. Step 3.
|
|
11
11
|
- Any answer with `madePublic` but `opened`: the run made the Netlify site public and it stays so, though the preview did not open; the report names that change as the bullet below says, whatever step comes next.
|
|
12
12
|
- `refused` with `protection: "none"` (Netlify: the site's settings show neither a password nor the team login, or the site is public now, `madePublic` saying who made it so, and the page past the login is not the app's), or on Vercel whose `reason` says the settings show no Deployment Protection: the page you proved is the app's own sign-in or another page, however it is worded, and the previews are open. Prove a page a visitor opens without signing in, as vercel-previews' or netlify-previews' step 1 says, and on `proven: true` go on with that case's step 2, the save, calling this tool no more: there is no lock to get past. No such page proven: the host's previews do not show this app to a visitor, so go on as that case's "Another app, or no answer" says (set the preview up as server-previews says, and say under "What is not verified" what the address answered), never step 3, whose lock the settings show is not there.
|
|
13
|
-
- Any other `refused` on Netlify whose `reason` says to prove or try a page a visitor opens, or that the page asks for the app's own sign-in past the lock: the site password (Netlify's own, or the one typed) got past the lock to the app's own sign-in on the page you proved, or
|
|
13
|
+
- Any other `refused` on Netlify whose `reason` says to prove or try a page a visitor opens, or that the page asks for the app's own sign-in past the lock: the site password (Netlify's own, or the one typed) got past the lock to the app's own sign-in on the page you proved, or Netlify's form did not take it, or Netlify did not answer. Prove a page a visitor opens without signing in, as netlify-previews' step 1 says, and call the tool again; the consent is not asked a second time. Refused again, step 3.
|
|
14
14
|
- Any other `skipped` or `refused`: nothing was saved; `reason` says what stopped it. Step 3.
|
|
15
|
-
- `save-failed`: the setup API did not take it all; `reason` says how far it got. Call the tool once more; when it fails again, step 3, with "Save the Vercel bypass secret on the setup page", "
|
|
16
|
-
2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}` (on a repository with several apps, the app's own key: the tool saved the key for that app), and report what it answered. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
|
|
17
|
-
3. **When nothing was saved**, save no method and set up no other preview, whatever the checkup's `next` says, and open no test drive (pull-requests' "No preview, no test drive"). Say under "What is not verified" that the previews are behind the host's lock, with the tool's `reason`, and name the settings page where the person makes the key, or makes the Netlify site public (the report's bullet below); put the person's one step in `left`, the settings action alone and never running the setup again (for Netlify's team login, none: the person was asked in this run, so `left` stays empty and the report names the Visitor access page); end the report's "Not part of this setup" line with the lock the tool met: "Vercel's Deployment Protection in front of the previews", "Netlify's password in front of the previews", "Netlify's team login in front of the previews", or "Cloudflare Access in front of the previews". Done when the report says so.
|
|
15
|
+
- `save-failed`: the setup API did not take it all; `reason` says how far it got. Call the tool once more; when it fails again, step 3, with "Save the Vercel bypass secret on the setup page", "Type the Netlify site password in the app's Settings, under "A way past your host's lock"" or "Save the Cloudflare service token on the setup page" in `left`.
|
|
16
|
+
2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}` (on a repository with several apps, the app's own key: the tool saved the key for that app), and report what it answered. A check that answers `access: "auth-wall"` met the lock all the same: say so under "What is not verified", with its `detail`, and never as verified; the last screen does not call the app set up while it stands. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
|
|
17
|
+
3. **When nothing was saved**, save no method and set up no other preview, whatever the checkup's `next` says, and open no test drive (pull-requests' "No preview, no test drive"). Say under "What is not verified" that the previews are behind the host's lock, with the tool's `reason`, and name the settings page where the person makes the key, or makes the Netlify site public (the report's bullet below); put the person's one step in `left`, the settings action alone and never running the setup again (for Netlify's site password: "Type the Netlify site password in the app's Settings, under "A way past your host's lock"", and, with the draft test drive open, "Then mark draft #<n> ready", never a bypass key, which Netlify has none of; for Netlify's team login, none: the person was asked in this run, so `left` stays empty and the report names the Visitor access page); end the report's "Not part of this setup" line with the lock the tool met: "Vercel's Deployment Protection in front of the previews", "Netlify's password in front of the previews", "Netlify's team login in front of the previews", or "Cloudflare Access in front of the previews". Done when the report says so.
|
|
18
18
|
|
|
19
19
|
### Reference
|
|
20
20
|
|
|
21
21
|
- **Before the test drive's push, on Vercel** (pull-requests' step 4): impeccable_vercel_bypass with `beforePush: true`, and the app's `appKey` on a repository with several apps, reads the project's settings and, where Vercel's login will stand in front of the test drive's preview, asks the person now, on a screen naming the project and the app. `protected: false`, or `protected: true` with `consent` `given` or `not-needed`, or `protected: "unknown"`: push. Once the proof meets the gate, step 1 runs as written, and the yes is not asked again. `skipped`: push and open no test drive, and end as step 3 says, with the tool's `reason`.
|
|
22
22
|
- **The report** names, under "What was configured, and by whom", the Vercel project that holds the secret named "Impeccable reviews" (`project`), whether this run created it, found it there or the person typed it, and where it is revoked: the project's Settings → Deployment Protection → Protection Bypass for Automation; or the Netlify site (`site`) whose password is stored, whether this run read it from the site or the person typed it, and where it is changed: the site's Project configuration → General → Visitor access; or, with `opened`, the Netlify site made public, by this run on the person's yes or by the person on Netlify's page (`madePublic`), so anyone with its address opens it, and where it is made private again: the same Visitor access; or the service token named "Impeccable reviews", whether this run created it or the person typed it, the Access application whose policy allows it (`application`), when a created one runs out (`expires`), and where it is revoked: Zero Trust → Access controls → Service credentials → Service Tokens; with `apiToken`, the Cloudflare API token the person made for the run is theirs to delete (dash.cloudflare.com/profile/api-tokens). Revoking, regenerating or changing it stops reviews until a new one is saved on the setup page.
|
|
23
|
-
- **The tool does the writes**: the key as Impeccable's credential for the lock, and the setting that sends it
|
|
23
|
+
- **The tool does the writes**: the key as Impeccable's credential for the lock, and the setting that sends it (Vercel's `x-vercel-protection-bypass` header, Netlify's password form, or Cloudflare's `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers), both through the setup API, so impeccable_write refuses those two. The state's `preview-protection` item words the same calls, and shows `keySaved` once they are done.
|
|
24
24
|
- **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails. On a repository with several apps, the item's `keySaved` speaks for one app at most: an app whose own proof answered a `gate` gets step 1 with its `appKey`, whatever another app's key did, and its step 2 check is its own.
|
|
25
25
|
- **The key is never yours**: it goes from the command's own process to the host and to Impeccable, and into no message, status line, file, command or report. `vercel project protection`, `vercel api`, `vercel curl` and `netlify api` print it, and the fence refuses them.
|
|
26
26
|
- **The app's own sign-in** is another card: a preview opened past the lock proves nothing about it.
|
package/dist/tools.js
CHANGED
|
@@ -10,13 +10,13 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifySamePassword, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
17
17
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
18
18
|
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
19
|
-
import {
|
|
19
|
+
import { passNetlifyLock } from './netlify-password.js';
|
|
20
20
|
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
21
21
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
22
22
|
import { fitsTask, stagesOf } from './stages.js';
|
|
@@ -123,8 +123,10 @@ export function appsSummary(apps) {
|
|
|
123
123
|
/** A host's lock in front of a preview, as impeccable_prove_preview names it: how the person reads it, the tool that gets a key past it, and the step left where none was saved (preview-protection's step 3). */
|
|
124
124
|
const LOCKS = {
|
|
125
125
|
'vercel-protection': { lock: "Vercel's login", tool: 'impeccable_vercel_bypass', left: 'Save the Vercel bypass secret on the setup page' },
|
|
126
|
-
'netlify-protection': { lock: "Netlify's password
|
|
126
|
+
'netlify-protection': { lock: "Netlify's site password", tool: 'impeccable_netlify_password', left: `Type the Netlify site password in the app's Settings, under "A way past your host's lock"` },
|
|
127
127
|
'cloudflare-access': { lock: 'Cloudflare Access', tool: 'impeccable_cloudflare_access', left: 'Save the Cloudflare service token on the setup page' },
|
|
128
|
+
// Netlify's gate where its page was the team login, which no key gets past: how the last screen words it (#375).
|
|
129
|
+
'netlify-login': { lock: "Netlify's team login", tool: 'impeccable_netlify_password', left: `Make the Netlify site public, or give it a password, in ${NETLIFY_PASSWORD_WHERE}` },
|
|
128
130
|
};
|
|
129
131
|
const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
|
|
130
132
|
/** Why the status line and the task list say different things, one task in progress at a time and the status its detail; null when they agree. */
|
|
@@ -140,7 +142,8 @@ function stageError(status, tasks) {
|
|
|
140
142
|
}
|
|
141
143
|
/** The line under the last screen's heading, about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. Without a test drive, what stopped it, from what the run saved and met (#321). */
|
|
142
144
|
export function resultLine(report) {
|
|
143
|
-
const lead =
|
|
145
|
+
const lead = report.walled ? `Impeccable's own check of the preview met a wall (${report.walled}), so the test drive's review can't see its pages yet. The report, printed here when this screen closes, says what the run found.`
|
|
146
|
+
: !report.testPr ? `${noTestDrive(report)} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
144
147
|
return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
|
|
145
148
|
}
|
|
146
149
|
/** Why a run opened no test drive, as far as what it saved and met tells. */
|
|
@@ -157,9 +160,12 @@ export function ending(report) {
|
|
|
157
160
|
// On a repository with several apps, each locked app's own line says it.
|
|
158
161
|
const step = !report.apps && !report.methodSaved && report.lock ? [LOCKS[report.lock].left] : [];
|
|
159
162
|
const todo = [...new Set([...step, ...apps.todo, ...report.left])];
|
|
160
|
-
if (report.testPr && !apps.notReady.length)
|
|
163
|
+
if (report.testPr && !apps.notReady.length && !report.walled)
|
|
161
164
|
return { outcome: 'done', title: "You're all set up", todo, code: 0 };
|
|
162
165
|
const outcome = report.methodSaved ? 'partial' : 'stopped';
|
|
166
|
+
// Impeccable's own check said it can't get in: whatever the run saved, reviews will meet the same wall (#383).
|
|
167
|
+
if (report.walled)
|
|
168
|
+
return { outcome, title: "Not set up: Impeccable's check can't get past the preview's wall", todo: [...new Set([`Open the app's Settings: Impeccable's check met ${report.walled}`, ...todo])], code: 1 };
|
|
163
169
|
if (apps.notReady.length)
|
|
164
170
|
return { outcome, title: `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up`, todo, code: 1 };
|
|
165
171
|
const title = report.methodSaved ? 'No test drive opened' : `Not set up: ${report.lock ? `the previews are behind ${LOCKS[report.lock].lock}` : 'no preview method was saved'}`;
|
|
@@ -1608,6 +1614,8 @@ export function createTools(ctx) {
|
|
|
1608
1614
|
/** Whether any preview write with a method was taken this run, and the host's lock the last gated proof met: the last screen names it when no preview ever opened (#321), and Preview method is completed only once a method is saved (#322). */
|
|
1609
1615
|
let methodSaved = false;
|
|
1610
1616
|
let lastLock;
|
|
1617
|
+
/** Netlify's gate, as last met, was its team login rather than the site password: the last screen names that one. */
|
|
1618
|
+
let lastTeamLogin = false;
|
|
1611
1619
|
/** The host preview addresses a proof or a host's key opened, and the Cloudflare account and Netlify team of each project or site the host tools read, by host and project: the last screen names them (#360), and no tool's answer does. */
|
|
1612
1620
|
const usedPreviews = new Set();
|
|
1613
1621
|
const accountOf = new Map();
|
|
@@ -1688,15 +1696,18 @@ export function createTools(ctx) {
|
|
|
1688
1696
|
const bypassOutcomes = new Map();
|
|
1689
1697
|
/** The stores whose consent was given before the test drive's push, each with the project it named (`<store>/<projectId>`): the call once the gate is met asks no second time for that project alone. */
|
|
1690
1698
|
const bypassConsented = new Set();
|
|
1691
|
-
/**
|
|
1692
|
-
|
|
1693
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* The Netlify site passwords saved in this run, by site, for the proof of that site's addresses to send; never for the
|
|
1701
|
+
* model. Each is one site's, so it goes only to that site's deploy previews, and a monorepo's sites keep theirs side by
|
|
1702
|
+
* side (#384): with what the tool answered, whether the person typed it, and the apps it is saved for.
|
|
1703
|
+
*/
|
|
1704
|
+
const sitePasswords = new Map();
|
|
1694
1705
|
/** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, the team login kept after the retries stays, and a site made public stays public. Another site of the repository is read on its own. */
|
|
1695
1706
|
const passwordStands = new Map();
|
|
1696
1707
|
/** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
|
|
1697
1708
|
const passwordConsented = new Set();
|
|
1698
|
-
/** The
|
|
1699
|
-
|
|
1709
|
+
/** The sites whose last typed password did not open the preview: the paste screen, asked again, says so first (#375). */
|
|
1710
|
+
const passwordRefused = new Set();
|
|
1700
1711
|
/** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
|
|
1701
1712
|
let accessGated;
|
|
1702
1713
|
/** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
|
|
@@ -1802,6 +1813,8 @@ export function createTools(ctx) {
|
|
|
1802
1813
|
const provenApps = new Set();
|
|
1803
1814
|
/** The apps whose preview check (connection-check or latest-pull-check) the setup API answered in this run ('' for the repository): a ready app missing here is named on the last screen (#338). */
|
|
1804
1815
|
const checkedApps = new Set();
|
|
1816
|
+
/** What Impeccable's own last check of each app's preview met where it did not get in ('' for the repository's), until a later check gets in (#383). */
|
|
1817
|
+
const checkWalls = new Map();
|
|
1805
1818
|
/** On a repository with several apps (#286): the method each app's preview write saved; the host's lock a proof of each app's preview met; and the apps whose lock was met with no key tool called for them since, which holds every other app's steps until it is. */
|
|
1806
1819
|
const appMethods = new Map();
|
|
1807
1820
|
const lockMet = new Map();
|
|
@@ -2088,14 +2101,15 @@ export function createTools(ctx) {
|
|
|
2088
2101
|
* Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
|
|
2089
2102
|
* `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
|
|
2090
2103
|
*/
|
|
2091
|
-
|
|
2104
|
+
/** Saves a host's key as the lock's own credential, then the lock that sends it: a bypass secret in its header, or Netlify's site password, with which reviews sign in on that site's form alone. */
|
|
2105
|
+
const saveLock = async (pi, what, lock, value, appKey) => {
|
|
2092
2106
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
2093
2107
|
// On a repository with several apps, the key is that app's own: each app's previews are its own project's or site's.
|
|
2094
2108
|
const app = appKey !== undefined ? { appKey } : {};
|
|
2095
2109
|
if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
|
|
2096
2110
|
return { stored: false, failed: `The setup API did not store the ${what}.` };
|
|
2097
2111
|
noteApp(appKey, { key_placed: true });
|
|
2098
|
-
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: {
|
|
2112
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { ...lock, ...app } })).ok)
|
|
2099
2113
|
return { stored: true, failed: `The setup API stored the ${what}, but not the setting that sends it: it is turned on under Deployment protection on the setup page.` };
|
|
2100
2114
|
noteApp(appKey, { method_saved: true });
|
|
2101
2115
|
return { stored: true, failed: null };
|
|
@@ -2223,6 +2237,9 @@ export function createTools(ctx) {
|
|
|
2223
2237
|
const proven = provenApps.has(key);
|
|
2224
2238
|
if (!pickedApps?.has(key))
|
|
2225
2239
|
return { key, name, folder, state: 'unpicked' };
|
|
2240
|
+
// Impeccable's own last check of its preview met a wall: its reviews will meet it too (#383).
|
|
2241
|
+
if (method !== undefined && checkWalls.has(key))
|
|
2242
|
+
return { key, name, folder, state: 'walled', left: `${name}: Impeccable's check of its preview met ${checkWalls.get(key)}; open its Settings.` };
|
|
2226
2243
|
if (method !== undefined && (method !== 'preview-deploy' || proven)) {
|
|
2227
2244
|
// With a test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
|
|
2228
2245
|
return ctx.sync.payload.test_pr && !checkedApps.has(key)
|
|
@@ -2244,6 +2261,8 @@ export function createTools(ctx) {
|
|
|
2244
2261
|
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2245
2262
|
if (a.state === 'unset')
|
|
2246
2263
|
return `- ${where}: not set up. No preview method was saved for it.`;
|
|
2264
|
+
if (a.state === 'walled')
|
|
2265
|
+
return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but Impeccable's own check of its preview met ${checkWalls.get(a.key)}.`;
|
|
2247
2266
|
return `- ${where}: not set up this run.`;
|
|
2248
2267
|
}).join('\n')}`;
|
|
2249
2268
|
/** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
|
|
@@ -2437,6 +2456,16 @@ export function createTools(ctx) {
|
|
|
2437
2456
|
// A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
|
|
2438
2457
|
if (check && (response.ok || response.status === 409))
|
|
2439
2458
|
checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
|
|
2459
|
+
if (check && response.ok) {
|
|
2460
|
+
// A login wall the check met is the one the review meets: the run is not called set up while it stands (#383).
|
|
2461
|
+
const key = typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '';
|
|
2462
|
+
const result = object(object(response.data)?.result), pulled = object(object(response.data)?.check);
|
|
2463
|
+
const walled = result?.access === 'auth-wall' || pulled?.outcome === 'sign-in';
|
|
2464
|
+
if (walled)
|
|
2465
|
+
checkWalls.set(key, typeof result?.detail === 'string' && plainText(result.detail) === result.detail && result.detail.length <= 120 ? `a ${result.detail}` : 'a login wall');
|
|
2466
|
+
else if (result?.access === 'ok' || pulled?.outcome === 'reached')
|
|
2467
|
+
checkWalls.delete(key);
|
|
2468
|
+
}
|
|
2440
2469
|
if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
|
|
2441
2470
|
confirmedApps = true;
|
|
2442
2471
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
@@ -2666,7 +2695,7 @@ export function createTools(ctx) {
|
|
|
2666
2695
|
}
|
|
2667
2696
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2668
2697
|
const hosts = hostLines(hostsUsed());
|
|
2669
|
-
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2698
|
+
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2670
2699
|
pi.shutdown();
|
|
2671
2700
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2672
2701
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2905,7 +2934,7 @@ export function createTools(ctx) {
|
|
|
2905
2934
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
2906
2935
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
2907
2936
|
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
2908
|
-
...(
|
|
2937
|
+
...(site && sitePasswords.has(site) ? { password: sitePasswords.get(site).value } : {}),
|
|
2909
2938
|
...(token ? { access: token } : {}),
|
|
2910
2939
|
});
|
|
2911
2940
|
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
@@ -2916,6 +2945,14 @@ export function createTools(ctx) {
|
|
|
2916
2945
|
}
|
|
2917
2946
|
// On a repository with several apps, the app the address was listed for: its proof, its lock and its key are that app's alone (#281).
|
|
2918
2947
|
const app = listedApps.get(origin);
|
|
2948
|
+
// A site's password opened the preview of an app it was not saved for (another app built from the same site): it is saved as that app's own before the proof stands, since that app's reviews read its own (#384).
|
|
2949
|
+
const held = site ? sitePasswords.get(site) : undefined;
|
|
2950
|
+
if (proof.proven && held && app && !held.apps.has(app.appKey)) {
|
|
2951
|
+
const saved = await saveLock(pi, 'password', { method: 'password', site: site }, held.value, app.appKey);
|
|
2952
|
+
if (saved.failed)
|
|
2953
|
+
return JSON.stringify({ proven: false, status: proof.status, appKey: app.appKey, reason: `The site's password opened the preview, but it is not saved for this app: ${saved.failed} Prove this address again to save it.` });
|
|
2954
|
+
held.apps.add(app.appKey);
|
|
2955
|
+
}
|
|
2919
2956
|
hostedPreviewProven ||= proof.proven;
|
|
2920
2957
|
if (proof.proven)
|
|
2921
2958
|
provenHost = previewHostName(url) ?? provenHost;
|
|
@@ -2933,6 +2970,7 @@ export function createTools(ctx) {
|
|
|
2933
2970
|
}
|
|
2934
2971
|
if (proof.gate && Object.hasOwn(LOCKS, proof.gate)) {
|
|
2935
2972
|
lastLock = proof.gate;
|
|
2973
|
+
lastTeamLogin = proof.teamLogin === true;
|
|
2936
2974
|
// Pristine learns the lock from the session, proven past or not, for a preview it may never meet itself (#365): an app's on its summary, a repository of one app's beside them.
|
|
2937
2975
|
const met = { gate: lastLock, preview_host: new URL(url).hostname };
|
|
2938
2976
|
// The app is proven again only once something opens it, as `provenApps` has it.
|
|
@@ -3244,7 +3282,7 @@ export function createTools(ctx) {
|
|
|
3244
3282
|
...(signal ? { signal } : {}),
|
|
3245
3283
|
prove: async (secret) => sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret })),
|
|
3246
3284
|
save: async (secret) => {
|
|
3247
|
-
const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
|
|
3285
|
+
const saved = await saveLock(pi, 'secret', { method: 'header', headerName: BYPASS_HEADER }, secret, at.app?.appKey);
|
|
3248
3286
|
stored = saved.stored;
|
|
3249
3287
|
return saved.failed;
|
|
3250
3288
|
},
|
|
@@ -3271,7 +3309,7 @@ export function createTools(ctx) {
|
|
|
3271
3309
|
}
|
|
3272
3310
|
return JSON.stringify(outcome);
|
|
3273
3311
|
}),
|
|
3274
|
-
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview,
|
|
3312
|
+
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one (after one offering the password the person typed for another site of this run, where there is one); opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3275
3313
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
3276
3314
|
if (!gated || !site)
|
|
3277
3315
|
return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
|
|
@@ -3280,8 +3318,8 @@ export function createTools(ctx) {
|
|
|
3280
3318
|
lockPending.delete(at.app.appKey);
|
|
3281
3319
|
/** The address pattern the proof with the password answers, saved as the method once the password is. */
|
|
3282
3320
|
let pattern;
|
|
3283
|
-
const prove = async (password
|
|
3284
|
-
const proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password
|
|
3321
|
+
const prove = async (password) => {
|
|
3322
|
+
const proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password }));
|
|
3285
3323
|
if (proof.proven && proof.pattern)
|
|
3286
3324
|
pattern = proof.pattern;
|
|
3287
3325
|
return proof;
|
|
@@ -3299,15 +3337,25 @@ export function createTools(ctx) {
|
|
|
3299
3337
|
}
|
|
3300
3338
|
else if (stands)
|
|
3301
3339
|
return JSON.stringify(stands);
|
|
3302
|
-
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
3303
|
-
|
|
3340
|
+
// A password in hand already: another deploy preview of the same site opens with it, and another app built from the same site gets it saved as its own. Another site's lock is read for that site.
|
|
3341
|
+
const held = sitePasswords.get(site);
|
|
3342
|
+
if (held && (await prove(held.value)).proven) {
|
|
3343
|
+
const store = at.app?.appKey ?? '';
|
|
3344
|
+
if (!held.apps.has(store)) {
|
|
3345
|
+
const saved = await saveLock(pi, 'password', { method: 'password', site }, held.value, at.app?.appKey);
|
|
3346
|
+
if (saved.failed)
|
|
3347
|
+
return JSON.stringify({ status: 'save-failed', site, protection: 'password', reason: saved.failed });
|
|
3348
|
+
held.apps.add(store);
|
|
3349
|
+
}
|
|
3304
3350
|
provePattern(pattern, at.app?.appKey);
|
|
3305
3351
|
if (at.app)
|
|
3306
3352
|
proveApp(at.app.appKey, at.url);
|
|
3307
|
-
return JSON.stringify(
|
|
3353
|
+
return JSON.stringify(held.saved);
|
|
3308
3354
|
}
|
|
3309
3355
|
/** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
|
|
3310
3356
|
let stored = false;
|
|
3357
|
+
/** The person typed a password on the paste screen in this call. */
|
|
3358
|
+
let typed = false;
|
|
3311
3359
|
const { value, ...told } = await passNetlifyLock({
|
|
3312
3360
|
run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
|
|
3313
3361
|
site,
|
|
@@ -3332,21 +3380,36 @@ export function createTools(ctx) {
|
|
|
3332
3380
|
return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
|
|
3333
3381
|
},
|
|
3334
3382
|
paste: async () => {
|
|
3335
|
-
|
|
3383
|
+
// One password for several sites is common: the one typed for another site of this run is offered first, once, never after it was refused here (#384).
|
|
3384
|
+
const other = passwordRefused.has(site) ? undefined : [...sitePasswords].find(([name, h]) => name !== site && h.typed);
|
|
3385
|
+
if (other && !(await ask(netlifySamePassword(site, other[0]))).skipped) {
|
|
3386
|
+
typed = true;
|
|
3387
|
+
return other[1].value;
|
|
3388
|
+
}
|
|
3389
|
+
const screen = sentTo(HOST_SCREENS['netlify-password-key'], at.url);
|
|
3390
|
+
const answer = await ask(passwordRefused.has(site) ? { ...screen, prompt: `The password typed last didn't open the preview on Netlify's form, so nothing was saved. Check it and type it again. ${screen.prompt}` } : screen);
|
|
3391
|
+
typed = !answer.skipped;
|
|
3336
3392
|
return answer.skipped ? undefined : answer.value?.trim();
|
|
3337
3393
|
},
|
|
3338
3394
|
prove,
|
|
3339
3395
|
save: async (password) => {
|
|
3340
|
-
|
|
3396
|
+
// Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
|
|
3397
|
+
const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
|
|
3341
3398
|
stored = saved.stored;
|
|
3342
3399
|
return saved.failed;
|
|
3343
3400
|
},
|
|
3344
3401
|
remember: noteSensitiveAnswer,
|
|
3345
3402
|
});
|
|
3346
3403
|
const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
|
|
3347
|
-
// Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321).
|
|
3404
|
+
// Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321). Else they say which lock it is.
|
|
3348
3405
|
if (told.protection === 'none' && lastLock === 'netlify-protection')
|
|
3349
3406
|
lastLock = undefined;
|
|
3407
|
+
if (told.protection === 'password' || told.protection === 'team-login')
|
|
3408
|
+
lastTeamLogin = told.protection === 'team-login';
|
|
3409
|
+
if (told.status === 'refused' && typed)
|
|
3410
|
+
passwordRefused.add(site);
|
|
3411
|
+
else if (told.status === 'saved')
|
|
3412
|
+
passwordRefused.delete(site);
|
|
3350
3413
|
if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
|
|
3351
3414
|
passwordStands.set(site, passwordOutcome);
|
|
3352
3415
|
if (told.status === 'opened') {
|
|
@@ -3359,23 +3422,18 @@ export function createTools(ctx) {
|
|
|
3359
3422
|
provePattern(pattern, at.app?.appKey);
|
|
3360
3423
|
}
|
|
3361
3424
|
if (value) {
|
|
3362
|
-
// What is in hand follows the store:
|
|
3363
|
-
|
|
3364
|
-
passwordSites.clear();
|
|
3365
|
-
passwordSites.add(site);
|
|
3425
|
+
// What is in hand follows the store: the password saved now is this site's, beside every other site's.
|
|
3426
|
+
sitePasswords.set(site, { value, saved: passwordOutcome, typed: told.password === 'typed', apps: new Set([at.app?.appKey ?? '']) });
|
|
3366
3427
|
hostedPreviewProven = true;
|
|
3367
3428
|
provenHost = 'Netlify';
|
|
3368
3429
|
usedPreviews.add(at.url);
|
|
3369
3430
|
if (at.app)
|
|
3370
3431
|
proveApp(at.app.appKey, at.url);
|
|
3371
3432
|
provePattern(pattern, at.app?.appKey);
|
|
3372
|
-
passwordSaved = passwordOutcome;
|
|
3373
3433
|
}
|
|
3374
3434
|
else if (told.status === 'save-failed' && stored) {
|
|
3375
|
-
// The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through.
|
|
3376
|
-
|
|
3377
|
-
passwordSites.clear();
|
|
3378
|
-
passwordSaved = undefined;
|
|
3435
|
+
// The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands for this site until a save goes through.
|
|
3436
|
+
sitePasswords.delete(site);
|
|
3379
3437
|
}
|
|
3380
3438
|
return JSON.stringify(passwordOutcome);
|
|
3381
3439
|
}),
|