@chainlesschain/personal-data-hub 0.1.0 → 0.2.1

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.
Files changed (154) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +396 -0
  4. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  5. package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
  6. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  7. package/__tests__/adapters/email-adapter.test.js +138 -1
  8. package/__tests__/adapters/email-classifier.test.js +347 -0
  9. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  10. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  11. package/__tests__/adapters/email-templates.test.js +699 -0
  12. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  13. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  14. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  15. package/__tests__/adapters/system-data-android.test.js +387 -0
  16. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  17. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  18. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  19. package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
  20. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  21. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  22. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  23. package/__tests__/analysis-skills.test.js +556 -0
  24. package/__tests__/analysis.test.js +329 -1
  25. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  26. package/__tests__/e2e/full-user-journey.test.js +188 -0
  27. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  28. package/__tests__/entity-resolver-stages.test.js +411 -0
  29. package/__tests__/entity-resolver-vault.test.js +246 -0
  30. package/__tests__/entity-resolver.test.js +526 -0
  31. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  32. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  33. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  34. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  35. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  36. package/__tests__/longtail-adapters.test.js +217 -0
  37. package/__tests__/mobile-extractor.test.js +288 -0
  38. package/__tests__/registry.test.js +4 -2
  39. package/__tests__/shopping-adapters.test.js +296 -0
  40. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  41. package/__tests__/sidecar-supervisor.test.js +120 -0
  42. package/__tests__/social-adapters.test.js +206 -0
  43. package/__tests__/travel-adapters.test.js +325 -0
  44. package/__tests__/vault.test.js +3 -3
  45. package/__tests__/wechat-adapter.test.js +476 -0
  46. package/__tests__/whatsapp-adapter.test.js +135 -0
  47. package/lib/adapter-spec.js +12 -0
  48. package/lib/adapters/_python-sidecar-base.js +207 -0
  49. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
  50. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  51. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  52. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  53. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  54. package/lib/adapters/ai-chat-history/index.js +28 -0
  55. package/lib/adapters/ai-chat-history/schema-map.js +258 -0
  56. package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
  57. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  58. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  59. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  60. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  61. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  62. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  63. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  64. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  65. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  66. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  67. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
  68. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  69. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  70. package/lib/adapters/alipay-bill/index.js +41 -0
  71. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  72. package/lib/adapters/email-imap/classifier.js +495 -0
  73. package/lib/adapters/email-imap/email-adapter.js +419 -8
  74. package/lib/adapters/email-imap/index.js +42 -0
  75. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  76. package/lib/adapters/email-imap/templates/bill.js +232 -0
  77. package/lib/adapters/email-imap/templates/government.js +120 -0
  78. package/lib/adapters/email-imap/templates/index.js +78 -0
  79. package/lib/adapters/email-imap/templates/order.js +186 -0
  80. package/lib/adapters/email-imap/templates/other.js +114 -0
  81. package/lib/adapters/email-imap/templates/register.js +113 -0
  82. package/lib/adapters/email-imap/templates/travel.js +157 -0
  83. package/lib/adapters/email-imap/templates/utils.js +275 -0
  84. package/lib/adapters/email-imap/transactions.js +234 -0
  85. package/lib/adapters/messaging-qq/index.js +158 -0
  86. package/lib/adapters/messaging-telegram/index.js +142 -0
  87. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  88. package/lib/adapters/shopping-base/index.js +208 -0
  89. package/lib/adapters/shopping-jd/index.js +150 -0
  90. package/lib/adapters/shopping-meituan/index.js +154 -0
  91. package/lib/adapters/shopping-taobao/index.js +176 -0
  92. package/lib/adapters/social-bilibili/index.js +171 -0
  93. package/lib/adapters/social-douyin/index.js +116 -0
  94. package/lib/adapters/social-kuaishou/index.js +237 -0
  95. package/lib/adapters/social-toutiao/index.js +236 -0
  96. package/lib/adapters/social-weibo/index.js +164 -0
  97. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  98. package/lib/adapters/system-data/disclosure.js +166 -0
  99. package/lib/adapters/system-data/index.js +34 -0
  100. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  101. package/lib/adapters/system-data-android/adapter.js +348 -0
  102. package/lib/adapters/system-data-android/index.js +76 -0
  103. package/lib/adapters/travel-12306/index.js +151 -0
  104. package/lib/adapters/travel-amap/index.js +164 -0
  105. package/lib/adapters/travel-baidu-map/index.js +162 -0
  106. package/lib/adapters/travel-base/index.js +240 -0
  107. package/lib/adapters/travel-ctrip/index.js +151 -0
  108. package/lib/adapters/wechat/bootstrap.js +146 -0
  109. package/lib/adapters/wechat/content-parser.js +326 -0
  110. package/lib/adapters/wechat/db-reader.js +209 -0
  111. package/lib/adapters/wechat/env-probe.js +218 -0
  112. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  113. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  114. package/lib/adapters/wechat/index.js +37 -0
  115. package/lib/adapters/wechat/key-extractor.js +158 -0
  116. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  117. package/lib/adapters/wechat/key-providers/index.js +22 -0
  118. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  119. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  120. package/lib/adapters/wechat/normalize.js +220 -0
  121. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  122. package/lib/analysis-skills/base.js +113 -0
  123. package/lib/analysis-skills/footprint.js +167 -0
  124. package/lib/analysis-skills/index.js +58 -0
  125. package/lib/analysis-skills/interests.js +161 -0
  126. package/lib/analysis-skills/relations.js +226 -0
  127. package/lib/analysis-skills/spending.js +219 -0
  128. package/lib/analysis-skills/timeline.js +167 -0
  129. package/lib/analysis.js +191 -2
  130. package/lib/entity-resolver/embedding-stage.js +198 -0
  131. package/lib/entity-resolver/entity-resolver.js +384 -0
  132. package/lib/entity-resolver/index.js +42 -0
  133. package/lib/entity-resolver/llm-stage.js +191 -0
  134. package/lib/entity-resolver/rule-stage.js +208 -0
  135. package/lib/entity-resolver/worker.js +149 -0
  136. package/lib/index.js +131 -0
  137. package/lib/migrations.js +73 -0
  138. package/lib/mobile-extractor/android.js +193 -0
  139. package/lib/mobile-extractor/index.js +9 -0
  140. package/lib/mobile-extractor/ios.js +223 -0
  141. package/lib/prompt-builder.js +11 -1
  142. package/lib/query-parser.js +7 -1
  143. package/lib/registry.js +42 -0
  144. package/lib/sidecar/index.js +15 -0
  145. package/lib/sidecar/supervisor.js +359 -0
  146. package/lib/vault.js +343 -0
  147. package/package.json +36 -3
  148. package/scripts/_make-fixture-all.js +126 -0
  149. package/scripts/_make-fixture-contacts.js +84 -0
  150. package/scripts/evaluate-entity-resolver.js +213 -0
  151. package/scripts/smoke-phase-5-5.js +196 -0
  152. package/scripts/smoke-phase-5-7.js +181 -0
  153. package/scripts/smoke-system-data-contacts.js +309 -0
  154. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,473 @@
1
+ /**
2
+ * AIChat WebView 鉴权向导 — Wizard Controller (Phase 10.3.2)
3
+ *
4
+ * Lives in the Electron main process. Drives the 4-step rendering-process
5
+ * Wizard for adding any of the 9 国产 AI vendors as a data source:
6
+ *
7
+ * openVendorLogin — provision a per-vendor session partition + (on
8
+ * desktop) return enough metadata for the renderer to
9
+ * host a `<webview>` / BrowserView. Web-shell mode is
10
+ * a no-op that returns `fallbackMode: "paste"`.
11
+ * probeCookies — read all cookies under the vendor's cookieDomains
12
+ * and classify against the spec.
13
+ * registerVendor — wrap AIChatHistoryAdapter's existing register path
14
+ * with cookie validation + accounts.json persistence
15
+ * (mirrors the shape used by Email / Alipay wiring).
16
+ * rotateLoginPartition — flush stored cookies + provision a fresh session.
17
+ * cleanupOrphanPartitions — startup-time scan that drops any
18
+ * persist:aichat-<vendor> partitions whose vendor is
19
+ * no longer in accounts.json.
20
+ *
21
+ * Reference: docs/design/Personal_Data_Hub_Phase_10_3_AIChat_WebView_Wizard.md §2 §4
22
+ *
23
+ * Electron access is fully behind `_deps` injection (sessionFactory /
24
+ * accountsStore / clock / logger / classifier). The Electron singletons are
25
+ * resolved lazily at construction time so tests stay Electron-free and
26
+ * Phase 10.3.3 (renderer wiring) can stub anything per test.
27
+ */
28
+
29
+ "use strict";
30
+
31
+ const {
32
+ getSpec,
33
+ listVendors,
34
+ classifyProbedCookies,
35
+ COOKIE_SPEC_VERSION,
36
+ } = require("./cookie-capture-spec");
37
+
38
+ const PARTITION_PREFIX = "persist:aichat-";
39
+
40
+ function _partitionNameFor(vendor) {
41
+ return PARTITION_PREFIX + vendor;
42
+ }
43
+
44
+ function _isAichatPartition(name) {
45
+ return typeof name === "string" && name.startsWith(PARTITION_PREFIX);
46
+ }
47
+
48
+ function _vendorFromPartition(name) {
49
+ return _isAichatPartition(name) ? name.slice(PARTITION_PREFIX.length) : null;
50
+ }
51
+
52
+ /**
53
+ * Default dep resolver — only loaded when running inside Electron. Tests pass
54
+ * an explicit `_deps` and never trigger this.
55
+ */
56
+ function _resolveElectronDeps() {
57
+ // Lazy require — keeps this module unit-testable in plain Node.
58
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
59
+ const electron = require("electron");
60
+ const fs = require("node:fs");
61
+ return {
62
+ sessionFactory: (partition) => electron.session.fromPartition(partition, { cache: true }),
63
+ fs,
64
+ clock: () => Date.now(),
65
+ logger: {
66
+ info: (...a) => console.info("[aichat-wizard]", ...a),
67
+ warn: (...a) => console.warn("[aichat-wizard]", ...a),
68
+ error: (...a) => console.error("[aichat-wizard]", ...a),
69
+ },
70
+ classifier: classifyProbedCookies,
71
+ specLookup: getSpec,
72
+ knownVendors: listVendors(),
73
+ cookieSpecVersion: COOKIE_SPEC_VERSION,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Construct a controller bound to:
79
+ * - an accountsStore ({ get(vendor), put(vendor, entry), delete(vendor), list() })
80
+ * which the desktop wiring resolves to an `aichat-accounts.json` reader.
81
+ * - a vendorAdapter (the AIChatHistoryAdapter or any object with
82
+ * `registerVendor(vendor, cookies, opts)` returning a Promise).
83
+ *
84
+ * `_deps` is the seam for tests / future shells (web-shell injects a
85
+ * `fallbackMode: "paste"` sessionFactory that doesn't open BrowserView).
86
+ */
87
+ function createAIChatWizardController({ accountsStore, vendorAdapter, _deps } = {}) {
88
+ if (!accountsStore || typeof accountsStore.get !== "function") {
89
+ throw new Error("aichat-wizard: accountsStore with get/put/delete/list required");
90
+ }
91
+ if (!vendorAdapter || typeof vendorAdapter.registerVendor !== "function") {
92
+ throw new Error("aichat-wizard: vendorAdapter.registerVendor required");
93
+ }
94
+
95
+ const deps = _deps || _resolveElectronDeps();
96
+ const {
97
+ sessionFactory, clock, logger, classifier, specLookup, knownVendors, cookieSpecVersion,
98
+ fallbackMode, // "browser-view" (default) or "paste" — web-shell sets this
99
+ } = deps;
100
+
101
+ function _requireSpec(vendor) {
102
+ const spec = specLookup(vendor);
103
+ if (!spec) {
104
+ const err = new Error("UNKNOWN_VENDOR");
105
+ err.code = "UNKNOWN_VENDOR";
106
+ err.vendor = vendor;
107
+ throw err;
108
+ }
109
+ return spec;
110
+ }
111
+
112
+ /**
113
+ * Step 1 of the wizard.
114
+ *
115
+ * Returns the metadata the renderer needs to either host a BrowserView
116
+ * (desktop) or display the paste-fallback (web-shell). NEVER throws on
117
+ * unknown vendor — returns `{ ok:false, reason:"UNKNOWN_VENDOR" }` so the
118
+ * renderer can show a typed error.
119
+ */
120
+ async function openVendorLogin({ vendor, opts = {} } = {}) {
121
+ const spec = specLookup(vendor);
122
+ if (!spec) {
123
+ return { ok: false, reason: "UNKNOWN_VENDOR", vendor };
124
+ }
125
+ const partition = _partitionNameFor(vendor);
126
+
127
+ // Web-shell path: no BrowserView, return paste helper instructions.
128
+ if (fallbackMode === "paste") {
129
+ return {
130
+ ok: true,
131
+ vendor,
132
+ fallbackMode: "paste",
133
+ helpText:
134
+ `请在外部浏览器打开 ${spec.loginUrl} 完成登录,` +
135
+ `登录后从开发者工具 Application → Cookies 复制全部 cookie 串粘贴到下方文本框。`,
136
+ loginUrl: spec.loginUrl,
137
+ requiredCookies: spec.requiredCookies.slice(),
138
+ };
139
+ }
140
+
141
+ // Desktop path: prime the session (cookies are persisted automatically
142
+ // by Electron once the partition exists). The renderer is responsible
143
+ // for mounting a BrowserView pointing at `loginUrl` with `partition`.
144
+ let session;
145
+ try {
146
+ session = sessionFactory(partition);
147
+ } catch (err) {
148
+ logger.error("openVendorLogin: sessionFactory failed", err);
149
+ return { ok: false, reason: "SESSION_INIT_FAILED", error: err.message };
150
+ }
151
+
152
+ // Optional: reset stored cookies if the caller wants a clean login.
153
+ if (opts.reuseSession === false && typeof session.clearStorageData === "function") {
154
+ try {
155
+ await session.clearStorageData({ storages: ["cookies"] });
156
+ } catch (err) {
157
+ logger.warn("openVendorLogin: clearStorageData failed (ignored)", err);
158
+ }
159
+ }
160
+
161
+ return {
162
+ ok: true,
163
+ vendor,
164
+ fallbackMode: "browser-view",
165
+ partition,
166
+ loginUrl: spec.loginUrl,
167
+ cookieDomains: spec.cookieDomains.slice(),
168
+ postLoginPathHints: spec.postLoginPathHints.slice(),
169
+ notes: spec.notes,
170
+ openedAt: clock(),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Step 2 of the wizard.
176
+ *
177
+ * Reads cookies from the vendor's partition (desktop) or parses the pasted
178
+ * cookie string (web-shell), classifies them against the spec, and returns
179
+ * everything the renderer needs to show Step 3's validation summary.
180
+ */
181
+ async function probeCookies({ vendor, cookieHeader } = {}) {
182
+ const spec = specLookup(vendor);
183
+ if (!spec) {
184
+ return { ok: false, reason: "UNKNOWN_VENDOR", vendor };
185
+ }
186
+
187
+ // Web-shell paste path takes precedence — if a header was supplied, use it.
188
+ if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
189
+ const classified = classifier(vendor, cookieHeader);
190
+ return {
191
+ ok: classified.ok,
192
+ vendor,
193
+ source: "paste",
194
+ cookies: _projectCookies(classified, cookieHeader, spec),
195
+ foundRequired: classified.foundRequired,
196
+ missingRequired: classified.missingRequired,
197
+ foundOptional: classified.foundOptional,
198
+ };
199
+ }
200
+
201
+ if (fallbackMode === "paste") {
202
+ // Web-shell asked to probe without supplying a header — return guidance.
203
+ return {
204
+ ok: false,
205
+ vendor,
206
+ reason: "PASTE_REQUIRED",
207
+ source: "paste",
208
+ };
209
+ }
210
+
211
+ // Desktop: ask each cookieDomain in turn for cookies.
212
+ const partition = _partitionNameFor(vendor);
213
+ let session;
214
+ try {
215
+ session = sessionFactory(partition);
216
+ } catch (err) {
217
+ logger.error("probeCookies: sessionFactory failed", err);
218
+ return { ok: false, reason: "SESSION_INIT_FAILED", error: err.message };
219
+ }
220
+
221
+ const jar = {};
222
+ for (const domain of spec.cookieDomains) {
223
+ let cookies = [];
224
+ try {
225
+ // Electron `cookies.get({ domain })` returns Cookie[]. We pass
226
+ // domain stripped of leading dot when querying (Electron accepts both).
227
+ cookies = await session.cookies.get({ domain: domain.replace(/^\./, "") });
228
+ } catch (err) {
229
+ logger.warn(`probeCookies: cookies.get failed for domain=${domain}`, err);
230
+ continue;
231
+ }
232
+ for (const c of cookies) {
233
+ if (c && typeof c.name === "string" && typeof c.value === "string" && c.value.length > 0) {
234
+ // Last write wins — domain order is intentional (root first via spec order).
235
+ jar[c.name] = c.value;
236
+ }
237
+ }
238
+ }
239
+
240
+ const classified = classifier(vendor, jar);
241
+ return {
242
+ ok: classified.ok,
243
+ vendor,
244
+ source: "browser-view",
245
+ cookies: jar,
246
+ foundRequired: classified.foundRequired,
247
+ missingRequired: classified.missingRequired,
248
+ foundOptional: classified.foundOptional,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Step 3 of the wizard.
254
+ *
255
+ * Re-classifies the supplied cookies one more time defensively (the
256
+ * renderer may have come from `probeCookies` long enough ago to drift),
257
+ * hands the jar off to AIChatHistoryAdapter.registerVendor, then persists
258
+ * a row in accounts.json on success. Returns `{ ok, validation, accountId }`.
259
+ */
260
+ async function registerVendor({ vendor, cookies, opts = {} } = {}) {
261
+ const spec = specLookup(vendor);
262
+ if (!spec) {
263
+ return { ok: false, reason: "UNKNOWN_VENDOR", vendor };
264
+ }
265
+ const classified = classifier(vendor, cookies);
266
+ if (!classified.ok) {
267
+ return {
268
+ ok: false,
269
+ vendor,
270
+ reason: "REQUIRED_COOKIES_MISSING",
271
+ missingRequired: classified.missingRequired,
272
+ };
273
+ }
274
+
275
+ let validation;
276
+ try {
277
+ validation = await vendorAdapter.registerVendor(vendor, _flattenJar(cookies), opts);
278
+ } catch (err) {
279
+ logger.error("registerVendor: vendorAdapter threw", err);
280
+ return { ok: false, vendor, reason: "ADAPTER_THREW", error: err.message };
281
+ }
282
+
283
+ if (!validation || validation.ok !== true) {
284
+ return {
285
+ ok: false,
286
+ vendor,
287
+ reason: (validation && validation.reason) || "VALIDATE_COOKIE_FAILED",
288
+ validation,
289
+ };
290
+ }
291
+
292
+ const now = clock();
293
+ const accountId = `${vendor}:${validation.userId || "anon"}`;
294
+ const entry = {
295
+ vendor,
296
+ registeredAt: (await _existingRegisteredAt(vendor)) || now,
297
+ cookies: _flattenJar(cookies),
298
+ userId: validation.userId || null,
299
+ displayName: spec.displayName,
300
+ lastSyncAt: null,
301
+ lastHealth: { ok: true, at: now },
302
+ cookieSpecVersion,
303
+ };
304
+ await accountsStore.put(vendor, entry);
305
+
306
+ return { ok: true, vendor, accountId, validation };
307
+ }
308
+
309
+ /**
310
+ * Step 4 (optional, re-login flow).
311
+ *
312
+ * Clears the vendor's partition storage and re-runs openVendorLogin so the
313
+ * renderer can re-host BrowserView fresh. Does NOT touch accounts.json —
314
+ * `registerVendor` will overwrite the entry once the user finishes.
315
+ */
316
+ async function rotateLoginPartition({ vendor } = {}) {
317
+ const spec = specLookup(vendor);
318
+ if (!spec) {
319
+ return { ok: false, reason: "UNKNOWN_VENDOR", vendor };
320
+ }
321
+ if (fallbackMode !== "paste") {
322
+ const partition = _partitionNameFor(vendor);
323
+ try {
324
+ const session = sessionFactory(partition);
325
+ if (session && typeof session.clearStorageData === "function") {
326
+ await session.clearStorageData({ storages: ["cookies"] });
327
+ }
328
+ } catch (err) {
329
+ logger.warn(`rotateLoginPartition: clearStorageData failed for ${vendor}`, err);
330
+ // Fall through — openVendorLogin will still surface fresh state.
331
+ }
332
+ }
333
+ return openVendorLogin({ vendor });
334
+ }
335
+
336
+ /**
337
+ * Startup-time housekeeping.
338
+ *
339
+ * Walks known partitions and clears any whose vendor is no longer in
340
+ * accounts.json. Prevents Trap T8 (wizard crashed mid-login → partition
341
+ * cookies linger forever).
342
+ *
343
+ * Electron has no public "list partitions" API; the caller supplies the
344
+ * list via `partitions` (typically derived from disk scan). Tests pass a
345
+ * synthetic array.
346
+ */
347
+ async function cleanupOrphanPartitions({ partitions = [] } = {}) {
348
+ const registered = new Set();
349
+ try {
350
+ const list = await accountsStore.list();
351
+ for (const entry of list || []) {
352
+ if (entry && entry.vendor) registered.add(entry.vendor);
353
+ }
354
+ } catch (err) {
355
+ logger.warn("cleanupOrphanPartitions: accountsStore.list failed", err);
356
+ // Be conservative — without accounts list we cannot safely decide,
357
+ // so skip the sweep.
358
+ return { ok: false, reason: "ACCOUNTS_LIST_FAILED", cleared: [] };
359
+ }
360
+
361
+ const cleared = [];
362
+ const known = new Set(knownVendors);
363
+ for (const partName of partitions) {
364
+ if (!_isAichatPartition(partName)) continue;
365
+ const vendor = _vendorFromPartition(partName);
366
+ if (!known.has(vendor)) continue; // Unknown vendor → not ours, leave it.
367
+ if (registered.has(vendor)) continue; // User has it active, keep.
368
+ try {
369
+ const session = sessionFactory(partName);
370
+ if (session && typeof session.clearStorageData === "function") {
371
+ await session.clearStorageData({ storages: ["cookies"] });
372
+ cleared.push(vendor);
373
+ }
374
+ } catch (err) {
375
+ logger.warn(`cleanupOrphanPartitions: clear failed ${partName}`, err);
376
+ }
377
+ }
378
+ return { ok: true, cleared };
379
+ }
380
+
381
+ async function _existingRegisteredAt(vendor) {
382
+ try {
383
+ const e = await accountsStore.get(vendor);
384
+ return e && e.registeredAt ? Number(e.registeredAt) : null;
385
+ } catch (_err) {
386
+ return null;
387
+ }
388
+ }
389
+
390
+ return {
391
+ openVendorLogin,
392
+ probeCookies,
393
+ registerVendor,
394
+ rotateLoginPartition,
395
+ cleanupOrphanPartitions,
396
+ // exported for tests / debug
397
+ _internal: { _partitionNameFor, _flattenJar, _projectCookies, _isAichatPartition },
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Reduce any classifier-accepted shape (object / Cookie[] / "k=v;" string)
403
+ * to a plain `{ name: value }` jar for persistence. Mirrors
404
+ * cookie-capture-spec _normalizeCookieJar but lives here so we don't reach
405
+ * into the adapter package's `_internal`.
406
+ */
407
+ function _flattenJar(input) {
408
+ if (!input) return {};
409
+ if (Array.isArray(input)) {
410
+ const out = {};
411
+ for (const c of input) {
412
+ if (c && typeof c.name === "string" && typeof c.value === "string") {
413
+ out[c.name] = c.value;
414
+ }
415
+ }
416
+ return out;
417
+ }
418
+ if (typeof input === "string") {
419
+ const out = {};
420
+ for (const pairRaw of input.split(/;\s*/)) {
421
+ const pair = pairRaw.trim();
422
+ if (!pair) continue;
423
+ const idx = pair.indexOf("=");
424
+ if (idx <= 0) continue;
425
+ out[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
426
+ }
427
+ return out;
428
+ }
429
+ if (typeof input === "object") {
430
+ const out = {};
431
+ for (const [k, v] of Object.entries(input)) {
432
+ if (typeof v === "string") out[k] = v;
433
+ }
434
+ return out;
435
+ }
436
+ return {};
437
+ }
438
+
439
+ /**
440
+ * Return a slimmed-down cookie projection suitable for shipping back to the
441
+ * renderer (no sensitive values for optional cookies — only the names).
442
+ *
443
+ * Required cookies' values are returned full-fidelity because the renderer
444
+ * needs them to drive Step 3 (registerVendor); the renderer is trusted with
445
+ * the same DOM origin as the BrowserView already saw them.
446
+ */
447
+ function _projectCookies(classified, raw, spec) {
448
+ const flat = _flattenJar(raw);
449
+ const out = {};
450
+ for (const name of classified.foundRequired || []) {
451
+ out[name] = flat[name];
452
+ }
453
+ for (const name of classified.foundOptional || []) {
454
+ out[name] = flat[name];
455
+ }
456
+ // Belt-and-braces: limit to spec.required + spec.optional so unrelated
457
+ // cookies leaked from the paste path don't escape the wizard surface.
458
+ const allowed = new Set([
459
+ ...(spec.requiredCookies || []),
460
+ ...(spec.optionalCookies || []),
461
+ ]);
462
+ const filtered = {};
463
+ for (const [k, v] of Object.entries(out)) {
464
+ if (allowed.has(k)) filtered[k] = v;
465
+ }
466
+ return filtered;
467
+ }
468
+
469
+ module.exports = {
470
+ createAIChatWizardController,
471
+ PARTITION_PREFIX,
472
+ _internal: { _partitionNameFor, _isAichatPartition, _vendorFromPartition, _flattenJar },
473
+ };