@lobu/cli 6.1.1 → 7.0.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.
Files changed (100) hide show
  1. package/dist/commands/_lib/apply/apply-cmd.d.ts +36 -0
  2. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  3. package/dist/commands/_lib/apply/apply-cmd.js +548 -40
  4. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  5. package/dist/commands/_lib/apply/client.d.ts +179 -0
  6. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  7. package/dist/commands/_lib/apply/client.js +308 -28
  8. package/dist/commands/_lib/apply/client.js.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.d.ts +134 -3
  10. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.js +700 -86
  12. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  13. package/dist/commands/_lib/apply/diff.d.ts +61 -3
  14. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  15. package/dist/commands/_lib/apply/diff.js +382 -92
  16. package/dist/commands/_lib/apply/diff.js.map +1 -1
  17. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  18. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.js +16 -0
  20. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  21. package/dist/commands/_lib/apply/render.d.ts +9 -0
  22. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  23. package/dist/commands/_lib/apply/render.js +80 -3
  24. package/dist/commands/_lib/apply/render.js.map +1 -1
  25. package/dist/commands/chat.d.ts.map +1 -1
  26. package/dist/commands/chat.js +9 -2
  27. package/dist/commands/chat.js.map +1 -1
  28. package/dist/commands/dev.d.ts +8 -0
  29. package/dist/commands/dev.d.ts.map +1 -1
  30. package/dist/commands/dev.js +118 -5
  31. package/dist/commands/dev.js.map +1 -1
  32. package/dist/commands/eval.d.ts.map +1 -1
  33. package/dist/commands/eval.js +16 -5
  34. package/dist/commands/eval.js.map +1 -1
  35. package/dist/commands/init.d.ts +2 -0
  36. package/dist/commands/init.d.ts.map +1 -1
  37. package/dist/commands/init.js +24 -0
  38. package/dist/commands/init.js.map +1 -1
  39. package/dist/commands/memory/_lib/schema.d.ts +28 -1
  40. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  41. package/dist/commands/memory/_lib/schema.js +120 -4
  42. package/dist/commands/memory/_lib/schema.js.map +1 -1
  43. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  44. package/dist/commands/memory/_lib/seed-cmd.js +41 -18
  45. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  46. package/dist/commands/org.d.ts +4 -0
  47. package/dist/commands/org.d.ts.map +1 -1
  48. package/dist/commands/org.js +10 -0
  49. package/dist/commands/org.js.map +1 -1
  50. package/dist/commands/token.d.ts +9 -0
  51. package/dist/commands/token.d.ts.map +1 -1
  52. package/dist/commands/token.js +54 -0
  53. package/dist/commands/token.js.map +1 -1
  54. package/dist/connectors/README.md +2 -2
  55. package/dist/connectors/apple_health.ts +138 -0
  56. package/dist/connectors/apple_screen_time.ts +82 -0
  57. package/dist/connectors/browser-scraper-utils.ts +35 -3
  58. package/dist/connectors/capterra.ts +5 -1
  59. package/dist/connectors/g2.ts +5 -1
  60. package/dist/connectors/github.ts +15 -38
  61. package/dist/connectors/glassdoor.ts +5 -1
  62. package/dist/connectors/google_calendar.ts +14 -4
  63. package/dist/connectors/google_gmail.ts +6 -3
  64. package/dist/connectors/google_play.ts +10 -3
  65. package/dist/connectors/index.ts +5 -0
  66. package/dist/connectors/linkedin.ts +32 -9
  67. package/dist/connectors/local_directory.ts +91 -0
  68. package/dist/connectors/revolut.ts +572 -0
  69. package/dist/connectors/trustpilot.ts +5 -1
  70. package/dist/connectors/website.ts +1 -1
  71. package/dist/connectors/whatsapp.ts +9 -1
  72. package/dist/connectors/whatsapp_local.ts +125 -0
  73. package/dist/connectors/x.ts +17 -7
  74. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  75. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  76. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  77. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  78. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  79. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  80. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  81. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  82. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  83. package/dist/eval/types.d.ts +2 -0
  84. package/dist/eval/types.d.ts.map +1 -1
  85. package/dist/index.d.ts +11 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +68 -114
  88. package/dist/index.js.map +1 -1
  89. package/dist/internal/gateway-url.d.ts +14 -0
  90. package/dist/internal/gateway-url.d.ts.map +1 -1
  91. package/dist/internal/gateway-url.js +19 -0
  92. package/dist/internal/gateway-url.js.map +1 -1
  93. package/dist/internal/index.d.ts +1 -1
  94. package/dist/internal/index.d.ts.map +1 -1
  95. package/dist/internal/index.js +1 -1
  96. package/dist/internal/index.js.map +1 -1
  97. package/dist/server.bundle.mjs +32494 -30475
  98. package/dist/start-local.bundle.mjs +10840 -7912
  99. package/dist/templates/TESTING.md.tmpl +9 -9
  100. package/package.json +6 -6
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Revolut Connector (V1 runtime)
3
+ *
4
+ * Revolut has no public personal-banking API, so this connector drives the
5
+ * Revolut web app and captures the JSON it fetches from
6
+ * `app.revolut.com/api/retail/user/current/transactions/last?...` while
7
+ * paginating the transaction list (the `to=<ms>` param walks back in time).
8
+ *
9
+ * Auth: CDP only. Revolut's `app.revolut.com` access token (`credentials`
10
+ * cookie) is bound to the browser that minted it (a per-request `x-device-id`
11
+ * header + Cloudflare/TLS fingerprint), so exported cookies replayed in a fresh
12
+ * headless browser get a 401 on `/api/retail/...` and bounce to
13
+ * `sso.revolut.com/passcode`. The connector therefore connects over CDP to a
14
+ * Chrome that already holds the live session — see the auth-schema notes.
15
+ *
16
+ * The emitted event shape matches the original file-import Revolut connector
17
+ * (`semantic_type: "transaction"`, metadata `{ date, description, amount,
18
+ * direction, balance, currency }`) so historical imports stay uniform.
19
+ */
20
+
21
+ import {
22
+ type ActionContext,
23
+ type ActionResult,
24
+ browserNetworkSync,
25
+ type ConnectorDefinition,
26
+ ConnectorRuntime,
27
+ type EventEnvelope,
28
+ type SyncContext,
29
+ type SyncResult,
30
+ } from "@lobu/connector-sdk";
31
+ import {
32
+ getBrowserCdpUrl,
33
+ getBrowserCookies,
34
+ getBrowserUserDataDir,
35
+ validateCookieNotExpired,
36
+ } from "./browser-scraper-utils";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export interface RevolutCheckpoint {
43
+ last_transaction_id?: string;
44
+ last_timestamp?: string;
45
+ }
46
+
47
+ export interface RevolutTransaction {
48
+ id: string;
49
+ description: string;
50
+ /** Absolute value in major currency units (e.g. 20.0 for £20.00). */
51
+ amount: number;
52
+ direction: "in" | "out";
53
+ /** Account balance after the transaction, in major units (may be absent). */
54
+ balance?: number;
55
+ currency: string;
56
+ /** ISO calendar date (YYYY-MM-DD) the transaction settled / started. */
57
+ date: string;
58
+ /** Full settlement timestamp. */
59
+ occurredAt: Date;
60
+ /** Revolut transaction type, e.g. CARD_PAYMENT, TRANSFER, TOPUP. */
61
+ type?: string;
62
+ /** Revolut state, e.g. COMPLETED, PENDING. */
63
+ state?: string;
64
+ }
65
+
66
+ // Currencies with zero minor units — Revolut returns these amounts unscaled.
67
+ const ZERO_DECIMAL_CURRENCIES = new Set([
68
+ "JPY",
69
+ "KRW",
70
+ "VND",
71
+ "CLP",
72
+ "ISK",
73
+ "XAF",
74
+ "XOF",
75
+ "BIF",
76
+ "DJF",
77
+ "GNF",
78
+ "KMF",
79
+ "MGA",
80
+ "PYG",
81
+ "RWF",
82
+ "UGX",
83
+ "VUV",
84
+ "XPF",
85
+ ]);
86
+
87
+ // Transaction states worth keeping. DECLINED/FAILED/REVERTED never settled.
88
+ const KEPT_STATES = new Set(["COMPLETED", "PENDING", "CONFIRMED", "SETTLED"]);
89
+
90
+ // Fields the web app uses for the transaction timestamp, best first.
91
+ const TIMESTAMP_FIELDS = [
92
+ "completedDate",
93
+ "completed_date",
94
+ "completedAt",
95
+ "bookingDate",
96
+ "booking_date",
97
+ "valueDate",
98
+ "value_date",
99
+ "startedDate",
100
+ "started_date",
101
+ "createdDate",
102
+ "created_date",
103
+ "createdAt",
104
+ "date",
105
+ ];
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Parsing
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function minorUnitsToMajor(raw: number, currency: string): number {
112
+ const exponent = ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2;
113
+ return raw / 10 ** exponent;
114
+ }
115
+
116
+ function coerceTimestamp(value: unknown): Date | null {
117
+ if (typeof value === "number" && Number.isFinite(value)) {
118
+ // Revolut uses ms-epoch; treat 10-digit values as seconds defensively.
119
+ const ms = value < 1e12 ? value * 1000 : value;
120
+ const d = new Date(ms);
121
+ return Number.isNaN(d.getTime()) ? null : d;
122
+ }
123
+ if (typeof value === "string" && value.trim()) {
124
+ const d = new Date(value);
125
+ return Number.isNaN(d.getTime()) ? null : d;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ function extractAmountAndCurrency(
131
+ record: Record<string, unknown>,
132
+ ): { amount: number; currency: string } | null {
133
+ // Flat shape: { amount: -2000, currency: "GBP" }
134
+ if (
135
+ typeof record.amount === "number" &&
136
+ typeof record.currency === "string"
137
+ ) {
138
+ return { amount: record.amount, currency: record.currency };
139
+ }
140
+ // Nested money shape: { amount: { value: -2000, currency: "GBP" } } or
141
+ // { amount: { amount: -20.0, currency: "GBP" } }.
142
+ const amt = record.amount;
143
+ if (amt && typeof amt === "object") {
144
+ const obj = amt as Record<string, unknown>;
145
+ const value =
146
+ typeof obj.value === "number"
147
+ ? obj.value
148
+ : typeof obj.amount === "number"
149
+ ? obj.amount
150
+ : null;
151
+ const currency = typeof obj.currency === "string" ? obj.currency : null;
152
+ if (value !== null && currency) return { amount: value, currency };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function nameOf(node: unknown): string | null {
158
+ if (!node || typeof node !== "object") return null;
159
+ const obj = node as Record<string, unknown>;
160
+ for (const key of ["name", "legalName", "username", "displayName"]) {
161
+ const v = obj[key];
162
+ if (typeof v === "string" && v.trim()) return v.trim();
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function describeTransaction(record: Record<string, unknown>): string {
168
+ // Card payments carry a clean `merchant.name` ("OpenAI") alongside a noisy
169
+ // raw descriptor ("Openai *chatgpt Subscr") — prefer the merchant name, which
170
+ // is also what the Revolut UI shows and what the legacy import used. Transfers
171
+ // and top-ups have no merchant, so fall back to the human description.
172
+ const merchant = nameOf(record.merchant);
173
+ if (merchant) return merchant;
174
+ for (const key of [
175
+ "description",
176
+ "localisedDescription",
177
+ "reference",
178
+ "comment",
179
+ ]) {
180
+ const v = record[key];
181
+ if (typeof v === "string" && v.trim()) return v.trim();
182
+ }
183
+ for (const key of [
184
+ "counterpart",
185
+ "counterparty",
186
+ "recipient",
187
+ "sender",
188
+ "beneficiary",
189
+ ]) {
190
+ const v = nameOf(record[key]);
191
+ if (v) return v;
192
+ }
193
+ const type = record.type;
194
+ return typeof type === "string" && type.trim()
195
+ ? type.replace(/_/g, " ")
196
+ : "Transaction";
197
+ }
198
+
199
+ function extractBalance(
200
+ record: Record<string, unknown>,
201
+ currency: string,
202
+ ): number | undefined {
203
+ const raw =
204
+ typeof record.balance === "number"
205
+ ? record.balance
206
+ : record.balance && typeof record.balance === "object"
207
+ ? ((record.balance as Record<string, unknown>).value ??
208
+ (record.balance as Record<string, unknown>).amount)
209
+ : undefined;
210
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
211
+ return Number.isInteger(raw) ? minorUnitsToMajor(raw, currency) : raw;
212
+ }
213
+
214
+ function parseTransactionRecord(
215
+ record: Record<string, unknown>,
216
+ ): RevolutTransaction | null {
217
+ const money = extractAmountAndCurrency(record);
218
+ if (!money) return null;
219
+
220
+ const id = record.id ?? record.legId ?? record.transactionId ?? record.code;
221
+ if (typeof id !== "string" && typeof id !== "number") return null;
222
+
223
+ let occurredAt: Date | null = null;
224
+ for (const field of TIMESTAMP_FIELDS) {
225
+ occurredAt = coerceTimestamp(record[field]);
226
+ if (occurredAt) break;
227
+ }
228
+ if (!occurredAt) return null;
229
+
230
+ const state =
231
+ typeof record.state === "string" ? record.state.toUpperCase() : undefined;
232
+ if (state && !KEPT_STATES.has(state)) return null;
233
+
234
+ const currency = money.currency.toUpperCase();
235
+ // Revolut's retail API returns integer minor units; some endpoints return a
236
+ // decimal already in major units — fractional values mean "already major".
237
+ const value = Number.isInteger(money.amount)
238
+ ? minorUnitsToMajor(money.amount, currency)
239
+ : money.amount;
240
+
241
+ return {
242
+ id: String(id),
243
+ description: describeTransaction(record),
244
+ amount: Math.abs(value),
245
+ direction: value < 0 ? "out" : "in",
246
+ balance: extractBalance(record, currency),
247
+ currency,
248
+ date: occurredAt.toISOString().slice(0, 10),
249
+ occurredAt,
250
+ type: typeof record.type === "string" ? record.type : undefined,
251
+ state,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Walk an arbitrary JSON value and pull out anything that looks like a Revolut
257
+ * transaction. The web app's responses vary (bare arrays, `{ items: [...] }`,
258
+ * paginated envelopes, single-transaction detail endpoints), so we recurse
259
+ * rather than assume one shape. A record only counts as a transaction if it
260
+ * carries both an amount/currency and a timestamp, which keeps merchant/budget
261
+ * objects out.
262
+ */
263
+ export function extractTransactionsFromResponse(
264
+ json: unknown,
265
+ ): RevolutTransaction[] {
266
+ const found: RevolutTransaction[] = [];
267
+ const seen = new Set<object>();
268
+
269
+ const visit = (node: unknown): void => {
270
+ if (!node || typeof node !== "object") return;
271
+ if (seen.has(node as object)) return;
272
+ seen.add(node as object);
273
+
274
+ if (Array.isArray(node)) {
275
+ for (const item of node) {
276
+ const parsed =
277
+ item && typeof item === "object" && !Array.isArray(item)
278
+ ? parseTransactionRecord(item as Record<string, unknown>)
279
+ : null;
280
+ if (parsed) found.push(parsed);
281
+ else visit(item);
282
+ }
283
+ return;
284
+ }
285
+
286
+ const record = node as Record<string, unknown>;
287
+ const asTxn = parseTransactionRecord(record);
288
+ if (asTxn) {
289
+ found.push(asTxn);
290
+ return;
291
+ }
292
+ for (const value of Object.values(record)) visit(value);
293
+ };
294
+
295
+ visit(json);
296
+ return found;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Checkpoint filtering
301
+ // ---------------------------------------------------------------------------
302
+
303
+ export function filterTransactionsSinceCheckpoint(
304
+ transactions: RevolutTransaction[],
305
+ checkpoint: RevolutCheckpoint | null | undefined,
306
+ ): RevolutTransaction[] {
307
+ const lastTs = checkpoint?.last_timestamp
308
+ ? new Date(checkpoint.last_timestamp).getTime()
309
+ : null;
310
+ const lastId = checkpoint?.last_transaction_id;
311
+ const seen = new Set<string>();
312
+ return transactions.filter((t) => {
313
+ if (seen.has(t.id)) return false;
314
+ seen.add(t.id);
315
+ if (lastId && t.id === lastId) return false;
316
+ if (
317
+ lastTs !== null &&
318
+ Number.isFinite(lastTs) &&
319
+ t.occurredAt.getTime() <= lastTs
320
+ ) {
321
+ return false;
322
+ }
323
+ return true;
324
+ });
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Event mapping (matches the original file-import Revolut connector)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function currencySymbol(currency: string): string {
332
+ switch (currency.toUpperCase()) {
333
+ case "GBP":
334
+ return "£";
335
+ case "USD":
336
+ return "$";
337
+ case "EUR":
338
+ return "€";
339
+ default:
340
+ return `${currency} `;
341
+ }
342
+ }
343
+
344
+ export function transactionToEvent(t: RevolutTransaction): EventEnvelope {
345
+ const sign = t.direction === "out" ? "-" : "+";
346
+ return {
347
+ origin_id: `revolut-${t.id}`,
348
+ payload_text: `${t.description} ${sign}${currencySymbol(t.currency)}${t.amount} on ${t.date}`,
349
+ occurred_at: t.occurredAt,
350
+ semantic_type: "transaction",
351
+ metadata: {
352
+ date: t.date,
353
+ description: t.description,
354
+ amount: t.amount,
355
+ direction: t.direction,
356
+ ...(t.balance !== undefined ? { balance: t.balance } : {}),
357
+ currency: t.currency,
358
+ ...(t.type ? { transaction_type: t.type } : {}),
359
+ ...(t.state ? { state: t.state } : {}),
360
+ },
361
+ };
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Sync
366
+ // ---------------------------------------------------------------------------
367
+
368
+ // The Revolut web app fetches account history from
369
+ // `app.revolut.com/api/retail/user/current/transactions/last?count=N&to=<ms>&internalPocketId=<uuid>`
370
+ // (the `to` param walks back in time as you scroll). These patterns also cover
371
+ // plausible alternates without catching unrelated `/api/retail/...` calls.
372
+ const TRANSACTION_API_PATTERNS: RegExp[] = [
373
+ /\/api\/retail\/.*transactions?(?:\/|\b)/i,
374
+ /\/api\/.*\/transactions(?:\b|\?|\/|$)/i,
375
+ /transactions?[./](?:last|recent|history|search)/i,
376
+ ];
377
+
378
+ const REVOLUT_AUTH_DOMAINS = ["app.revolut.com", ".revolut.com"];
379
+ // `/transactions` shows the full, infinitely-scrollable history for the default
380
+ // account; `/home` only shows the latest ~10. Per-pocket history lives at
381
+ // `/transactions?accountType=pocket&walletId=<uuid>&pocketId=<uuid>` — point a
382
+ // second feed's `start_url` there to sync a non-default currency pocket.
383
+ const DEFAULT_START_URL = "https://app.revolut.com/transactions";
384
+
385
+ function isLoggedIn(url: string): boolean {
386
+ let host: string;
387
+ try {
388
+ host = new URL(url).hostname;
389
+ } catch {
390
+ return false;
391
+ }
392
+ // An unauthenticated session is bounced to sso.revolut.com/passcode.
393
+ if (host !== "app.revolut.com") return false;
394
+ return !/\/(?:start|signin|login|verify|onboarding)\b/i.test(url);
395
+ }
396
+
397
+ const configSchema = {
398
+ type: "object",
399
+ properties: {
400
+ start_url: {
401
+ type: "string",
402
+ default: DEFAULT_START_URL,
403
+ description:
404
+ "Revolut web app URL to open. Defaults to the full transactions view for the primary account; set it to a per-pocket /transactions?...pocketId=<uuid> URL to sync a different currency pocket.",
405
+ },
406
+ currency_filter: {
407
+ type: "string",
408
+ description:
409
+ 'If set, keep only transactions in this ISO 4217 currency (e.g. "GBP").',
410
+ },
411
+ max_scrolls: {
412
+ type: "integer",
413
+ minimum: 1,
414
+ maximum: 100,
415
+ default: 20,
416
+ description:
417
+ "Maximum scroll iterations to paginate older transactions (default: 20).",
418
+ },
419
+ },
420
+ };
421
+
422
+ const transactionMetadataSchema = {
423
+ type: "object",
424
+ properties: {
425
+ date: { type: "string", format: "date" },
426
+ description: { type: "string" },
427
+ amount: { type: "number" },
428
+ direction: { type: "string", enum: ["in", "out"] },
429
+ balance: { type: "number" },
430
+ currency: { type: "string" },
431
+ transaction_type: { type: "string" },
432
+ state: { type: "string" },
433
+ },
434
+ };
435
+
436
+ export default class RevolutConnector extends ConnectorRuntime {
437
+ readonly definition: ConnectorDefinition = {
438
+ key: "revolut",
439
+ name: "Revolut",
440
+ description:
441
+ "Syncs Revolut account transactions from the Revolut web app (no public API). Requires a Chrome instance, with remote debugging enabled, that stays logged in to app.revolut.com.",
442
+ version: "2.0.0",
443
+ faviconDomain: "app.revolut.com",
444
+ authSchema: {
445
+ methods: [
446
+ // CDP only — *not* `cli` cookie capture. Revolut's `app.revolut.com`
447
+ // access token (the `credentials` cookie) is bound to the browser that
448
+ // minted it (device-id header + Cloudflare/TLS fingerprint), so cookies
449
+ // exported from Chrome and replayed in a fresh headless browser get a 401
450
+ // on /api/retail/... and bounce to sso.revolut.com/passcode. The only
451
+ // path that authenticates is connecting over CDP to the *same* Chrome
452
+ // that holds the live session — keep one logged in and reachable.
453
+ {
454
+ type: "browser",
455
+ capture: "cdp",
456
+ defaultCdpUrl: "http://127.0.0.1:9222",
457
+ requiredDomains: REVOLUT_AUTH_DOMAINS,
458
+ description:
459
+ "Connect over CDP to a Chrome logged in to app.revolut.com: lobu memory browser-auth --connector revolut --launch-cdp (log in there, re-enter the passcode whenever Revolut expires the session).",
460
+ },
461
+ ],
462
+ },
463
+ feeds: {
464
+ transactions: {
465
+ key: "transactions",
466
+ name: "Transactions",
467
+ description: "Account transactions pulled from the Revolut web app.",
468
+ configSchema,
469
+ eventKinds: {
470
+ transaction: {
471
+ description: "A bank transaction",
472
+ metadataSchema: transactionMetadataSchema,
473
+ },
474
+ },
475
+ },
476
+ },
477
+ optionsSchema: configSchema,
478
+ };
479
+
480
+ async sync(ctx: SyncContext): Promise<SyncResult> {
481
+ const config = (ctx.config ?? {}) as Record<string, unknown>;
482
+ const checkpoint = (ctx.checkpoint ?? {}) as RevolutCheckpoint;
483
+
484
+ const startUrl =
485
+ typeof config.start_url === "string" && config.start_url.trim()
486
+ ? config.start_url.trim()
487
+ : DEFAULT_START_URL;
488
+ const currencyFilter =
489
+ typeof config.currency_filter === "string" &&
490
+ config.currency_filter.trim()
491
+ ? config.currency_filter.trim().toUpperCase()
492
+ : null;
493
+ const maxScrolls = Math.max(
494
+ 1,
495
+ Math.min(100, Number(config.max_scrolls ?? 20) || 20),
496
+ );
497
+
498
+ // Primary auth is CDP (connect to the Chrome that holds the live Revolut
499
+ // session). Stored cookies are only a best-effort fallback for the
500
+ // Playwright path — see the auth-schema comment on why they rarely suffice
501
+ // for Revolut. Don't fail the sync just because there are none.
502
+ const userDataDir = getBrowserUserDataDir(ctx.sessionState);
503
+ const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? "auto";
504
+ let cookies: ReturnType<typeof getBrowserCookies> = [];
505
+ if (!userDataDir) {
506
+ try {
507
+ cookies = getBrowserCookies(
508
+ ctx.checkpoint as Record<string, unknown> | null,
509
+ ctx.sessionState,
510
+ "revolut",
511
+ );
512
+ validateCookieNotExpired(cookies, "credentials", "revolut");
513
+ } catch {
514
+ cookies = [];
515
+ }
516
+ }
517
+
518
+ const result = await browserNetworkSync<RevolutTransaction>({
519
+ config: {
520
+ interceptPatterns: TRANSACTION_API_PATTERNS,
521
+ authDomains: REVOLUT_AUTH_DOMAINS,
522
+ maxScrolls,
523
+ scrollDelayMs: 2500,
524
+ responseTimeoutMs: 8000,
525
+ navigationTimeoutMs: 20000,
526
+ stealth: true,
527
+ },
528
+ url: startUrl,
529
+ cdpUrl,
530
+ cookies,
531
+ userDataDir,
532
+ parseResponse: (_url, json) => extractTransactionsFromResponse(json),
533
+ checkAuth: async (page) => isLoggedIn(page.url()),
534
+ });
535
+
536
+ let transactions = filterTransactionsSinceCheckpoint(
537
+ result.items,
538
+ checkpoint,
539
+ );
540
+ if (currencyFilter) {
541
+ transactions = transactions.filter((t) => t.currency === currencyFilter);
542
+ }
543
+ transactions.sort(
544
+ (a, b) => b.occurredAt.getTime() - a.occurredAt.getTime(),
545
+ );
546
+
547
+ const events: EventEnvelope[] = transactions.map(transactionToEvent);
548
+ const newest = transactions[0];
549
+ const newCheckpoint: RevolutCheckpoint = newest
550
+ ? {
551
+ last_transaction_id: newest.id,
552
+ last_timestamp: newest.occurredAt.toISOString(),
553
+ }
554
+ : checkpoint;
555
+
556
+ return {
557
+ events,
558
+ checkpoint: newCheckpoint as unknown as Record<string, unknown>,
559
+ auth_update: { cookies: result.cookies },
560
+ metadata: {
561
+ items_found: events.length,
562
+ api_calls: result.apiCallCount,
563
+ backend: result.backend,
564
+ ...(currencyFilter ? { currency_filter: currencyFilter } : {}),
565
+ },
566
+ };
567
+ }
568
+
569
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
570
+ return { success: false, error: "Actions not supported" };
571
+ }
572
+ }
@@ -15,6 +15,8 @@ import {
15
15
  type SyncResult,
16
16
  } from '@lobu/connector-sdk';
17
17
  import {
18
+ getBrowserCdpUrl,
19
+ getBrowserUserDataDir,
18
20
  handleCookieConsent,
19
21
  openStealthBrowser,
20
22
  validateUrlDomain,
@@ -99,7 +101,9 @@ export default class TrustpilotConnector extends ConnectorRuntime {
99
101
  const baseUrl = businessUrl || `https://www.trustpilot.com/review/${businessName}`;
100
102
  validateUrlDomain(baseUrl, 'trustpilot.com');
101
103
 
102
- const session = await openStealthBrowser({ cdpUrl: 'auto' });
104
+ const userDataDir = getBrowserUserDataDir(ctx.sessionState);
105
+ const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto';
106
+ const session = await openStealthBrowser({ cdpUrl, userDataDir });
103
107
 
104
108
  return withBrowserErrorCapture(session, 'trustpilot-sync', async (page) => {
105
109
  await page.goto(baseUrl, {
@@ -238,7 +238,7 @@ export default class WebsiteConnector extends ConnectorRuntime {
238
238
  urls = urls.slice(0, maxPages);
239
239
 
240
240
  // Launch browser
241
- const { browser } = await launchBrowser({} as any, { stealth: false });
241
+ const { browser } = await launchBrowser({ stealth: false });
242
242
  const events: EventEnvelope[] = [];
243
243
  const newHashes: Record<string, string> = {};
244
244
 
@@ -164,6 +164,7 @@ export default class WhatsAppConnector extends ConnectorRuntime {
164
164
  metadataSchema: {
165
165
  type: 'object',
166
166
  properties: {
167
+ source: { type: 'string', const: 'whatsapp' },
167
168
  chat_jid: { type: 'string' },
168
169
  is_group: { type: 'boolean' },
169
170
  from_me: { type: 'boolean' },
@@ -178,7 +179,7 @@ export default class WhatsAppConnector extends ConnectorRuntime {
178
179
  },
179
180
  entityLinks: [
180
181
  {
181
- entityType: '$member',
182
+ entityType: 'person',
182
183
  autoCreate: true,
183
184
  titlePath: 'metadata.push_name',
184
185
  identities: [
@@ -1001,6 +1002,13 @@ export function toEvent(
1001
1002
  occurred_at: occurredAt,
1002
1003
  origin_parent_id: chatJid,
1003
1004
  metadata: {
1005
+ // Mirror the bridge's `source` field so consumers can tell which
1006
+ // transport delivered an event when the same message arrives via both
1007
+ // (QR-paired socket and the local Mac archive). Origin id alignment
1008
+ // (both connectors emit the bare WhatsApp stanza id) makes the gateway
1009
+ // dedupe on insert; `source` records which side produced the row that
1010
+ // survived.
1011
+ source: 'whatsapp',
1004
1012
  chat_jid: chatJid,
1005
1013
  is_group: isGroup,
1006
1014
  from_me: fromMe,