@swimmingliu/autovpn 2.1.2 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifacts/list.js +37 -7
- package/dist/backend/node-backend.js +90 -6
- package/dist/cli/accounts-commands.js +2 -1
- package/dist/config/profile.js +54 -4
- package/dist/pipeline/brand-cache.js +22 -0
- package/dist/pipeline/credential-source.js +403 -78
- package/dist/pipeline/extract.js +2 -1
- package/dist/pipeline/orchestrator.js +10 -2
- package/dist/server/http.js +10 -0
- package/dist/server/runtime.js +327 -33
- package/dist/server/web-adapter.js +4 -0
- package/dist/web/renderer/analytics.bundle.js +9 -9
- package/dist/web/renderer/app.js +72 -2
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +1 -0
- package/dist/web/renderer/styles.css +2 -2
- package/dist/web/renderer/views.js +154 -28
- package/dist/web/renderer/workspace.css +50 -9
- package/package.json +1 -1
|
@@ -9,6 +9,23 @@ import { BRANDS, brandFromSite, brandSite, normalizeBrand } from '../pool/brands
|
|
|
9
9
|
import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
|
|
10
10
|
import { resolveUserRuntimeRoot } from '../runtime/paths.js';
|
|
11
11
|
import { redactText } from '../runtime/redaction.js';
|
|
12
|
+
/**
|
|
13
|
+
* How many pooled accounts one source may try before it reports failure.
|
|
14
|
+
*
|
|
15
|
+
* A redirected row leases from a foreign vendor's list, and only a minority of
|
|
16
|
+
* those accounts exist on the backend: measured against the deployed pool, 2 of
|
|
17
|
+
* 40 黑洞 accounts log in on the 雷霆 backend. Each rejected attempt retires that
|
|
18
|
+
* account and leases the next by least-recent use, so the width decides whether
|
|
19
|
+
* the run ever reaches a usable credential. A row without a redirect keeps the
|
|
20
|
+
* tighter default.
|
|
21
|
+
*/
|
|
22
|
+
export function credentialAccountAttempts(source, brand) {
|
|
23
|
+
const backend = normalizeBrand(String(source.backend_brand ?? '').trim());
|
|
24
|
+
const redirected = Boolean(backend && brand && backend !== brand);
|
|
25
|
+
const fallback = redirected ? 24 : 3;
|
|
26
|
+
const parsed = Math.trunc(Number(source.account_retry_limit ?? fallback));
|
|
27
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
|
|
28
|
+
}
|
|
12
29
|
/**
|
|
13
30
|
* Each brand concatenates `getCommParams()` in its own order with its own
|
|
14
31
|
* literal block. The layout can be overridden from the profile, and is picked
|
|
@@ -28,7 +45,9 @@ export function resolveBrandLayout(source) {
|
|
|
28
45
|
return { afterPackage: explicit.startsWith('&') ? explicit : `&${explicit}` };
|
|
29
46
|
}
|
|
30
47
|
}
|
|
31
|
-
|
|
48
|
+
// A redirected data plane speaks the backend's parameter order, not the
|
|
49
|
+
// storefront's: the backend decides which flag block it recognises.
|
|
50
|
+
const brand = String(source.brand_override ?? source.brand ?? '').trim();
|
|
32
51
|
if (brand.includes('旋风') || /xuanfeng/i.test(brand)) {
|
|
33
52
|
return { vipFirst: true, viewport: true };
|
|
34
53
|
}
|
|
@@ -47,7 +66,7 @@ export function resolveBrandLayout(source) {
|
|
|
47
66
|
* downloader uses a Chinese product name, so both are mapped. The official
|
|
48
67
|
* host is the last resort when a profile uses a custom source name.
|
|
49
68
|
*/
|
|
50
|
-
const BRAND_APK_PREFIXES = {
|
|
69
|
+
export const BRAND_APK_PREFIXES = {
|
|
51
70
|
雷霆: 'lt',
|
|
52
71
|
旋风: 'xf',
|
|
53
72
|
黑洞: 'hd',
|
|
@@ -70,6 +89,22 @@ function siteHost(site) {
|
|
|
70
89
|
return '';
|
|
71
90
|
}
|
|
72
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Archive prefix for any spelling of a brand: its English key, its Chinese
|
|
94
|
+
* label, a known storefront host, or a name that normalizes to one of them.
|
|
95
|
+
*/
|
|
96
|
+
function credentialPrefixFor(value) {
|
|
97
|
+
const raw = String(value ?? '').trim();
|
|
98
|
+
if (!raw) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const direct = BRAND_APK_PREFIXES[raw] ?? BRAND_APK_PREFIXES[raw.toLowerCase()];
|
|
102
|
+
if (direct) {
|
|
103
|
+
return direct;
|
|
104
|
+
}
|
|
105
|
+
const brand = normalizeBrand(raw);
|
|
106
|
+
return brand ? (BRAND_APK_PREFIXES[brand] ?? null) : null;
|
|
107
|
+
}
|
|
73
108
|
/**
|
|
74
109
|
* Stable key for a brand's fingerprint.
|
|
75
110
|
*
|
|
@@ -81,14 +116,82 @@ function siteHost(site) {
|
|
|
81
116
|
* be established, which keeps custom entries working.
|
|
82
117
|
*/
|
|
83
118
|
export function credentialBrandKey(sourceName, source) {
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
// Brand first, host second. A row's own name identifies the APK even when the
|
|
120
|
+
// operator points it at an alternate storefront (`www.leitingku.com`,
|
|
121
|
+
// `www.heidongm.com`, …), and hosting the file under that host split one
|
|
122
|
+
// brand across several cache entries — so a redirected row keyed by prefix
|
|
123
|
+
// never saw the entry the native row had just written.
|
|
124
|
+
const name = String(sourceName ?? '').trim();
|
|
125
|
+
const byName = credentialPrefixFor(name);
|
|
126
|
+
if (byName) {
|
|
127
|
+
return byName;
|
|
128
|
+
}
|
|
129
|
+
const host = siteHost(String(source.official_site ?? '').trim());
|
|
130
|
+
const byHost = credentialPrefixFor(host);
|
|
131
|
+
if (byHost) {
|
|
132
|
+
return byHost;
|
|
133
|
+
}
|
|
134
|
+
const lowered = name.toLowerCase();
|
|
135
|
+
return host || lowered || 'unknown';
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Fingerprint key for the data plane a row actually reads.
|
|
139
|
+
*
|
|
140
|
+
* A redirected row carries the backend's canonical brand key (`leiting`), not
|
|
141
|
+
* the short archive prefix its cache file is named with (`lt`). Using the
|
|
142
|
+
* canonical key straight away produced `brand-leiting.json`, which never
|
|
143
|
+
* exists: every run missed the cache, fell back to downloading the APK from the
|
|
144
|
+
* storefront, and on a host that cannot reach that storefront failed with
|
|
145
|
+
* "APK could not be located" — while `brand-lt.json` sat on disk, unused.
|
|
146
|
+
*/
|
|
147
|
+
export function fingerprintKeyFor(sourceName, source) {
|
|
148
|
+
if (!String(source.brand_override ?? '').trim()) {
|
|
149
|
+
return credentialBrandKey(sourceName, source);
|
|
150
|
+
}
|
|
151
|
+
return credentialPrefixFor(String(source.brand_override))
|
|
152
|
+
?? String(source.brand_override).trim().toLowerCase();
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* What to do with a pooled account after its login was refused.
|
|
156
|
+
*
|
|
157
|
+
* Retirement and rotation are separate decisions. Only a *classified* rejection
|
|
158
|
+
* — the vendor said the password or the account is wrong, or that the
|
|
159
|
+
* membership lapsed — is evidence the row is dead, so only that retires it.
|
|
160
|
+
* Any rejection at all, though, means this account cannot be used right now and
|
|
161
|
+
* the pool holds more.
|
|
162
|
+
*
|
|
163
|
+
* Conflating the two ended the source at zero rounds: 雷霆 refuses most of this
|
|
164
|
+
* pool with a bare `code=128` and no message, which matches neither classifier,
|
|
165
|
+
* so the loop gave up on the first account while 159 active rows went unread.
|
|
166
|
+
*/
|
|
167
|
+
export function accountFollowUp(state) {
|
|
168
|
+
if (state.membershipExpired) {
|
|
169
|
+
return { act: true, retire: 'expired', rotate: true };
|
|
170
|
+
}
|
|
171
|
+
if (state.credentialRejected) {
|
|
172
|
+
return { act: true, retire: 'invalid', rotate: true };
|
|
173
|
+
}
|
|
174
|
+
if (state.loginRejected) {
|
|
175
|
+
// Usable by nobody right now, but nothing proves it is dead: keep the row.
|
|
176
|
+
return { act: true, retire: null, rotate: true };
|
|
177
|
+
}
|
|
178
|
+
return { act: false, retire: null, rotate: false };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Cache files an earlier build may have written for this row.
|
|
182
|
+
*
|
|
183
|
+
* Before brand-first keying, the key was the official-site host, so a row
|
|
184
|
+
* pointed at an alternate storefront wrote `brand-www.leitingku.com.json`
|
|
185
|
+
* instead of `brand-lt.json`. Those files hold a valid fingerprint — often the
|
|
186
|
+
* only verified one on a host that cannot reach the vendor's site — so a read
|
|
187
|
+
* falls back to them and migrates the entry to the canonical key.
|
|
188
|
+
*/
|
|
189
|
+
function legacyFingerprintKey(sourceName, source) {
|
|
190
|
+
const host = siteHost(String(source.official_site ?? '').trim());
|
|
86
191
|
if (host) {
|
|
87
|
-
return
|
|
192
|
+
return host;
|
|
88
193
|
}
|
|
89
|
-
|
|
90
|
-
const prefix = BRAND_APK_PREFIXES[sourceName] ?? BRAND_APK_PREFIXES[name];
|
|
91
|
-
return prefix ?? (name || 'unknown');
|
|
194
|
+
return String(sourceName ?? '').trim().toLowerCase() || 'unknown';
|
|
92
195
|
}
|
|
93
196
|
export function createFingerprintMemo(store, brand) {
|
|
94
197
|
let value = store?.read(brand) ?? null;
|
|
@@ -176,6 +279,59 @@ export const MAX_EMPTY_ROUNDS = 5;
|
|
|
176
279
|
* node is stopped explicitly and reported.
|
|
177
280
|
*/
|
|
178
281
|
export const PLACEHOLDER_ONLY_ROUNDS = 8;
|
|
282
|
+
/**
|
|
283
|
+
* Lap-mode defaults. A lap is one full pass over the frozen area list, so
|
|
284
|
+
* these numbers mean what they say: 50 laps × N areas is the request ceiling,
|
|
285
|
+
* 13 empty laps × N areas is the give-up point. The legacy round defaults
|
|
286
|
+
* above stay untouched for rows that never set a lap key.
|
|
287
|
+
*/
|
|
288
|
+
export const DEFAULT_MAX_LAPS = 50;
|
|
289
|
+
export const DEFAULT_DRY_LAPS = 13;
|
|
290
|
+
function finiteLapValue(value, min) {
|
|
291
|
+
const parsed = Number(value);
|
|
292
|
+
return Number.isFinite(parsed) && parsed >= min;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Resolve the lap policy, or null when the row never opted in.
|
|
296
|
+
*
|
|
297
|
+
* Null is the compatibility contract: the caller then runs the legacy round
|
|
298
|
+
* path with the same arguments as before, so an untouched profile produces
|
|
299
|
+
* the same request sequence after an upgrade.
|
|
300
|
+
*/
|
|
301
|
+
export function resolveLapRoundPolicy(source, areaCount) {
|
|
302
|
+
const optedIn = finiteLapValue(source.max_laps, 1) || finiteLapValue(source.dry_laps, 1) || finiteLapValue(source.min_laps, 0);
|
|
303
|
+
if (!optedIn) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const areas = Math.max(1, Math.trunc(numberOrDefault(areaCount, 1)));
|
|
307
|
+
const cap = Math.max(1, Math.trunc(numberOrDefault(source.max_laps, DEFAULT_MAX_LAPS)));
|
|
308
|
+
const plateau = Math.max(1, Math.trunc(numberOrDefault(source.dry_laps, DEFAULT_DRY_LAPS)));
|
|
309
|
+
const floor = Math.min(Math.max(0, Math.trunc(numberOrDefault(source.min_laps, 0))), cap);
|
|
310
|
+
return {
|
|
311
|
+
policy: {
|
|
312
|
+
max_iterations: cap,
|
|
313
|
+
min_iterations: floor,
|
|
314
|
+
plateau_limit: plateau,
|
|
315
|
+
max_runtime_seconds: Math.max(0, numberOrDefault(source.max_runtime_seconds, 0))
|
|
316
|
+
},
|
|
317
|
+
areas_per_round: areas,
|
|
318
|
+
// The guards were tuned in rounds of 8 areas; keep their request volume
|
|
319
|
+
// when the unit becomes a lap of N areas, never below two laps.
|
|
320
|
+
empty_round_limit: Math.max(2, Math.ceil((MAX_EMPTY_ROUNDS * DEFAULT_AREAS_PER_ROUND) / areas)),
|
|
321
|
+
placeholder_round_limit: Math.max(2, Math.ceil((PLACEHOLDER_ONLY_ROUNDS * DEFAULT_AREAS_PER_ROUND) / areas)),
|
|
322
|
+
round_unit: 'lap'
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The budget to advertise before the area list is known. Pre-flight events
|
|
327
|
+
* fire before login, so they must report the unit the loop will run in.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveAdvertisedMaxIterations(source) {
|
|
330
|
+
if (finiteLapValue(source.max_laps, 1) || finiteLapValue(source.dry_laps, 1) || finiteLapValue(source.min_laps, 0)) {
|
|
331
|
+
return Math.max(1, Math.trunc(numberOrDefault(source.max_laps, DEFAULT_MAX_LAPS)));
|
|
332
|
+
}
|
|
333
|
+
return resolveRoundPolicy(source).max_iterations;
|
|
334
|
+
}
|
|
179
335
|
/** How many areas one round takes from the immutable list. */
|
|
180
336
|
export function resolveAreasPerRound(value, areaCount) {
|
|
181
337
|
const parsed = Math.trunc(Number(value));
|
|
@@ -490,6 +646,16 @@ function declaredSizeTooLarge(response) {
|
|
|
490
646
|
const length = Number(raw);
|
|
491
647
|
return Number.isFinite(length) && length > MAX_APK_BYTES;
|
|
492
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* Test seam: `defaultLoadApk` with the brand name spelled out.
|
|
651
|
+
*
|
|
652
|
+
* The archive prefix is chosen from this name, and a redirected row must pass
|
|
653
|
+
* its backend's name rather than the row's. Exported so a test can prove which
|
|
654
|
+
* name reaches the loader without standing up a whole extraction.
|
|
655
|
+
*/
|
|
656
|
+
export async function loadApkWithBrand(source, fetchImpl, timeoutMs, now, log, brandName, egress) {
|
|
657
|
+
return defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName, egress);
|
|
658
|
+
}
|
|
493
659
|
async function defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName = '', egress) {
|
|
494
660
|
if (source.apk_path) {
|
|
495
661
|
try {
|
|
@@ -805,9 +971,14 @@ export async function fetchCredentialSourceLinks(input, options = {}) {
|
|
|
805
971
|
}
|
|
806
972
|
}
|
|
807
973
|
async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
808
|
-
|
|
974
|
+
// A copy: a redirected data plane rewrites the site and the layout marker,
|
|
975
|
+
// and the caller's config object must not carry that into later stages.
|
|
976
|
+
const source = { ...input.source };
|
|
809
977
|
const platforms = resolveCredentialPlatforms(source);
|
|
810
|
-
|
|
978
|
+
// Pre-flight events fire before the area list exists, so the budget is
|
|
979
|
+
// advertised in the unit the loop will run in: laps or legacy rounds.
|
|
980
|
+
const advertisedIterations = resolveAdvertisedMaxIterations(source);
|
|
981
|
+
const legacyPolicy = resolveRoundPolicy(source);
|
|
811
982
|
const errors = [];
|
|
812
983
|
const diagnostics = {
|
|
813
984
|
brand: input.source_name,
|
|
@@ -827,7 +998,7 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
827
998
|
};
|
|
828
999
|
const empty = {
|
|
829
1000
|
source_name: input.source_name,
|
|
830
|
-
requested_iterations:
|
|
1001
|
+
requested_iterations: advertisedIterations,
|
|
831
1002
|
successful_iterations: 0,
|
|
832
1003
|
failed_iterations: 0,
|
|
833
1004
|
links: [],
|
|
@@ -835,8 +1006,8 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
835
1006
|
};
|
|
836
1007
|
event(options, 'extract_source_started', {
|
|
837
1008
|
source_name: input.source_name,
|
|
838
|
-
requested_iterations:
|
|
839
|
-
min_iterations:
|
|
1009
|
+
requested_iterations: advertisedIterations,
|
|
1010
|
+
min_iterations: legacyPolicy.min_iterations,
|
|
840
1011
|
resume_from_iteration: 1,
|
|
841
1012
|
platforms
|
|
842
1013
|
});
|
|
@@ -845,6 +1016,29 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
845
1016
|
const brand = normalizeBrand(input.source_name)
|
|
846
1017
|
?? brandFromSite(source.official_site)
|
|
847
1018
|
?? normalizeBrand(credentialBrandKey(input.source_name, source));
|
|
1019
|
+
// The account is always leased as the row's own brand; only the data plane
|
|
1020
|
+
// can be redirected. See `backend_brand` for why 黑洞 needs this.
|
|
1021
|
+
const backendBrand = normalizeBrand(String(source.backend_brand ?? '').trim()) ?? brand;
|
|
1022
|
+
if (backendBrand && brand && backendBrand !== brand) {
|
|
1023
|
+
const redirected = brandSite(backendBrand);
|
|
1024
|
+
if (redirected) {
|
|
1025
|
+
// A pinned `apk_path` / `apk_url` is the operator's explicit choice of
|
|
1026
|
+
// build and stays as given: it may already be the backend's own APK, and
|
|
1027
|
+
// discarding it turned a working pinned profile into "APK could not be
|
|
1028
|
+
// located". Only the site moves.
|
|
1029
|
+
if (!source.apk_path && !source.apk_url) {
|
|
1030
|
+
source.official_site = redirected;
|
|
1031
|
+
}
|
|
1032
|
+
// The layout is picked from `brand` at every call site; carrying the row
|
|
1033
|
+
// name through would keep sending the storefront's own flag block.
|
|
1034
|
+
source.brand_override = backendBrand;
|
|
1035
|
+
}
|
|
1036
|
+
// Informational, not an error: the row is expected to work this way.
|
|
1037
|
+
diagnostics.backend_brand = backendBrand;
|
|
1038
|
+
event(options, 'log', {
|
|
1039
|
+
message: `[extract][${input.source_name}] data plane ${brand} -> ${backendBrand}`
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
848
1042
|
let store = options.accountStore ?? null;
|
|
849
1043
|
let ownsStore = false;
|
|
850
1044
|
let leased = null;
|
|
@@ -872,20 +1066,32 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
872
1066
|
store?.close();
|
|
873
1067
|
event(options, 'extract_source_completed', {
|
|
874
1068
|
source_name: input.source_name,
|
|
875
|
-
requested_iterations:
|
|
1069
|
+
requested_iterations: advertisedIterations,
|
|
876
1070
|
successful_iterations: 0,
|
|
877
1071
|
failed_iterations: 1,
|
|
878
1072
|
raw_links: 0
|
|
879
1073
|
});
|
|
880
1074
|
return { ...empty, failed_iterations: 1 };
|
|
881
1075
|
}
|
|
882
|
-
|
|
1076
|
+
// Keyed by the data plane, not the storefront: a redirected row downloads and
|
|
1077
|
+
// inspects the backend's APK, so it must share that brand's cached entry.
|
|
1078
|
+
//
|
|
1079
|
+
// A caller-supplied memo cannot be trusted here. The orchestrator builds one
|
|
1080
|
+
// per row name before the redirect exists, so `options.memo` for a `heidong`
|
|
1081
|
+
// row is seeded from 黑洞's entry while every read and write below goes to the
|
|
1082
|
+
// 雷霆 one. That pairing sends the 黑洞 APK's package and seeds to the 雷霆
|
|
1083
|
+
// backend on a cached run, and it hides the 雷霆 entry the next run should
|
|
1084
|
+
// reuse. A redirect therefore always gets a memo of its own key; the shared
|
|
1085
|
+
// one is used only when the two brands agree.
|
|
1086
|
+
const memoBrandKey = fingerprintKeyFor(input.source_name, source);
|
|
1087
|
+
const memo = source.brand_override
|
|
1088
|
+
? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey)
|
|
1089
|
+
: (options.memo ?? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey));
|
|
883
1090
|
let linkQueue = Promise.resolve();
|
|
884
1091
|
// A non-numeric retry limit (a hand-edited `account_retry_limit = "three"`)
|
|
885
1092
|
// must not turn the attempt loop into zero passes, which reported the source
|
|
886
1093
|
// as successful with no links and no error.
|
|
887
|
-
const
|
|
888
|
-
const attempts = explicitAccount && explicitPassword ? 1 : (Number.isFinite(retryLimit) && retryLimit >= 1 ? retryLimit : 3);
|
|
1094
|
+
const attempts = explicitAccount && explicitPassword ? 1 : credentialAccountAttempts(source, brand);
|
|
889
1095
|
let results = [];
|
|
890
1096
|
let callbackFailure = null;
|
|
891
1097
|
// The dashboard reads per-source totals from one `extract_iteration` stream.
|
|
@@ -915,8 +1121,8 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
915
1121
|
event(options, 'extract_platform_started', {
|
|
916
1122
|
...payload,
|
|
917
1123
|
platform,
|
|
918
|
-
requested_iterations:
|
|
919
|
-
min_iterations:
|
|
1124
|
+
requested_iterations: advertisedIterations,
|
|
1125
|
+
min_iterations: legacyPolicy.min_iterations
|
|
920
1126
|
});
|
|
921
1127
|
return;
|
|
922
1128
|
}
|
|
@@ -942,7 +1148,7 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
942
1148
|
// counter.
|
|
943
1149
|
platform,
|
|
944
1150
|
iteration: Number(payload.iteration ?? 0),
|
|
945
|
-
requested_iterations:
|
|
1151
|
+
requested_iterations: advertisedIterations,
|
|
946
1152
|
new_items: Number(payload.new_items ?? 0),
|
|
947
1153
|
new_meaningful_items: Number(payload.new_meaningful_items ?? 0),
|
|
948
1154
|
extracted_links: Number(payload.extracted_links ?? 0),
|
|
@@ -1006,29 +1212,54 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
1006
1212
|
if (lapsed) {
|
|
1007
1213
|
store.markExpired(leased.id, 'membership expired');
|
|
1008
1214
|
}
|
|
1009
|
-
else if (level !== undefined && level < 2 && !linked) {
|
|
1215
|
+
else if (level !== undefined && level < 2 && !linked && !source.brand_override) {
|
|
1010
1216
|
// A free-tier account (level 0/1) only ever produces placeholders.
|
|
1011
1217
|
// Retire it, but only when it actually produced nothing: a paid
|
|
1012
1218
|
// account misreported as level 1 must not lose its row.
|
|
1219
|
+
//
|
|
1220
|
+
// A redirected row is exempt: the level it reports is the *row*
|
|
1221
|
+
// brand's backend grading it, not the backend it now reads, so a
|
|
1222
|
+
// low level there says nothing about whether the account works.
|
|
1223
|
+
// Retiring on it would discard exactly the accounts the rotation
|
|
1224
|
+
// below is trying to find.
|
|
1013
1225
|
store.markInvalid(leased.id, 'no paid membership');
|
|
1014
1226
|
}
|
|
1015
1227
|
}
|
|
1228
|
+
// A login is not proof the account is usable. On a backend that gates
|
|
1229
|
+
// routes by membership, a free-tier account logs in fine and then sees
|
|
1230
|
+
// every paid row disabled, so the vendor filter leaves nothing to ask.
|
|
1231
|
+
// Publishing that as a finished source was the bug; rotate instead.
|
|
1232
|
+
// The account itself is valid, so it is not retired — only skipped.
|
|
1233
|
+
const producedRoutes = results.some((result) => result.diagnostics.route_count > 0);
|
|
1234
|
+
const producedLinks = results.some((result) => result.links.length > 0);
|
|
1235
|
+
if (!producedRoutes && !producedLinks && store && leased && attempt < attempts && brand) {
|
|
1236
|
+
const next = store.lease(brand);
|
|
1237
|
+
if (next) {
|
|
1238
|
+
leased = next;
|
|
1239
|
+
account = next.account;
|
|
1240
|
+
password = next.password;
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1016
1244
|
break;
|
|
1017
1245
|
}
|
|
1018
1246
|
const membershipExpired = results.length > 0
|
|
1019
1247
|
&& results.every((result) => result.diagnostics.login_rejected)
|
|
1020
1248
|
&& results.some((result) => result.diagnostics.membership_expired);
|
|
1021
1249
|
const credentialRejected = results.length > 0 && results.every((result) => result.diagnostics.credential_rejected);
|
|
1022
|
-
|
|
1023
|
-
|
|
1250
|
+
const loginRejected = results.length > 0 && results.every((result) => result.diagnostics.login_rejected);
|
|
1251
|
+
const followUp = accountFollowUp({ membershipExpired, credentialRejected, loginRejected });
|
|
1252
|
+
if (store && leased && followUp.act) {
|
|
1253
|
+
if (followUp.retire === 'expired')
|
|
1024
1254
|
store.markExpired(leased.id, 'membership expired');
|
|
1025
|
-
else
|
|
1255
|
+
else if (followUp.retire === 'invalid')
|
|
1026
1256
|
store.markInvalid(leased.id, 'login rejected');
|
|
1027
|
-
if (attempt < attempts && brand) {
|
|
1028
|
-
|
|
1029
|
-
if (
|
|
1030
|
-
|
|
1031
|
-
|
|
1257
|
+
if (followUp.rotate && attempt < attempts && brand) {
|
|
1258
|
+
const next = store.lease(brand);
|
|
1259
|
+
if (next) {
|
|
1260
|
+
leased = next;
|
|
1261
|
+
account = next.account;
|
|
1262
|
+
password = next.password;
|
|
1032
1263
|
continue;
|
|
1033
1264
|
}
|
|
1034
1265
|
}
|
|
@@ -1109,15 +1340,19 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
1109
1340
|
}
|
|
1110
1341
|
event(options, 'extract_source_completed', {
|
|
1111
1342
|
source_name: input.source_name,
|
|
1112
|
-
requested_iterations:
|
|
1343
|
+
requested_iterations: advertisedIterations,
|
|
1113
1344
|
successful_iterations: successful,
|
|
1114
1345
|
failed_iterations: failed,
|
|
1115
1346
|
raw_links: links.length,
|
|
1116
|
-
platforms
|
|
1347
|
+
platforms,
|
|
1348
|
+
// route_count is the max across platforms; both channels freeze the same
|
|
1349
|
+
// vendor list, so this is the drawer's per-row area count.
|
|
1350
|
+
area_count: diagnostics.route_count,
|
|
1351
|
+
round_unit: resolveLapRoundPolicy(source, diagnostics.route_count) ? 'lap' : 'round'
|
|
1117
1352
|
});
|
|
1118
1353
|
return {
|
|
1119
1354
|
source_name: input.source_name,
|
|
1120
|
-
requested_iterations:
|
|
1355
|
+
requested_iterations: advertisedIterations,
|
|
1121
1356
|
successful_iterations: successful,
|
|
1122
1357
|
failed_iterations: failed,
|
|
1123
1358
|
links,
|
|
@@ -1190,11 +1425,13 @@ async function runCredentialPlatform(input, options) {
|
|
|
1190
1425
|
errors.push('account and password are required');
|
|
1191
1426
|
return empty;
|
|
1192
1427
|
}
|
|
1428
|
+
// Advertised before the area list is known: lap rows report their lap cap.
|
|
1429
|
+
const startLap = resolveLapRoundPolicy(source, 0);
|
|
1193
1430
|
const startPolicy = resolveRoundPolicy(source);
|
|
1194
1431
|
event(options, 'extract_source_started', {
|
|
1195
1432
|
source_name: input.source_name,
|
|
1196
|
-
requested_iterations:
|
|
1197
|
-
min_iterations: startPolicy.min_iterations,
|
|
1433
|
+
requested_iterations: resolveAdvertisedMaxIterations(source),
|
|
1434
|
+
min_iterations: startLap ? startLap.policy.min_iterations : startPolicy.min_iterations,
|
|
1198
1435
|
resume_from_iteration: 1,
|
|
1199
1436
|
platform
|
|
1200
1437
|
});
|
|
@@ -1203,15 +1440,38 @@ async function runCredentialPlatform(input, options) {
|
|
|
1203
1440
|
// These only change when the vendor publishes a new build, so a cached entry
|
|
1204
1441
|
// is used first and the APK is inspected only when the cached seeds all stop
|
|
1205
1442
|
// answering. That keeps a normal run to a handful of HTTP calls.
|
|
1206
|
-
|
|
1443
|
+
// Keyed by the data plane, not the storefront: a redirected row downloads and
|
|
1444
|
+
// inspects the backend's APK, so it must share that brand's cached entry.
|
|
1445
|
+
const brandKey = fingerprintKeyFor(input.source_name, source);
|
|
1207
1446
|
const store = options.fingerprintStore ?? null;
|
|
1208
|
-
|
|
1447
|
+
// Same rule as the outer memo: a caller-supplied memo is keyed by the row
|
|
1448
|
+
// name, so a redirected row must not read or write through it.
|
|
1449
|
+
const memo = source.brand_override
|
|
1450
|
+
? createFingerprintMemo(store, brandKey)
|
|
1451
|
+
: (options.memo ?? createFingerprintMemo(store, brandKey));
|
|
1209
1452
|
// A fingerprint past its trust window is still used when its seeds answer,
|
|
1210
1453
|
// but it is re-verified against the APK rather than trusted indefinitely: a
|
|
1211
1454
|
// vendor can rotate the signing certificate while the old seeds still serve
|
|
1212
1455
|
// `/api/eversion`, and then every login fails on a stale certificate.
|
|
1213
1456
|
const fresh = store?.readFresh(brandKey) ?? null;
|
|
1214
|
-
|
|
1457
|
+
let storedEntry = store?.read(brandKey) ?? null;
|
|
1458
|
+
if (!storedEntry && store && !source.brand_override) {
|
|
1459
|
+
// An older build keyed this row by its official-site host. Reuse that entry
|
|
1460
|
+
// instead of forcing an APK download the host may not be able to make, and
|
|
1461
|
+
// move it to the canonical key so the next run reads it directly.
|
|
1462
|
+
const legacyKey = legacyFingerprintKey(input.source_name, source);
|
|
1463
|
+
if (legacyKey !== brandKey) {
|
|
1464
|
+
const legacy = store.readFresh(legacyKey) ?? store.read(legacyKey);
|
|
1465
|
+
if (legacy) {
|
|
1466
|
+
log(`[extract] adopting fingerprint cached under ${legacyKey}`);
|
|
1467
|
+
// Copies the entry under the canonical key while keeping the original
|
|
1468
|
+
// `verified_at`: stamping a new one would promote an entry that is
|
|
1469
|
+
// genuinely past its window into a trusted one.
|
|
1470
|
+
store.adopt(brandKey, legacy);
|
|
1471
|
+
storedEntry = store.read(brandKey);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1215
1475
|
const staleButUsable = Boolean(storedEntry) && !fresh;
|
|
1216
1476
|
const cached = staleButUsable ? storedEntry : memo.get();
|
|
1217
1477
|
const session = options.egressSession;
|
|
@@ -1222,20 +1482,30 @@ async function runCredentialPlatform(input, options) {
|
|
|
1222
1482
|
// Report real extraction work instead of making the UI infer APK/domain
|
|
1223
1483
|
// activity from text logs. Cache reuse remains distinct from executed work.
|
|
1224
1484
|
const substage = (name, status) => event(options, 'extract_substage', { source_name: input.source_name, platform, substage: name, status });
|
|
1225
|
-
const inspectApk = async () => {
|
|
1485
|
+
const inspectApk = async (recordFailure = true) => {
|
|
1226
1486
|
substage('apk_download', 'running');
|
|
1227
1487
|
let apkBytes = null;
|
|
1228
1488
|
try {
|
|
1229
1489
|
apkBytes = options.loadApk
|
|
1230
1490
|
? await options.loadApk(source)
|
|
1231
|
-
: await defaultLoadApk(source, fetchImpl, timeoutMs, now, log,
|
|
1491
|
+
: await defaultLoadApk(source, fetchImpl, timeoutMs, now, log,
|
|
1492
|
+
// The archive prefix names the *backend's* build (`_lt`, not `_hd`):
|
|
1493
|
+
// a redirected row downloads from the backend's CDN, which publishes
|
|
1494
|
+
// only the backend's own archives. Passing the row name here made
|
|
1495
|
+
// every candidate `_hd<N>.apk` against a 雷霆 host, so all lookback
|
|
1496
|
+
// slots missed and the run failed with "APK could not be located".
|
|
1497
|
+
source.brand_override ?? input.source_name, egress);
|
|
1232
1498
|
}
|
|
1233
1499
|
catch (error) {
|
|
1234
|
-
|
|
1500
|
+
if (recordFailure) {
|
|
1501
|
+
errors.push(`apk: ${error instanceof Error ? error.message : String(error)}`);
|
|
1502
|
+
}
|
|
1235
1503
|
}
|
|
1236
1504
|
if (!apkBytes) {
|
|
1237
1505
|
substage('apk_download', 'failed');
|
|
1238
|
-
|
|
1506
|
+
if (recordFailure) {
|
|
1507
|
+
errors.push('APK could not be located');
|
|
1508
|
+
}
|
|
1239
1509
|
return null;
|
|
1240
1510
|
}
|
|
1241
1511
|
substage('apk_download', 'success');
|
|
@@ -1252,7 +1522,7 @@ async function runCredentialPlatform(input, options) {
|
|
|
1252
1522
|
log(`[extract] ${discovered.package_name} ${discovered.version_name} seeds=${discovered.seed_domains.length}`);
|
|
1253
1523
|
return discovered.seed_domains.length > 0 ? discovered : null;
|
|
1254
1524
|
};
|
|
1255
|
-
const inspectSharedApk = () => (memo.resolve ? memo.resolve(inspectApk) : inspectApk());
|
|
1525
|
+
const inspectSharedApk = (recordFailure = true) => (memo.resolve ? memo.resolve(() => inspectApk(recordFailure)) : inspectApk(recordFailure));
|
|
1256
1526
|
// `apk_url` / `apk_path` are an explicit override: when either is set the
|
|
1257
1527
|
// caller is asserting a specific build, so a cached fingerprint must not
|
|
1258
1528
|
// shadow it. Otherwise the cache would make the override a no-op.
|
|
@@ -1273,7 +1543,11 @@ async function runCredentialPlatform(input, options) {
|
|
|
1273
1543
|
// stale certificate fails every login with no other signal.
|
|
1274
1544
|
if (staleButUsable) {
|
|
1275
1545
|
log('[extract] cached fingerprint is past its trust window; re-inspecting the APK');
|
|
1276
|
-
|
|
1546
|
+
// The refresh is opportunistic: the cached entry already answered
|
|
1547
|
+
// `/api/eversion`, so an unreachable storefront must not also append
|
|
1548
|
+
// "APK could not be located" to a run that is about to work. That line
|
|
1549
|
+
// was the whole error text of a run whose nodes came through fine.
|
|
1550
|
+
const refreshed = await inspectSharedApk(false);
|
|
1277
1551
|
if (refreshed) {
|
|
1278
1552
|
const refreshedEversion = await tryEversion(refreshed, credentialKeyCandidates(source, refreshed));
|
|
1279
1553
|
if (refreshedEversion) {
|
|
@@ -1348,7 +1622,7 @@ async function runCredentialPlatform(input, options) {
|
|
|
1348
1622
|
platform,
|
|
1349
1623
|
timestampMs: ts,
|
|
1350
1624
|
code: ''
|
|
1351
|
-
}, resolveBrandLayout({ ...source, brand: input.source_name })), keys, timeoutMs, egress);
|
|
1625
|
+
}, resolveBrandLayout({ ...source, brand: source.brand_override ?? input.source_name })), keys, timeoutMs, egress);
|
|
1352
1626
|
substage('domain_discovery', resolved ? 'success' : 'failed');
|
|
1353
1627
|
return resolved;
|
|
1354
1628
|
}
|
|
@@ -1359,7 +1633,7 @@ async function runCredentialPlatform(input, options) {
|
|
|
1359
1633
|
identity,
|
|
1360
1634
|
device,
|
|
1361
1635
|
platform,
|
|
1362
|
-
layout: resolveBrandLayout({ ...source, brand: input.source_name }),
|
|
1636
|
+
layout: resolveBrandLayout({ ...source, brand: source.brand_override ?? input.source_name }),
|
|
1363
1637
|
token: '',
|
|
1364
1638
|
// Before login the app's local KV has no `code`, so the request carries an
|
|
1365
1639
|
// empty one; `&code=` only holds a value once the account has one.
|
|
@@ -1391,10 +1665,15 @@ async function runCredentialPlatform(input, options) {
|
|
|
1391
1665
|
diagnostics.membership_expired = isMembershipExpiredMessage(message);
|
|
1392
1666
|
diagnostics.credential_rejected = !diagnostics.membership_expired && isCredentialLoginRejection(message);
|
|
1393
1667
|
errors.push(`login rejected: code=${String(payload.code)}`);
|
|
1394
|
-
// A cached fingerprint whose
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
|
|
1668
|
+
// A cached fingerprint whose signing cert the vendor rotated fails here,
|
|
1669
|
+
// so the cache must not outlive the failure. Only a real credential
|
|
1670
|
+
// rejection is evidence of that: a throttled or free-tier account is
|
|
1671
|
+
// rejected too, and dropping the brand's fingerprint for it made the next
|
|
1672
|
+
// run re-download the APK from a storefront this host cannot reach —
|
|
1673
|
+
// turning one bad account into a source that never runs again. On a
|
|
1674
|
+
// redirected row that rejection is the norm (most accounts are unknown to
|
|
1675
|
+
// the backend), so there the cache is never dropped on a login result.
|
|
1676
|
+
if (cachedInUse && diagnostics.credential_rejected && !source.brand_override) {
|
|
1398
1677
|
store?.clear(brandKey);
|
|
1399
1678
|
}
|
|
1400
1679
|
return { ...empty, failed_iterations: 1 };
|
|
@@ -1518,9 +1797,13 @@ async function runCredentialPlatform(input, options) {
|
|
|
1518
1797
|
// Raw totals use the exact-string identity the pipeline dedupes with, so the
|
|
1519
1798
|
// live number and `extract_source_completed.raw_links` agree.
|
|
1520
1799
|
const linkIds = new Set();
|
|
1521
|
-
|
|
1800
|
+
// Lap mode: one round is one full sweep of the frozen list, so the cap and
|
|
1801
|
+
// the plateau are laps with no conversion factor. A row with no lap key
|
|
1802
|
+
// keeps the legacy round policy with identical arguments.
|
|
1803
|
+
const lap = resolveLapRoundPolicy(source, activeRoutes.length);
|
|
1804
|
+
const policy = lap ? lap.policy : resolveRoundPolicy(source);
|
|
1522
1805
|
const maxIterations = policy.max_iterations;
|
|
1523
|
-
const areasPerRound = resolveAreasPerRound(source.areas_per_round, activeRoutes.length);
|
|
1806
|
+
const areasPerRound = lap ? lap.areas_per_round : resolveAreasPerRound(source.areas_per_round, activeRoutes.length);
|
|
1524
1807
|
const areaConcurrency = resolveConcurrency(source.area_concurrency, DEFAULT_AREA_CONCURRENCY);
|
|
1525
1808
|
const cursor = new AreaCursor(activeRoutes);
|
|
1526
1809
|
const startedAt = Date.now();
|
|
@@ -1533,8 +1816,11 @@ async function runCredentialPlatform(input, options) {
|
|
|
1533
1816
|
diagnostics.route_count = activeRoutes.length;
|
|
1534
1817
|
diagnostics.areas_per_round = areasPerRound;
|
|
1535
1818
|
diagnostics.area_concurrency = areaConcurrency;
|
|
1536
|
-
log(
|
|
1537
|
-
` ${
|
|
1819
|
+
log(lap
|
|
1820
|
+
? `[extract] area list frozen: ${activeRoutes.length} areas, ${maxIterations} 遍 (1 遍 = 全部区域各一次),` +
|
|
1821
|
+
` concurrency ${areaConcurrency}, egress ${session.transport}`
|
|
1822
|
+
: `[extract] area list frozen: ${activeRoutes.length} areas,` +
|
|
1823
|
+
` ${areasPerRound}/round, concurrency ${areaConcurrency}, egress ${session.transport}`);
|
|
1538
1824
|
for (let round = 1; round <= maxIterations; round += 1) {
|
|
1539
1825
|
if (!shouldStartRound(round, policy, (Date.now() - startedAt) / 1000)) {
|
|
1540
1826
|
break;
|
|
@@ -1620,8 +1906,8 @@ async function runCredentialPlatform(input, options) {
|
|
|
1620
1906
|
if (session.reportEmptySweep()) {
|
|
1621
1907
|
log('[extract] no route answered; switching to the upstream proxy');
|
|
1622
1908
|
}
|
|
1623
|
-
if (emptySweep >= MAX_EMPTY_ROUNDS) {
|
|
1624
|
-
errors.push(`no route answered for ${emptySweep} consecutive rounds`);
|
|
1909
|
+
if (emptySweep >= (lap?.empty_round_limit ?? MAX_EMPTY_ROUNDS)) {
|
|
1910
|
+
errors.push(`no route answered for ${emptySweep} consecutive ${lap ? 'laps' : 'rounds'}`);
|
|
1625
1911
|
break;
|
|
1626
1912
|
}
|
|
1627
1913
|
}
|
|
@@ -1644,17 +1930,19 @@ async function runCredentialPlatform(input, options) {
|
|
|
1644
1930
|
new_item_fingerprints: newFingerprints,
|
|
1645
1931
|
new_item_link_ids: newLinkIds
|
|
1646
1932
|
});
|
|
1647
|
-
log(
|
|
1933
|
+
log((lap
|
|
1934
|
+
? `[extract] 遍 ${round}/${maxIterations} area=${batch.map((route) => route.area).join(',')}`
|
|
1935
|
+
: `[extract] round ${round}/${maxIterations} area=${batch.map((route) => route.area).join(',')}`) +
|
|
1648
1936
|
` +${appendedInRound} new (+${meaningfulInRound} meaningful, total ${links.length},` +
|
|
1649
|
-
` plateau ${plateau}/${policy.plateau_limit})`);
|
|
1937
|
+
` plateau ${plateau}/${policy.plateau_limit}${lap ? ' 遍' : ''})`);
|
|
1650
1938
|
if (shouldStopOnPlateau(round, plateau, policy)) {
|
|
1651
|
-
log(`[extract] early stop: ${plateau} consecutive rounds without a meaningful new node`);
|
|
1939
|
+
log(`[extract] early stop: ${plateau} consecutive ${lap ? 'laps' : 'rounds'} without a meaningful new node`);
|
|
1652
1940
|
break;
|
|
1653
1941
|
}
|
|
1654
|
-
if (placeholderStreak >= PLACEHOLDER_ONLY_ROUNDS) {
|
|
1655
|
-
errors.push(`channel produced only placeholder nodes for ${placeholderStreak} rounds; stopping it` +
|
|
1942
|
+
if (placeholderStreak >= (lap?.placeholder_round_limit ?? PLACEHOLDER_ONLY_ROUNDS)) {
|
|
1943
|
+
errors.push(`channel produced only placeholder nodes for ${placeholderStreak} ${lap ? 'laps' : 'rounds'}; stopping it` +
|
|
1656
1944
|
' (this brand does not serve real nodes on this platform)');
|
|
1657
|
-
log(`[extract] ${platform} channel stopped: ${placeholderStreak} rounds of placeholder nodes only.` +
|
|
1945
|
+
log(`[extract] ${platform} channel stopped: ${placeholderStreak} ${lap ? 'laps' : 'rounds'} of placeholder nodes only.` +
|
|
1658
1946
|
' The other channel still runs; this platform is not serving real nodes for this brand.');
|
|
1659
1947
|
break;
|
|
1660
1948
|
}
|
|
@@ -1670,7 +1958,9 @@ async function runCredentialPlatform(input, options) {
|
|
|
1670
1958
|
requested_iterations: maxIterations,
|
|
1671
1959
|
successful_iterations: successfulRounds,
|
|
1672
1960
|
failed_iterations: failedRounds,
|
|
1673
|
-
raw_links: links.length
|
|
1961
|
+
raw_links: links.length,
|
|
1962
|
+
area_count: activeRoutes.length,
|
|
1963
|
+
round_unit: lap ? 'lap' : 'round'
|
|
1674
1964
|
});
|
|
1675
1965
|
return {
|
|
1676
1966
|
source_name: input.source_name,
|
|
@@ -1681,6 +1971,36 @@ async function runCredentialPlatform(input, options) {
|
|
|
1681
1971
|
diagnostics
|
|
1682
1972
|
};
|
|
1683
1973
|
}
|
|
1974
|
+
/**
|
|
1975
|
+
* Decide what a membership sweep does with one pooled row.
|
|
1976
|
+
*
|
|
1977
|
+
* The sweep logs in with explicit credentials, so the run path's own retirement
|
|
1978
|
+
* (which only fires for a *leased* row) never runs for it — the same decision has
|
|
1979
|
+
* to be reachable from here, or a free-tier account with a date years in the
|
|
1980
|
+
* future stays in rotation forever. Kept pure so the policy can be asserted
|
|
1981
|
+
* without a vendor login.
|
|
1982
|
+
*/
|
|
1983
|
+
export function classifyMembershipRefresh(facts, today) {
|
|
1984
|
+
const expires = String(facts.membership_expires_at ?? '');
|
|
1985
|
+
if (facts.login_ok) {
|
|
1986
|
+
if (expires && expires < today)
|
|
1987
|
+
return { status: 'expired', reason: 'membership expired' };
|
|
1988
|
+
const level = facts.membership_level;
|
|
1989
|
+
const produced = Number(facts.produced_links ?? 0) > 0 || Number(facts.route_count ?? 0) > 0;
|
|
1990
|
+
// A free-tier row (level 0/1) only ever yields placeholders. Retire it only
|
|
1991
|
+
// when it produced nothing, exactly as the run path does, so a paid row the
|
|
1992
|
+
// vendor misreports as level 1 keeps its place.
|
|
1993
|
+
if (level !== undefined && level < 2 && !produced)
|
|
1994
|
+
return { status: 'retired', reason: 'no paid membership' };
|
|
1995
|
+
return { status: 'updated', reason: '' };
|
|
1996
|
+
}
|
|
1997
|
+
if (facts.membership_expired)
|
|
1998
|
+
return { status: 'expired', reason: 'membership expired' };
|
|
1999
|
+
// The vendor rejected the credential itself: the row cannot log in at all.
|
|
2000
|
+
if (facts.credential_rejected)
|
|
2001
|
+
return { status: 'retired', reason: 'login rejected' };
|
|
2002
|
+
return { status: 'failed', reason: '' };
|
|
2003
|
+
}
|
|
1684
2004
|
/**
|
|
1685
2005
|
* Learn every pooled account's real membership expiry by logging in.
|
|
1686
2006
|
*
|
|
@@ -1701,7 +2021,7 @@ export async function refreshAccountExpiry(input = {}) {
|
|
|
1701
2021
|
cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
|
|
1702
2022
|
now
|
|
1703
2023
|
});
|
|
1704
|
-
const outcome = { checked: 0, updated: 0, expired: 0, failed: 0, results: [] };
|
|
2024
|
+
const outcome = { checked: 0, updated: 0, expired: 0, retired: 0, failed: 0, results: [] };
|
|
1705
2025
|
const todayText = localDay(now());
|
|
1706
2026
|
const requested = input.brand ? normalizeBrand(input.brand) : null;
|
|
1707
2027
|
// Every brand the pool knows, from the registry rather than a second list.
|
|
@@ -1743,25 +2063,30 @@ export async function refreshAccountExpiry(input = {}) {
|
|
|
1743
2063
|
}, { env, now, memo, fingerprintStore });
|
|
1744
2064
|
const expires = result.diagnostics.membership_expires_at ?? '';
|
|
1745
2065
|
entry.expires_at = expires;
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
}
|
|
2066
|
+
const verdict = classifyMembershipRefresh({
|
|
2067
|
+
...result.diagnostics,
|
|
2068
|
+
produced_links: result.links.length
|
|
2069
|
+
}, todayText);
|
|
2070
|
+
entry.status = verdict.status;
|
|
2071
|
+
if (verdict.status === 'updated' || verdict.status === 'expired') {
|
|
2072
|
+
// A lapsed date is still the account's real date: keep it on the row
|
|
2073
|
+
// so the pool shows when it ran out, not an em dash.
|
|
2074
|
+
if (expires)
|
|
2075
|
+
store.setExpiry(brand, leased.account, expires);
|
|
1757
2076
|
}
|
|
1758
|
-
|
|
1759
|
-
store.markExpired(leased.id,
|
|
1760
|
-
entry.status = 'expired';
|
|
2077
|
+
if (verdict.status === 'expired') {
|
|
2078
|
+
store.markExpired(leased.id, verdict.reason);
|
|
1761
2079
|
outcome.expired += 1;
|
|
1762
2080
|
}
|
|
2081
|
+
else if (verdict.status === 'retired') {
|
|
2082
|
+
store.markInvalid(leased.id, verdict.reason);
|
|
2083
|
+
entry.error = verdict.reason;
|
|
2084
|
+
outcome.retired += 1;
|
|
2085
|
+
}
|
|
2086
|
+
else if (verdict.status === 'updated') {
|
|
2087
|
+
outcome.updated += 1;
|
|
2088
|
+
}
|
|
1763
2089
|
else {
|
|
1764
|
-
entry.status = 'failed';
|
|
1765
2090
|
// The CLI prints this line, so nothing that can carry a credential
|
|
1766
2091
|
// or a request URL may reach it.
|
|
1767
2092
|
entry.error = redactText(result.diagnostics.errors.join('; ')) || 'login did not report an expiry';
|