@qulib/core 0.4.2 → 0.5.0
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 +135 -8
- package/dist/__tests__/cli-smoke-fixture.d.ts +2 -0
- package/dist/__tests__/cli-smoke-fixture.d.ts.map +1 -0
- package/dist/__tests__/cli-smoke-fixture.js +58 -0
- package/dist/__tests__/fixture-server.d.ts +6 -0
- package/dist/__tests__/fixture-server.d.ts.map +1 -0
- package/dist/__tests__/fixture-server.js +141 -0
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +84 -5
- package/dist/cli/auth-login-run.d.ts.map +1 -1
- package/dist/cli/auth-login-run.js +26 -2
- package/dist/cli/index.js +12 -6
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/llm/providers/anthropic.js +1 -1
- package/dist/phases/observe.js +2 -2
- package/dist/phases/think-finalize.d.ts.map +1 -1
- package/dist/phases/think-finalize.js +7 -1
- package/dist/phases/think.js +1 -1
- package/dist/schemas/automation-maturity.schema.d.ts +8 -0
- package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
- package/dist/schemas/automation-maturity.schema.js +1 -0
- package/dist/schemas/repo-analysis.schema.d.ts +7 -0
- package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
- package/dist/telemetry/telemetry.interface.d.ts +1 -1
- package/dist/telemetry/telemetry.interface.d.ts.map +1 -1
- package/dist/tools/apply-auth.d.ts +4 -0
- package/dist/tools/apply-auth.d.ts.map +1 -0
- package/dist/tools/apply-auth.js +35 -0
- package/dist/tools/auth/apply.d.ts +4 -0
- package/dist/tools/auth/apply.d.ts.map +1 -0
- package/dist/tools/auth/apply.js +35 -0
- package/dist/tools/auth/block-gap.d.ts +9 -0
- package/dist/tools/auth/block-gap.d.ts.map +1 -0
- package/dist/tools/auth/block-gap.js +52 -0
- package/dist/tools/auth/custom-providers.d.ts +15 -0
- package/dist/tools/auth/custom-providers.d.ts.map +1 -0
- package/dist/tools/auth/custom-providers.js +62 -0
- package/dist/tools/auth/detect.d.ts +23 -0
- package/dist/tools/auth/detect.d.ts.map +1 -0
- package/dist/tools/auth/detect.js +526 -0
- package/dist/tools/auth/detector.d.ts +23 -0
- package/dist/tools/auth/detector.d.ts.map +1 -0
- package/dist/tools/auth/detector.js +526 -0
- package/dist/tools/auth/explore.d.ts +4 -0
- package/dist/tools/auth/explore.d.ts.map +1 -0
- package/dist/tools/auth/explore.js +346 -0
- package/dist/tools/auth/explorer.d.ts +4 -0
- package/dist/tools/auth/explorer.d.ts.map +1 -0
- package/dist/tools/auth/explorer.js +346 -0
- package/dist/tools/auth/gaps.d.ts +9 -0
- package/dist/tools/auth/gaps.d.ts.map +1 -0
- package/dist/tools/auth/gaps.js +52 -0
- package/dist/tools/auth/oauth-providers.d.ts +7 -0
- package/dist/tools/auth/oauth-providers.d.ts.map +1 -0
- package/dist/tools/auth/oauth-providers.js +21 -0
- package/dist/tools/auth/providers.d.ts +7 -0
- package/dist/tools/auth/providers.d.ts.map +1 -0
- package/dist/tools/auth/providers.js +21 -0
- package/dist/tools/auth/surface-analyzer.d.ts +4 -0
- package/dist/tools/auth/surface-analyzer.d.ts.map +1 -0
- package/dist/tools/auth/surface-analyzer.js +170 -0
- package/dist/tools/auth/surface.d.ts +4 -0
- package/dist/tools/auth/surface.d.ts.map +1 -0
- package/dist/tools/auth/surface.js +170 -0
- package/dist/tools/auth/user-providers.d.ts +15 -0
- package/dist/tools/auth/user-providers.d.ts.map +1 -0
- package/dist/tools/auth/user-providers.js +62 -0
- package/dist/tools/auth-block-gap.d.ts +6 -0
- package/dist/tools/auth-block-gap.d.ts.map +1 -1
- package/dist/tools/auth-block-gap.js +42 -9
- package/dist/tools/auth-detector.d.ts +9 -8
- package/dist/tools/auth-detector.d.ts.map +1 -1
- package/dist/tools/auth-detector.js +106 -8
- package/dist/tools/explorers/browser.d.ts +3 -0
- package/dist/tools/explorers/browser.d.ts.map +1 -0
- package/dist/tools/explorers/browser.js +13 -0
- package/dist/tools/explorers/cypress-explorer.d.ts +8 -0
- package/dist/tools/explorers/cypress-explorer.d.ts.map +1 -0
- package/dist/tools/explorers/cypress-explorer.js +5 -0
- package/dist/tools/explorers/cypress.d.ts +8 -0
- package/dist/tools/explorers/cypress.d.ts.map +1 -0
- package/dist/tools/explorers/cypress.js +5 -0
- package/dist/tools/explorers/explorer.interface.d.ts +7 -0
- package/dist/tools/explorers/explorer.interface.d.ts.map +1 -0
- package/dist/tools/explorers/explorer.interface.js +1 -0
- package/dist/tools/explorers/factory.d.ts +4 -0
- package/dist/tools/explorers/factory.d.ts.map +1 -0
- package/dist/tools/explorers/factory.js +12 -0
- package/dist/tools/explorers/playwright-explorer.d.ts +8 -0
- package/dist/tools/explorers/playwright-explorer.d.ts.map +1 -0
- package/dist/tools/explorers/playwright-explorer.js +172 -0
- package/dist/tools/explorers/playwright.d.ts +8 -0
- package/dist/tools/explorers/playwright.d.ts.map +1 -0
- package/dist/tools/explorers/playwright.js +172 -0
- package/dist/tools/explorers/types.d.ts +7 -0
- package/dist/tools/explorers/types.d.ts.map +1 -0
- package/dist/tools/explorers/types.js +1 -0
- package/dist/tools/playwright-explorer.js +1 -1
- package/dist/tools/repo/detect-framework.d.ts +15 -0
- package/dist/tools/repo/detect-framework.d.ts.map +1 -0
- package/dist/tools/repo/detect-framework.js +153 -0
- package/dist/tools/repo/framework-detector.d.ts +15 -0
- package/dist/tools/repo/framework-detector.d.ts.map +1 -0
- package/dist/tools/repo/framework-detector.js +153 -0
- package/dist/tools/repo/scan.d.ts +19 -0
- package/dist/tools/repo/scan.d.ts.map +1 -0
- package/dist/tools/repo/scan.js +181 -0
- package/dist/tools/repo/scanner.d.ts +19 -0
- package/dist/tools/repo/scanner.d.ts.map +1 -0
- package/dist/tools/repo/scanner.js +181 -0
- package/dist/tools/scoring/automation-maturity.d.ts +4 -0
- package/dist/tools/scoring/automation-maturity.d.ts.map +1 -0
- package/dist/tools/scoring/automation-maturity.js +231 -0
- package/dist/tools/scoring/gap-engine.d.ts +8 -0
- package/dist/tools/scoring/gap-engine.d.ts.map +1 -0
- package/dist/tools/scoring/gap-engine.js +138 -0
- package/dist/tools/scoring/gaps.d.ts +8 -0
- package/dist/tools/scoring/gaps.d.ts.map +1 -0
- package/dist/tools/scoring/gaps.js +138 -0
- package/dist/tools/scoring/public-surface.d.ts +5 -0
- package/dist/tools/scoring/public-surface.d.ts.map +1 -0
- package/dist/tools/scoring/public-surface.js +13 -0
- package/package.json +3 -3
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { launchBrowser } from '../explorers/browser.js';
|
|
4
|
+
import { BUILT_IN_OAUTH_PROVIDERS } from './providers.js';
|
|
5
|
+
async function waitNetworkIdleBestEffort(page) {
|
|
6
|
+
try {
|
|
7
|
+
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// best-effort — analytics or polling can prevent networkidle
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const PROVIDER_LABELS = new Set(BUILT_IN_OAUTH_PROVIDERS.map((p) => p.label.toLowerCase()));
|
|
14
|
+
function textLooksLikeOAuthIdpButton(text) {
|
|
15
|
+
const t = text.trim();
|
|
16
|
+
if (t.length === 0 || t.length > 120) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (/\b(sign in with|log in with|continue with|sign up with)\b/i.test(t)) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
// Accept single-word / short labels that exactly match a known provider name
|
|
23
|
+
if (PROVIDER_LABELS.has(t.toLowerCase())) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const MAGIC_LINK_PATTERNS = [
|
|
29
|
+
/email me a (sign[- ]?in )?link/i,
|
|
30
|
+
/sign in with email/i,
|
|
31
|
+
/passwordless/i,
|
|
32
|
+
/we'll send you a link/i,
|
|
33
|
+
];
|
|
34
|
+
async function firstTextInputNameForLogin(page) {
|
|
35
|
+
const emailName = await page.locator('input[type="email"]').first().getAttribute('name').catch(() => null);
|
|
36
|
+
if (emailName) {
|
|
37
|
+
return emailName;
|
|
38
|
+
}
|
|
39
|
+
const textInputs = page.locator('input[type="text"]');
|
|
40
|
+
const count = await textInputs.count();
|
|
41
|
+
for (let i = 0; i < count; i++) {
|
|
42
|
+
const name = await textInputs.nth(i).getAttribute('name');
|
|
43
|
+
if (name && /user|email|login/i.test(name)) {
|
|
44
|
+
return name;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function debugAuth() {
|
|
50
|
+
return process.env.QULIB_DEBUG === '1';
|
|
51
|
+
}
|
|
52
|
+
function slugify(label) {
|
|
53
|
+
const s = label
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/\s+/g, '-')
|
|
56
|
+
.replace(/[^a-z0-9-]+/g, '')
|
|
57
|
+
.replace(/^-+|-+$/g, '');
|
|
58
|
+
return s.length > 0 ? s : 'custom';
|
|
59
|
+
}
|
|
60
|
+
function escapeRegExp(s) {
|
|
61
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
62
|
+
}
|
|
63
|
+
async function resolveVisibleFieldLabel(page, el) {
|
|
64
|
+
const id = await el.getAttribute('id');
|
|
65
|
+
if (id) {
|
|
66
|
+
const lt = await page.locator(`label[for="${id.replace(/"/g, '\\"')}"]`).first().textContent().catch(() => null);
|
|
67
|
+
const fromLabel = (lt ?? '').trim();
|
|
68
|
+
if (fromLabel)
|
|
69
|
+
return fromLabel;
|
|
70
|
+
}
|
|
71
|
+
const placeholder = (await el.getAttribute('placeholder'))?.trim();
|
|
72
|
+
if (placeholder)
|
|
73
|
+
return placeholder;
|
|
74
|
+
const aria = (await el.getAttribute('aria-label'))?.trim();
|
|
75
|
+
if (aria)
|
|
76
|
+
return aria;
|
|
77
|
+
const name = (await el.getAttribute('name'))?.trim();
|
|
78
|
+
if (name)
|
|
79
|
+
return name;
|
|
80
|
+
const typ = (await el.getAttribute('type'))?.trim();
|
|
81
|
+
return typ && typ !== 'select' ? typ : 'text';
|
|
82
|
+
}
|
|
83
|
+
async function deriveCredentialFieldName(el) {
|
|
84
|
+
const name = (await el.getAttribute('name'))?.trim();
|
|
85
|
+
if (name)
|
|
86
|
+
return name;
|
|
87
|
+
const placeholder = (await el.getAttribute('placeholder'))?.trim();
|
|
88
|
+
if (placeholder)
|
|
89
|
+
return slugify(placeholder);
|
|
90
|
+
const aria = (await el.getAttribute('aria-label'))?.trim();
|
|
91
|
+
if (aria)
|
|
92
|
+
return slugify(aria);
|
|
93
|
+
const id = (await el.getAttribute('id'))?.trim();
|
|
94
|
+
if (id)
|
|
95
|
+
return slugify(id);
|
|
96
|
+
return 'field';
|
|
97
|
+
}
|
|
98
|
+
async function buildCredentialFieldsFromVisibleForm(page) {
|
|
99
|
+
const fields = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const loc = page.locator('input[type="text"]:visible, input[type="email"]:visible, input[type="password"]:visible, select:visible');
|
|
102
|
+
const count = await loc.count();
|
|
103
|
+
for (let i = 0; i < count; i++) {
|
|
104
|
+
const el = loc.nth(i);
|
|
105
|
+
const tag = await el.evaluate((node) => node.tagName.toLowerCase()).catch(() => '');
|
|
106
|
+
if (tag === 'select') {
|
|
107
|
+
const name = await deriveCredentialFieldName(el);
|
|
108
|
+
const label = await resolveVisibleFieldLabel(page, el);
|
|
109
|
+
const opts = await el.locator('option').allInnerTexts();
|
|
110
|
+
const observedOptions = opts.map((o) => o.trim()).filter((x) => x.length > 0).slice(0, 20);
|
|
111
|
+
const dedupeKey = `select|${name}|${label}`;
|
|
112
|
+
if (seen.has(dedupeKey))
|
|
113
|
+
continue;
|
|
114
|
+
seen.add(dedupeKey);
|
|
115
|
+
fields.push({ name, label, type: 'select', observedOptions });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const rawType = ((await el.getAttribute('type')) ?? 'text').toLowerCase();
|
|
119
|
+
if (rawType === 'hidden')
|
|
120
|
+
continue;
|
|
121
|
+
const fieldType = rawType === 'email' ? 'email' : rawType === 'password' ? 'password' : 'text';
|
|
122
|
+
const name = await deriveCredentialFieldName(el);
|
|
123
|
+
const label = await resolveVisibleFieldLabel(page, el);
|
|
124
|
+
const placeholder = (await el.getAttribute('placeholder'))?.trim() ?? '';
|
|
125
|
+
const dedupeKey = `${name}|${placeholder}`;
|
|
126
|
+
if (seen.has(dedupeKey))
|
|
127
|
+
continue;
|
|
128
|
+
seen.add(dedupeKey);
|
|
129
|
+
fields.push({ name, label, type: fieldType, observedOptions: [] });
|
|
130
|
+
}
|
|
131
|
+
return fields;
|
|
132
|
+
}
|
|
133
|
+
function authPathsFromOauthButtons(oauthButtons, loginUrl) {
|
|
134
|
+
return oauthButtons.map((b) => {
|
|
135
|
+
const isUnknown = b.provider === 'unknown';
|
|
136
|
+
const id = isUnknown ? slugify(b.text) : b.provider;
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
label: b.text,
|
|
140
|
+
type: isUnknown ? 'oauth-unknown' : 'oauth',
|
|
141
|
+
provider: isUnknown ? slugify(b.text) : b.provider,
|
|
142
|
+
source: isUnknown ? 'heuristic' : 'built-in',
|
|
143
|
+
automatable: false,
|
|
144
|
+
confidence: isUnknown ? 'low' : 'high',
|
|
145
|
+
requirements: {
|
|
146
|
+
method: 'storage-state',
|
|
147
|
+
instruction: `Run qulib auth init --base-url ${loginUrl}`,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function probeClickToRevealForms(page, loginUrl, alreadyMatchedTexts, timeoutMs, progress) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const buttons = page.locator('button');
|
|
155
|
+
const n = await buttons.count();
|
|
156
|
+
const seenLabels = new Set();
|
|
157
|
+
const SUBMIT_RE = /^(sign in|log in|submit|continue|next|cancel|close)$/i;
|
|
158
|
+
let candidateAttempts = 0;
|
|
159
|
+
for (let i = 0; i < n && candidateAttempts < 4; i++) {
|
|
160
|
+
const label = ((await buttons.nth(i).textContent()) ?? '').trim();
|
|
161
|
+
if (!label || label.length > 80)
|
|
162
|
+
continue;
|
|
163
|
+
if (alreadyMatchedTexts.has(label))
|
|
164
|
+
continue;
|
|
165
|
+
if (SUBMIT_RE.test(label))
|
|
166
|
+
continue;
|
|
167
|
+
if (seenLabels.has(label))
|
|
168
|
+
continue;
|
|
169
|
+
seenLabels.add(label);
|
|
170
|
+
candidateAttempts += 1;
|
|
171
|
+
const originBefore = new URL(page.url()).origin;
|
|
172
|
+
if (debugAuth()) {
|
|
173
|
+
progress?.debug(`detect_auth click-reveal try label="${label.slice(0, 80)}"`);
|
|
174
|
+
}
|
|
175
|
+
let clicked = false;
|
|
176
|
+
try {
|
|
177
|
+
await page.getByRole('button', { name: label, exact: true }).first().click({ timeout: 2000 });
|
|
178
|
+
clicked = true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
try {
|
|
182
|
+
await page
|
|
183
|
+
.locator('button')
|
|
184
|
+
.filter({ hasText: new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`, 'i') })
|
|
185
|
+
.first()
|
|
186
|
+
.click({ timeout: 2000 });
|
|
187
|
+
clicked = true;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* skip */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!clicked) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* best-effort after navigation */
|
|
201
|
+
}
|
|
202
|
+
if (new URL(page.url()).origin !== originBefore) {
|
|
203
|
+
if (debugAuth()) {
|
|
204
|
+
progress?.debug(`detect_auth click-reveal aborted (cross-origin after click): was ${originBefore} now ${new URL(page.url()).origin}`);
|
|
205
|
+
}
|
|
206
|
+
await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
207
|
+
await waitNetworkIdleBestEffort(page);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await page.locator('input[type="password"]:visible').first().waitFor({ state: 'visible', timeout: 2000 });
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
215
|
+
await waitNetworkIdleBestEffort(page);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const fields = await buildCredentialFieldsFromVisibleForm(page);
|
|
219
|
+
const slug = slugify(label);
|
|
220
|
+
out.push({
|
|
221
|
+
id: slug,
|
|
222
|
+
label,
|
|
223
|
+
type: 'form-login',
|
|
224
|
+
provider: slug,
|
|
225
|
+
source: 'heuristic',
|
|
226
|
+
automatable: true,
|
|
227
|
+
confidence: 'medium',
|
|
228
|
+
requirements: {
|
|
229
|
+
method: 'credentials',
|
|
230
|
+
fields,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
234
|
+
await waitNetworkIdleBestEffort(page);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
export function evaluateStorageStateValidity(signals) {
|
|
239
|
+
let finalOrigin = null;
|
|
240
|
+
try {
|
|
241
|
+
finalOrigin = new URL(signals.finalUrl).origin;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
finalOrigin = null;
|
|
245
|
+
}
|
|
246
|
+
if (finalOrigin === null || finalOrigin !== signals.expectedOrigin) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
reasonCode: 'wrong-origin',
|
|
250
|
+
reason: 'session redirected to a different origin than the target app',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (signals.visiblePasswordCount > 0) {
|
|
254
|
+
return {
|
|
255
|
+
valid: false,
|
|
256
|
+
reasonCode: 'expired-or-unauthorized',
|
|
257
|
+
reason: 'login form still visible after loading storage state (session likely expired)',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (signals.hadUnauthorizedHttp) {
|
|
261
|
+
return {
|
|
262
|
+
valid: false,
|
|
263
|
+
reasonCode: 'expired-or-unauthorized',
|
|
264
|
+
reason: 'HTTP 401/403 on authenticated request (session likely expired or invalid)',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return { valid: true, reasonCode: 'ok', reason: 'session appears active' };
|
|
268
|
+
}
|
|
269
|
+
export async function preflightStorageStateFile(storagePath) {
|
|
270
|
+
let exists = false;
|
|
271
|
+
try {
|
|
272
|
+
const s = await stat(storagePath);
|
|
273
|
+
exists = s.isFile();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
exists = false;
|
|
277
|
+
}
|
|
278
|
+
if (!exists) {
|
|
279
|
+
return {
|
|
280
|
+
valid: false,
|
|
281
|
+
reasonCode: 'missing-file',
|
|
282
|
+
reason: 'storage state file does not exist',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
let raw;
|
|
286
|
+
try {
|
|
287
|
+
raw = await readFile(storagePath, 'utf8');
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return {
|
|
291
|
+
valid: false,
|
|
292
|
+
reasonCode: 'unreadable-file',
|
|
293
|
+
reason: 'storage state file is not readable',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
let parsed;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(raw);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return {
|
|
302
|
+
valid: false,
|
|
303
|
+
reasonCode: 'invalid-json',
|
|
304
|
+
reason: 'storage state file is not valid JSON',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const cookieCount = Array.isArray(parsed?.cookies) ? parsed.cookies.length : 0;
|
|
308
|
+
const originEntries = Array.isArray(parsed?.origins) ? parsed.origins : [];
|
|
309
|
+
const localStorageEntryCount = originEntries.reduce((sum, entry) => {
|
|
310
|
+
return sum + (Array.isArray(entry?.localStorage) ? entry.localStorage.length : 0);
|
|
311
|
+
}, 0);
|
|
312
|
+
if (cookieCount === 0 && localStorageEntryCount === 0) {
|
|
313
|
+
return {
|
|
314
|
+
valid: false,
|
|
315
|
+
reasonCode: 'no-auth-cookies',
|
|
316
|
+
reason: 'storage state file contains no cookies and no localStorage entries',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
export async function waitForReturnToOrigin(page, baseUrl, timeoutMs = 15000) {
|
|
322
|
+
const targetOrigin = new URL(baseUrl).origin;
|
|
323
|
+
const deadline = Date.now() + timeoutMs;
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
const finalUrl = page.url();
|
|
326
|
+
try {
|
|
327
|
+
if (new URL(finalUrl).origin === targetOrigin) {
|
|
328
|
+
return { returned: true, finalUrl };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
/* ignore transient invalid URLs */
|
|
333
|
+
}
|
|
334
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
335
|
+
}
|
|
336
|
+
return { returned: false, finalUrl: page.url() };
|
|
337
|
+
}
|
|
338
|
+
export async function validateStorageState(url, storagePath, timeoutMs = 10000) {
|
|
339
|
+
let expectedOrigin;
|
|
340
|
+
try {
|
|
341
|
+
expectedOrigin = new URL(url).origin;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return {
|
|
345
|
+
valid: false,
|
|
346
|
+
reasonCode: 'unknown',
|
|
347
|
+
reason: 'target URL could not be parsed for origin matching',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const storageAbs = resolve(process.cwd(), storagePath);
|
|
351
|
+
const preflight = await preflightStorageStateFile(storageAbs);
|
|
352
|
+
if (preflight !== null) {
|
|
353
|
+
return preflight;
|
|
354
|
+
}
|
|
355
|
+
let hadUnauthorizedHttp = false;
|
|
356
|
+
const browser = await launchBrowser();
|
|
357
|
+
try {
|
|
358
|
+
const context = await browser.newContext({ storageState: storageAbs });
|
|
359
|
+
const page = await context.newPage();
|
|
360
|
+
page.on('response', (res) => {
|
|
361
|
+
const s = res.status();
|
|
362
|
+
if (s === 401 || s === 403) {
|
|
363
|
+
hadUnauthorizedHttp = true;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
try {
|
|
367
|
+
await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return {
|
|
371
|
+
valid: false,
|
|
372
|
+
reasonCode: 'unknown',
|
|
373
|
+
reason: 'navigation to target URL failed while validating storage state',
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
await waitNetworkIdleBestEffort(page);
|
|
377
|
+
const finalUrl = page.url();
|
|
378
|
+
const visiblePasswordCount = await page
|
|
379
|
+
.locator('input[type="password"]:visible')
|
|
380
|
+
.count()
|
|
381
|
+
.catch(() => 0);
|
|
382
|
+
return evaluateStorageStateValidity({
|
|
383
|
+
expectedOrigin,
|
|
384
|
+
finalUrl,
|
|
385
|
+
visiblePasswordCount,
|
|
386
|
+
hadUnauthorizedHttp,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
await browser.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export async function detectAuth(url, timeoutMs = 15000, progress) {
|
|
394
|
+
const browser = await launchBrowser();
|
|
395
|
+
try {
|
|
396
|
+
const context = await browser.newContext();
|
|
397
|
+
const page = await context.newPage();
|
|
398
|
+
progress?.info(`detect_auth URL=${url}`);
|
|
399
|
+
await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
400
|
+
await waitNetworkIdleBestEffort(page);
|
|
401
|
+
if (debugAuth()) {
|
|
402
|
+
const html = await page.content();
|
|
403
|
+
progress?.debug(`detect_auth HTML byteLength=${Buffer.byteLength(html, 'utf8')}`);
|
|
404
|
+
}
|
|
405
|
+
let loginUrl = url;
|
|
406
|
+
const looksLikeLoginPage = /login|sign[- ]?in|auth/i.test(page.url()) ||
|
|
407
|
+
(await page.locator('input[type="password"]:visible').count()) > 0;
|
|
408
|
+
if (!looksLikeLoginPage) {
|
|
409
|
+
const loginLink = page.locator('a').filter({ hasText: /^(log ?in|sign ?in|sign in)$/i }).first();
|
|
410
|
+
const loginLinkCount = await loginLink.count();
|
|
411
|
+
progress?.debug(`detect_auth selector loginLink count=${loginLinkCount}`);
|
|
412
|
+
if (loginLinkCount > 0) {
|
|
413
|
+
const href = await loginLink.getAttribute('href');
|
|
414
|
+
progress?.debug(`detect_auth selector loginLink href matched=${Boolean(href)}`);
|
|
415
|
+
if (href) {
|
|
416
|
+
loginUrl = new URL(href, url).toString();
|
|
417
|
+
await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
418
|
+
await waitNetworkIdleBestEffort(page);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const passwordInputs = page.locator('input[type="password"]:visible');
|
|
423
|
+
const passwordCount = await passwordInputs.count();
|
|
424
|
+
progress?.debug(`detect_auth selector input[type=password] count=${passwordCount}`);
|
|
425
|
+
const hasFormLogin = passwordCount > 0;
|
|
426
|
+
const oauthButtons = [];
|
|
427
|
+
const buttonTexts = await page.locator('button, a').allInnerTexts();
|
|
428
|
+
for (const text of buttonTexts) {
|
|
429
|
+
const trimmed = text.trim();
|
|
430
|
+
if (!textLooksLikeOAuthIdpButton(trimmed)) {
|
|
431
|
+
if (debugAuth() && trimmed.length > 0 && trimmed.length <= 120) {
|
|
432
|
+
progress?.debug(`detect_auth oauth text skipped (not Idp-shaped) sample="${trimmed.slice(0, 80)}"`);
|
|
433
|
+
}
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
let matchedAny = false;
|
|
437
|
+
for (const { id, patterns } of BUILT_IN_OAUTH_PROVIDERS) {
|
|
438
|
+
const matched = patterns.some((p) => p.test(trimmed));
|
|
439
|
+
if (debugAuth()) {
|
|
440
|
+
progress?.debug(`detect_auth oauth pattern try provider=${id} matched=${matched}`);
|
|
441
|
+
}
|
|
442
|
+
if (matched) {
|
|
443
|
+
if (!oauthButtons.find((b) => b.provider === id)) {
|
|
444
|
+
oauthButtons.push({ provider: id, text: trimmed.slice(0, 100) });
|
|
445
|
+
}
|
|
446
|
+
matchedAny = true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!matchedAny) {
|
|
450
|
+
const builtIn = BUILT_IN_OAUTH_PROVIDERS.find((p) => p.label.toLowerCase() === trimmed.toLowerCase());
|
|
451
|
+
if (builtIn) {
|
|
452
|
+
if (!oauthButtons.find((b) => b.provider === builtIn.id)) {
|
|
453
|
+
oauthButtons.push({ provider: builtIn.id, text: trimmed.slice(0, 100) });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else if (!oauthButtons.find((b) => b.text === trimmed.slice(0, 100))) {
|
|
457
|
+
oauthButtons.push({ provider: 'unknown', text: trimmed.slice(0, 100) });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const skipProbeLabels = new Set(oauthButtons.map((b) => b.text.trim()));
|
|
462
|
+
const clickRevealForms = await probeClickToRevealForms(page, loginUrl, skipProbeLabels, timeoutMs, progress);
|
|
463
|
+
const pageText = await page.locator('body').innerText().catch(() => '');
|
|
464
|
+
const hasMagicLink = MAGIC_LINK_PATTERNS.some((p) => p.test(pageText));
|
|
465
|
+
let type = 'none';
|
|
466
|
+
let provider = null;
|
|
467
|
+
let observedSelectors = null;
|
|
468
|
+
let recommendation = '';
|
|
469
|
+
if (oauthButtons.length > 0) {
|
|
470
|
+
type = 'oauth';
|
|
471
|
+
provider = oauthButtons[0].provider;
|
|
472
|
+
recommendation = `OAuth detected (${oauthButtons.map((b) => b.provider).join(', ')}). OAuth cannot be automated. Run "qulib auth init --base-url ${loginUrl}" to log in manually once and save a reusable storage state file.`;
|
|
473
|
+
}
|
|
474
|
+
else if (hasFormLogin) {
|
|
475
|
+
type = 'form-login';
|
|
476
|
+
const usernameName = await firstTextInputNameForLogin(page);
|
|
477
|
+
const passwordName = await passwordInputs.first().getAttribute('name').catch(() => null);
|
|
478
|
+
const submitName = await page
|
|
479
|
+
.locator('button[type="submit"], input[type="submit"]')
|
|
480
|
+
.first()
|
|
481
|
+
.getAttribute('name')
|
|
482
|
+
.catch(() => null);
|
|
483
|
+
observedSelectors = {
|
|
484
|
+
usernameSelector: usernameName ? `input[name="${usernameName}"]` : null,
|
|
485
|
+
passwordSelector: passwordName ? `input[name="${passwordName}"]` : null,
|
|
486
|
+
submitSelector: submitName ? `button[name="${submitName}"]` : 'button[type="submit"]',
|
|
487
|
+
};
|
|
488
|
+
if (debugAuth()) {
|
|
489
|
+
progress?.debug(`detect_auth resolved selectors username=${observedSelectors.usernameSelector ?? 'null'} password=${observedSelectors.passwordSelector ?? 'null'} submit=${observedSelectors.submitSelector}`);
|
|
490
|
+
}
|
|
491
|
+
recommendation = `Form login detected. Configure auth with type="form-login", credentials, and the selectors above. Test selectors in your browser dev tools to confirm.`;
|
|
492
|
+
}
|
|
493
|
+
else if (hasMagicLink) {
|
|
494
|
+
type = 'magic-link';
|
|
495
|
+
recommendation = `Magic link / passwordless auth detected. Qulib cannot complete email-link flows. Run "qulib auth init --base-url ${loginUrl}" to log in manually once and save a storage state file.`;
|
|
496
|
+
}
|
|
497
|
+
else if (looksLikeLoginPage) {
|
|
498
|
+
type = 'unknown';
|
|
499
|
+
recommendation = `Authentication required but the pattern is unrecognized. Use "qulib auth init --base-url ${loginUrl}" to capture a storage state by logging in manually.`;
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
type = 'none';
|
|
503
|
+
recommendation = `No authentication required for the entry URL. Qulib can scan anonymously.`;
|
|
504
|
+
}
|
|
505
|
+
if (clickRevealForms.length > 0) {
|
|
506
|
+
recommendation += `\nAutomatable form login detected via: ${clickRevealForms.map((f) => f.label).join(', ')}. Use type="form-login" with the observed selectors in authOptions.`;
|
|
507
|
+
}
|
|
508
|
+
const providerList = oauthButtons.length > 0 ? oauthButtons.map((b) => b.provider).join(', ') : provider ?? 'none';
|
|
509
|
+
const automatable = type === 'form-login' || clickRevealForms.length > 0;
|
|
510
|
+
progress?.info(`Auth detected: ${type} (${providerList}) automatable=${automatable}`);
|
|
511
|
+
const authOptions = [...authPathsFromOauthButtons(oauthButtons, loginUrl), ...clickRevealForms];
|
|
512
|
+
return {
|
|
513
|
+
hasAuth: type !== 'none' || oauthButtons.length > 0 || clickRevealForms.length > 0,
|
|
514
|
+
type,
|
|
515
|
+
provider,
|
|
516
|
+
loginUrl: type === 'none' && oauthButtons.length === 0 && clickRevealForms.length === 0 ? null : loginUrl,
|
|
517
|
+
observedSelectors,
|
|
518
|
+
oauthButtons,
|
|
519
|
+
...(authOptions.length > 0 ? { authOptions } : {}),
|
|
520
|
+
recommendation,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
await browser.close();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import type { DetectedAuth } from '../../schemas/config.schema.js';
|
|
3
|
+
import type { AnalyzeProgressSink } from '../../harness/progress-log.js';
|
|
4
|
+
export type StorageStateInvalidReason = 'missing-file' | 'unreadable-file' | 'invalid-json' | 'wrong-origin' | 'expired-or-unauthorized' | 'no-auth-cookies' | 'unknown';
|
|
5
|
+
export interface StorageStateValidationResult {
|
|
6
|
+
valid: boolean;
|
|
7
|
+
reasonCode: StorageStateInvalidReason | 'ok';
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function evaluateStorageStateValidity(signals: {
|
|
11
|
+
expectedOrigin: string;
|
|
12
|
+
finalUrl: string;
|
|
13
|
+
visiblePasswordCount: number;
|
|
14
|
+
hadUnauthorizedHttp: boolean;
|
|
15
|
+
}): StorageStateValidationResult;
|
|
16
|
+
export declare function preflightStorageStateFile(storagePath: string): Promise<StorageStateValidationResult | null>;
|
|
17
|
+
export declare function waitForReturnToOrigin(page: Page, baseUrl: string, timeoutMs?: number): Promise<{
|
|
18
|
+
returned: boolean;
|
|
19
|
+
finalUrl: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function validateStorageState(url: string, storagePath: string, timeoutMs?: number): Promise<StorageStateValidationResult>;
|
|
22
|
+
export declare function detectAuth(url: string, timeoutMs?: number, progress?: AnalyzeProgressSink): Promise<DetectedAuth>;
|
|
23
|
+
//# sourceMappingURL=detector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../../src/tools/auth/detector.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,EAAY,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAIzE,MAAM,MAAM,yBAAyB,GACjC,cAAc,GACd,iBAAiB,GACjB,cAAc,GACd,cAAc,GACd,yBAAyB,GACzB,iBAAiB,GACjB,SAAS,CAAC;AAEd,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,yBAAyB,GAAG,IAAI,CAAC;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB;AAsQD,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,mBAAmB,EAAE,OAAO,CAAC;CAC9B,GAAG,4BAA4B,CA6B/B;AAOD,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,4BAA4B,GAAG,IAAI,CAAC,CAgD9C;AAED,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,MAAM,EACf,SAAS,SAAQ,GAChB,OAAO,CAAC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAelD;AAED,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,SAAS,SAAQ,GAChB,OAAO,CAAC,4BAA4B,CAAC,CAyDvC;AAED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,SAAQ,EACjB,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAqJvB"}
|