@lobu/cli 6.1.1 → 7.1.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 (177) 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 +696 -40
  4. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  5. package/dist/commands/_lib/apply/client.d.ts +285 -0
  6. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  7. package/dist/commands/_lib/apply/client.js +469 -28
  8. package/dist/commands/_lib/apply/client.js.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.d.ts +187 -3
  10. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.js +879 -88
  12. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  13. package/dist/commands/_lib/apply/diff.d.ts +72 -3
  14. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  15. package/dist/commands/_lib/apply/diff.js +473 -84
  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/_lib/connector-loader.d.ts +3 -0
  26. package/dist/commands/_lib/connector-loader.d.ts.map +1 -0
  27. package/dist/commands/_lib/connector-loader.js +129 -0
  28. package/dist/commands/_lib/connector-loader.js.map +1 -0
  29. package/dist/commands/_lib/connector-run-cmd.d.ts +35 -0
  30. package/dist/commands/_lib/connector-run-cmd.d.ts.map +1 -0
  31. package/dist/commands/_lib/connector-run-cmd.js +351 -0
  32. package/dist/commands/_lib/connector-run-cmd.js.map +1 -0
  33. package/dist/commands/_lib/export/export-cmd.d.ts +35 -0
  34. package/dist/commands/_lib/export/export-cmd.d.ts.map +1 -0
  35. package/dist/commands/_lib/export/export-cmd.js +329 -0
  36. package/dist/commands/_lib/export/export-cmd.js.map +1 -0
  37. package/dist/commands/agent.d.ts.map +1 -1
  38. package/dist/commands/agent.js +11 -14
  39. package/dist/commands/agent.js.map +1 -1
  40. package/dist/commands/chat.d.ts.map +1 -1
  41. package/dist/commands/chat.js +28 -7
  42. package/dist/commands/chat.js.map +1 -1
  43. package/dist/commands/connector.d.ts +3 -0
  44. package/dist/commands/connector.d.ts.map +1 -0
  45. package/dist/commands/connector.js +5 -0
  46. package/dist/commands/connector.js.map +1 -0
  47. package/dist/commands/dev.d.ts +23 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +273 -8
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/doctor.d.ts.map +1 -1
  52. package/dist/commands/doctor.js +2 -3
  53. package/dist/commands/doctor.js.map +1 -1
  54. package/dist/commands/eval.d.ts.map +1 -1
  55. package/dist/commands/eval.js +28 -18
  56. package/dist/commands/eval.js.map +1 -1
  57. package/dist/commands/init.d.ts +2 -0
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +29 -1
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/login.d.ts.map +1 -1
  62. package/dist/commands/login.js +22 -16
  63. package/dist/commands/login.js.map +1 -1
  64. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  65. package/dist/commands/memory/_lib/browser-auth-cmd.js +15 -144
  66. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  67. package/dist/commands/memory/_lib/schema.d.ts +28 -1
  68. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  69. package/dist/commands/memory/_lib/schema.js +120 -4
  70. package/dist/commands/memory/_lib/schema.js.map +1 -1
  71. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  72. package/dist/commands/memory/_lib/seed-cmd.js +41 -18
  73. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  74. package/dist/commands/org.d.ts +4 -0
  75. package/dist/commands/org.d.ts.map +1 -1
  76. package/dist/commands/org.js +10 -0
  77. package/dist/commands/org.js.map +1 -1
  78. package/dist/commands/token.d.ts +9 -0
  79. package/dist/commands/token.d.ts.map +1 -1
  80. package/dist/commands/token.js +54 -3
  81. package/dist/commands/token.js.map +1 -1
  82. package/dist/commands/validate.d.ts.map +1 -1
  83. package/dist/commands/validate.js +4 -13
  84. package/dist/commands/validate.js.map +1 -1
  85. package/dist/config/loader.js +2 -2
  86. package/dist/config/loader.js.map +1 -1
  87. package/dist/connectors/README.md +2 -3
  88. package/dist/connectors/apple_health.ts +138 -0
  89. package/dist/connectors/apple_photos.ts +178 -0
  90. package/dist/connectors/apple_screen_time.ts +82 -0
  91. package/dist/connectors/browser/evaluate.ts +120 -0
  92. package/dist/connectors/browser/fill_form.ts +107 -0
  93. package/dist/connectors/browser/page_text.ts +108 -0
  94. package/dist/connectors/browser-scraper-utils.ts +111 -3
  95. package/dist/connectors/capterra.ts +5 -1
  96. package/dist/connectors/chrome_tabs.ts +74 -0
  97. package/dist/connectors/g2.ts +5 -1
  98. package/dist/connectors/github.ts +16 -38
  99. package/dist/connectors/glassdoor.ts +5 -1
  100. package/dist/connectors/google_calendar.ts +28 -6
  101. package/dist/connectors/google_gmail.ts +6 -3
  102. package/dist/connectors/google_play.ts +32 -5
  103. package/dist/connectors/hackernews.ts +37 -2
  104. package/dist/connectors/index.ts +14 -1
  105. package/dist/connectors/linkedin.ts +32 -9
  106. package/dist/connectors/local_directory.ts +91 -0
  107. package/dist/connectors/reddit.ts +1 -0
  108. package/dist/connectors/revolut.ts +569 -0
  109. package/dist/connectors/rss.ts +33 -8
  110. package/dist/connectors/trustpilot.ts +36 -21
  111. package/dist/connectors/website.ts +8 -69
  112. package/dist/connectors/whatsapp.ts +21 -22
  113. package/dist/connectors/whatsapp_local.ts +125 -0
  114. package/dist/connectors/x.ts +17 -7
  115. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  116. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  117. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  118. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  119. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  120. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  121. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  122. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  123. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  124. package/dist/db/migrations/20260514130000_connection_action_modes.sql +103 -0
  125. package/dist/db/migrations/20260514160000_auth_profiles_mirror_mode.sql +32 -0
  126. package/dist/db/migrations/20260515120000_agents_per_org_pk.sql +66 -0
  127. package/dist/db/migrations/20260515150000_geo_enrichment.sql +208 -0
  128. package/dist/db/migrations/20260515160000_drop_agents_org_id_unique.sql +24 -0
  129. package/dist/db/migrations/20260515170000_auth_profiles_default_for_connector.sql +23 -0
  130. package/dist/db/migrations/20260516120000_agents_per_org_pk_swap.sql +125 -0
  131. package/dist/db/migrations/20260516200000_events_search_tsv.sql +134 -0
  132. package/dist/db/migrations/20260516200100_events_lifecycle_changes_index.sql +25 -0
  133. package/dist/db/migrations/20260517010000_drop_unused_indexes.sql +49 -0
  134. package/dist/db/migrations/20260517020000_softdelete_orphan_feeds.sql +56 -0
  135. package/dist/db/migrations/20260517030000_pat_worker_id_binding.sql +27 -0
  136. package/dist/db/migrations/20260517040000_archive_orphan_watchers.sql +30 -0
  137. package/dist/db/migrations/20260517050000_watcher_agent_id_not_null.sql +34 -0
  138. package/dist/db/migrations/20260517060000_watcher_schema_additions.sql +78 -0
  139. package/dist/db/migrations/20260517150000_goals_primitive.sql +55 -0
  140. package/dist/db/migrations/20260517160000_drop_goals_primitive.sql +45 -0
  141. package/dist/db/migrations/20260518000000_pending_interactions.sql +49 -0
  142. package/dist/db/migrations/20260518010000_runs_heartbeat_reaper_index.sql +22 -0
  143. package/dist/eval/client.d.ts.map +1 -1
  144. package/dist/eval/client.js +11 -0
  145. package/dist/eval/client.js.map +1 -1
  146. package/dist/eval/grader.js +2 -1
  147. package/dist/eval/grader.js.map +1 -1
  148. package/dist/eval/types.d.ts +2 -0
  149. package/dist/eval/types.d.ts.map +1 -1
  150. package/dist/index.d.ts +11 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +115 -114
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/context.d.ts +9 -0
  155. package/dist/internal/context.d.ts.map +1 -1
  156. package/dist/internal/context.js +41 -6
  157. package/dist/internal/context.js.map +1 -1
  158. package/dist/internal/credentials.d.ts +5 -0
  159. package/dist/internal/credentials.d.ts.map +1 -1
  160. package/dist/internal/credentials.js +75 -1
  161. package/dist/internal/credentials.js.map +1 -1
  162. package/dist/internal/gateway-url.d.ts +14 -0
  163. package/dist/internal/gateway-url.d.ts.map +1 -1
  164. package/dist/internal/gateway-url.js +19 -0
  165. package/dist/internal/gateway-url.js.map +1 -1
  166. package/dist/internal/index.d.ts +1 -1
  167. package/dist/internal/index.d.ts.map +1 -1
  168. package/dist/internal/index.js +1 -1
  169. package/dist/internal/index.js.map +1 -1
  170. package/dist/internal/local-env.d.ts.map +1 -1
  171. package/dist/internal/local-env.js +9 -2
  172. package/dist/internal/local-env.js.map +1 -1
  173. package/dist/server.bundle.mjs +42251 -36931
  174. package/dist/start-local.bundle.mjs +16437 -9882
  175. package/dist/templates/TESTING.md.tmpl +9 -9
  176. package/package.json +8 -6
  177. package/dist/connectors/google_photos.ts +0 -776
@@ -0,0 +1,569 @@
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
+ let value: number | null = null;
146
+ if (typeof obj.value === "number") value = obj.value;
147
+ else if (typeof obj.amount === "number") value = obj.amount;
148
+ const currency = typeof obj.currency === "string" ? obj.currency : null;
149
+ if (value !== null && currency) return { amount: value, currency };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function nameOf(node: unknown): string | null {
155
+ if (!node || typeof node !== "object") return null;
156
+ const obj = node as Record<string, unknown>;
157
+ for (const key of ["name", "legalName", "username", "displayName"]) {
158
+ const v = obj[key];
159
+ if (typeof v === "string" && v.trim()) return v.trim();
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function describeTransaction(record: Record<string, unknown>): string {
165
+ // Card payments carry a clean `merchant.name` ("OpenAI") alongside a noisy
166
+ // raw descriptor ("Openai *chatgpt Subscr") — prefer the merchant name, which
167
+ // is also what the Revolut UI shows and what the legacy import used. Transfers
168
+ // and top-ups have no merchant, so fall back to the human description.
169
+ const merchant = nameOf(record.merchant);
170
+ if (merchant) return merchant;
171
+ for (const key of [
172
+ "description",
173
+ "localisedDescription",
174
+ "reference",
175
+ "comment",
176
+ ]) {
177
+ const v = record[key];
178
+ if (typeof v === "string" && v.trim()) return v.trim();
179
+ }
180
+ for (const key of [
181
+ "counterpart",
182
+ "counterparty",
183
+ "recipient",
184
+ "sender",
185
+ "beneficiary",
186
+ ]) {
187
+ const v = nameOf(record[key]);
188
+ if (v) return v;
189
+ }
190
+ const type = record.type;
191
+ return typeof type === "string" && type.trim()
192
+ ? type.replace(/_/g, " ")
193
+ : "Transaction";
194
+ }
195
+
196
+ function extractBalance(
197
+ record: Record<string, unknown>,
198
+ currency: string,
199
+ ): number | undefined {
200
+ let raw: unknown;
201
+ if (typeof record.balance === "number") {
202
+ raw = record.balance;
203
+ } else if (record.balance && typeof record.balance === "object") {
204
+ const obj = record.balance as Record<string, unknown>;
205
+ raw = obj.value ?? obj.amount;
206
+ }
207
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
208
+ return Number.isInteger(raw) ? minorUnitsToMajor(raw, currency) : raw;
209
+ }
210
+
211
+ function parseTransactionRecord(
212
+ record: Record<string, unknown>,
213
+ ): RevolutTransaction | null {
214
+ const money = extractAmountAndCurrency(record);
215
+ if (!money) return null;
216
+
217
+ const id = record.id ?? record.legId ?? record.transactionId ?? record.code;
218
+ if (typeof id !== "string" && typeof id !== "number") return null;
219
+
220
+ let occurredAt: Date | null = null;
221
+ for (const field of TIMESTAMP_FIELDS) {
222
+ occurredAt = coerceTimestamp(record[field]);
223
+ if (occurredAt) break;
224
+ }
225
+ if (!occurredAt) return null;
226
+
227
+ const state =
228
+ typeof record.state === "string" ? record.state.toUpperCase() : undefined;
229
+ if (state && !KEPT_STATES.has(state)) return null;
230
+
231
+ const currency = money.currency.toUpperCase();
232
+ // Revolut's retail API returns integer minor units; some endpoints return a
233
+ // decimal already in major units — fractional values mean "already major".
234
+ const value = Number.isInteger(money.amount)
235
+ ? minorUnitsToMajor(money.amount, currency)
236
+ : money.amount;
237
+
238
+ return {
239
+ id: String(id),
240
+ description: describeTransaction(record),
241
+ amount: Math.abs(value),
242
+ direction: value < 0 ? "out" : "in",
243
+ balance: extractBalance(record, currency),
244
+ currency,
245
+ date: occurredAt.toISOString().slice(0, 10),
246
+ occurredAt,
247
+ type: typeof record.type === "string" ? record.type : undefined,
248
+ state,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Walk an arbitrary JSON value and pull out anything that looks like a Revolut
254
+ * transaction. The web app's responses vary (bare arrays, `{ items: [...] }`,
255
+ * paginated envelopes, single-transaction detail endpoints), so we recurse
256
+ * rather than assume one shape. A record only counts as a transaction if it
257
+ * carries both an amount/currency and a timestamp, which keeps merchant/budget
258
+ * objects out.
259
+ */
260
+ export function extractTransactionsFromResponse(
261
+ json: unknown,
262
+ ): RevolutTransaction[] {
263
+ const found: RevolutTransaction[] = [];
264
+ const seen = new Set<object>();
265
+
266
+ const visit = (node: unknown): void => {
267
+ if (!node || typeof node !== "object") return;
268
+ if (seen.has(node as object)) return;
269
+ seen.add(node as object);
270
+
271
+ if (Array.isArray(node)) {
272
+ for (const item of node) {
273
+ const parsed =
274
+ item && typeof item === "object" && !Array.isArray(item)
275
+ ? parseTransactionRecord(item as Record<string, unknown>)
276
+ : null;
277
+ if (parsed) found.push(parsed);
278
+ else visit(item);
279
+ }
280
+ return;
281
+ }
282
+
283
+ const record = node as Record<string, unknown>;
284
+ const asTxn = parseTransactionRecord(record);
285
+ if (asTxn) {
286
+ found.push(asTxn);
287
+ return;
288
+ }
289
+ for (const value of Object.values(record)) visit(value);
290
+ };
291
+
292
+ visit(json);
293
+ return found;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Checkpoint filtering
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export function filterTransactionsSinceCheckpoint(
301
+ transactions: RevolutTransaction[],
302
+ checkpoint: RevolutCheckpoint | null | undefined,
303
+ ): RevolutTransaction[] {
304
+ const lastTs = checkpoint?.last_timestamp
305
+ ? new Date(checkpoint.last_timestamp).getTime()
306
+ : null;
307
+ const lastId = checkpoint?.last_transaction_id;
308
+ const seen = new Set<string>();
309
+ return transactions.filter((t) => {
310
+ if (seen.has(t.id)) return false;
311
+ seen.add(t.id);
312
+ if (lastId && t.id === lastId) return false;
313
+ if (
314
+ lastTs !== null &&
315
+ Number.isFinite(lastTs) &&
316
+ t.occurredAt.getTime() <= lastTs
317
+ ) {
318
+ return false;
319
+ }
320
+ return true;
321
+ });
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Event mapping (matches the original file-import Revolut connector)
326
+ // ---------------------------------------------------------------------------
327
+
328
+ function currencySymbol(currency: string): string {
329
+ switch (currency.toUpperCase()) {
330
+ case "GBP":
331
+ return "£";
332
+ case "USD":
333
+ return "$";
334
+ case "EUR":
335
+ return "€";
336
+ default:
337
+ return `${currency} `;
338
+ }
339
+ }
340
+
341
+ export function transactionToEvent(t: RevolutTransaction): EventEnvelope {
342
+ const sign = t.direction === "out" ? "-" : "+";
343
+ return {
344
+ origin_id: `revolut-${t.id}`,
345
+ payload_text: `${t.description} ${sign}${currencySymbol(t.currency)}${t.amount} on ${t.date}`,
346
+ occurred_at: t.occurredAt,
347
+ semantic_type: "transaction",
348
+ metadata: {
349
+ date: t.date,
350
+ description: t.description,
351
+ amount: t.amount,
352
+ direction: t.direction,
353
+ ...(t.balance !== undefined ? { balance: t.balance } : {}),
354
+ currency: t.currency,
355
+ ...(t.type ? { transaction_type: t.type } : {}),
356
+ ...(t.state ? { state: t.state } : {}),
357
+ },
358
+ };
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Sync
363
+ // ---------------------------------------------------------------------------
364
+
365
+ // The Revolut web app fetches account history from
366
+ // `app.revolut.com/api/retail/user/current/transactions/last?count=N&to=<ms>&internalPocketId=<uuid>`
367
+ // (the `to` param walks back in time as you scroll). These patterns also cover
368
+ // plausible alternates without catching unrelated `/api/retail/...` calls.
369
+ const TRANSACTION_API_PATTERNS: RegExp[] = [
370
+ /\/api\/retail\/.*transactions?(?:\/|\b)/i,
371
+ /\/api\/.*\/transactions(?:\b|\?|\/|$)/i,
372
+ /transactions?[./](?:last|recent|history|search)/i,
373
+ ];
374
+
375
+ const REVOLUT_AUTH_DOMAINS = ["app.revolut.com", ".revolut.com"];
376
+ // `/transactions` shows the full, infinitely-scrollable history for the default
377
+ // account; `/home` only shows the latest ~10. Per-pocket history lives at
378
+ // `/transactions?accountType=pocket&walletId=<uuid>&pocketId=<uuid>` — point a
379
+ // second feed's `start_url` there to sync a non-default currency pocket.
380
+ const DEFAULT_START_URL = "https://app.revolut.com/transactions";
381
+
382
+ function isLoggedIn(url: string): boolean {
383
+ let host: string;
384
+ try {
385
+ host = new URL(url).hostname;
386
+ } catch {
387
+ return false;
388
+ }
389
+ // An unauthenticated session is bounced to sso.revolut.com/passcode.
390
+ if (host !== "app.revolut.com") return false;
391
+ return !/\/(?:start|signin|login|verify|onboarding)\b/i.test(url);
392
+ }
393
+
394
+ const configSchema = {
395
+ type: "object",
396
+ properties: {
397
+ start_url: {
398
+ type: "string",
399
+ default: DEFAULT_START_URL,
400
+ description:
401
+ "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.",
402
+ },
403
+ currency_filter: {
404
+ type: "string",
405
+ description:
406
+ 'If set, keep only transactions in this ISO 4217 currency (e.g. "GBP").',
407
+ },
408
+ max_scrolls: {
409
+ type: "integer",
410
+ minimum: 1,
411
+ maximum: 100,
412
+ default: 20,
413
+ description:
414
+ "Maximum scroll iterations to paginate older transactions (default: 20).",
415
+ },
416
+ },
417
+ };
418
+
419
+ const transactionMetadataSchema = {
420
+ type: "object",
421
+ properties: {
422
+ date: { type: "string", format: "date" },
423
+ description: { type: "string" },
424
+ amount: { type: "number" },
425
+ direction: { type: "string", enum: ["in", "out"] },
426
+ balance: { type: "number" },
427
+ currency: { type: "string" },
428
+ transaction_type: { type: "string" },
429
+ state: { type: "string" },
430
+ },
431
+ };
432
+
433
+ export default class RevolutConnector extends ConnectorRuntime {
434
+ readonly definition: ConnectorDefinition = {
435
+ key: "revolut",
436
+ name: "Revolut",
437
+ description:
438
+ "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.",
439
+ version: "2.0.0",
440
+ faviconDomain: "app.revolut.com",
441
+ authSchema: {
442
+ methods: [
443
+ // CDP only — *not* `cli` cookie capture. Revolut's `app.revolut.com`
444
+ // access token (the `credentials` cookie) is bound to the browser that
445
+ // minted it (device-id header + Cloudflare/TLS fingerprint), so cookies
446
+ // exported from Chrome and replayed in a fresh headless browser get a 401
447
+ // on /api/retail/... and bounce to sso.revolut.com/passcode. The only
448
+ // path that authenticates is connecting over CDP to the *same* Chrome
449
+ // that holds the live session — keep one logged in and reachable.
450
+ {
451
+ type: "browser",
452
+ capture: "cdp",
453
+ defaultCdpUrl: "http://127.0.0.1:9222",
454
+ requiredDomains: REVOLUT_AUTH_DOMAINS,
455
+ description:
456
+ "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).",
457
+ },
458
+ ],
459
+ },
460
+ feeds: {
461
+ transactions: {
462
+ key: "transactions",
463
+ name: "Transactions",
464
+ description: "Account transactions pulled from the Revolut web app.",
465
+ configSchema,
466
+ eventKinds: {
467
+ transaction: {
468
+ description: "A bank transaction",
469
+ metadataSchema: transactionMetadataSchema,
470
+ },
471
+ },
472
+ },
473
+ },
474
+ optionsSchema: configSchema,
475
+ };
476
+
477
+ async sync(ctx: SyncContext): Promise<SyncResult> {
478
+ const config = (ctx.config ?? {}) as Record<string, unknown>;
479
+ const checkpoint = (ctx.checkpoint ?? {}) as RevolutCheckpoint;
480
+
481
+ const startUrl =
482
+ typeof config.start_url === "string" && config.start_url.trim()
483
+ ? config.start_url.trim()
484
+ : DEFAULT_START_URL;
485
+ const currencyFilter =
486
+ typeof config.currency_filter === "string" &&
487
+ config.currency_filter.trim()
488
+ ? config.currency_filter.trim().toUpperCase()
489
+ : null;
490
+ const maxScrolls = Math.max(
491
+ 1,
492
+ Math.min(100, Number(config.max_scrolls ?? 20) || 20),
493
+ );
494
+
495
+ // Primary auth is CDP (connect to the Chrome that holds the live Revolut
496
+ // session). Stored cookies are only a best-effort fallback for the
497
+ // Playwright path — see the auth-schema comment on why they rarely suffice
498
+ // for Revolut. Don't fail the sync just because there are none.
499
+ const userDataDir = getBrowserUserDataDir(ctx.sessionState);
500
+ const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? "auto";
501
+ let cookies: ReturnType<typeof getBrowserCookies> = [];
502
+ if (!userDataDir) {
503
+ try {
504
+ cookies = getBrowserCookies(
505
+ ctx.checkpoint as Record<string, unknown> | null,
506
+ ctx.sessionState,
507
+ "revolut",
508
+ );
509
+ validateCookieNotExpired(cookies, "credentials", "revolut");
510
+ } catch {
511
+ cookies = [];
512
+ }
513
+ }
514
+
515
+ const result = await browserNetworkSync<RevolutTransaction>({
516
+ config: {
517
+ interceptPatterns: TRANSACTION_API_PATTERNS,
518
+ authDomains: REVOLUT_AUTH_DOMAINS,
519
+ maxScrolls,
520
+ scrollDelayMs: 2500,
521
+ responseTimeoutMs: 8000,
522
+ navigationTimeoutMs: 20000,
523
+ stealth: true,
524
+ },
525
+ url: startUrl,
526
+ cdpUrl,
527
+ cookies,
528
+ userDataDir,
529
+ parseResponse: (_url, json) => extractTransactionsFromResponse(json),
530
+ checkAuth: async (page) => isLoggedIn(page.url()),
531
+ });
532
+
533
+ let transactions = filterTransactionsSinceCheckpoint(
534
+ result.items,
535
+ checkpoint,
536
+ );
537
+ if (currencyFilter) {
538
+ transactions = transactions.filter((t) => t.currency === currencyFilter);
539
+ }
540
+ transactions.sort(
541
+ (a, b) => b.occurredAt.getTime() - a.occurredAt.getTime(),
542
+ );
543
+
544
+ const events: EventEnvelope[] = transactions.map(transactionToEvent);
545
+ const newest = transactions[0];
546
+ const newCheckpoint: RevolutCheckpoint = newest
547
+ ? {
548
+ last_transaction_id: newest.id,
549
+ last_timestamp: newest.occurredAt.toISOString(),
550
+ }
551
+ : checkpoint;
552
+
553
+ return {
554
+ events,
555
+ checkpoint: newCheckpoint as unknown as Record<string, unknown>,
556
+ auth_update: { cookies: result.cookies },
557
+ metadata: {
558
+ items_found: events.length,
559
+ api_calls: result.apiCallCount,
560
+ backend: result.backend,
561
+ ...(currencyFilter ? { currency_filter: currencyFilter } : {}),
562
+ },
563
+ };
564
+ }
565
+
566
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
567
+ return { success: false, error: "Actions not supported" };
568
+ }
569
+ }
@@ -15,6 +15,7 @@ import {
15
15
  type SyncContext,
16
16
  type SyncResult,
17
17
  } from '@lobu/connector-sdk';
18
+ import { validatePublicUrl } from './browser-scraper-utils.ts';
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Types
@@ -211,6 +212,11 @@ export default class RSSConnector extends ConnectorRuntime {
211
212
  // -------------------------------------------------------------------------
212
213
 
213
214
  private async fetchAndParseFeed(feedUrl: string, maxItems: number): Promise<RSSFeedItem[]> {
215
+ // SSRF guard at the trust boundary. `feed_urls` is operator/user supplied
216
+ // via connector config and must not be allowed to target loopback, RFC1918,
217
+ // or cloud-metadata IPs from the gateway process.
218
+ validatePublicUrl(feedUrl);
219
+
214
220
  const controller = new AbortController();
215
221
  const timeoutId = setTimeout(() => controller.abort(), this.FETCH_TIMEOUT_MS);
216
222
 
@@ -222,18 +228,13 @@ export default class RSSConnector extends ConnectorRuntime {
222
228
  Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
223
229
  },
224
230
  });
225
-
226
- clearTimeout(timeoutId);
227
-
228
231
  if (!response.ok) {
229
232
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
230
233
  }
231
-
232
234
  const xml = await response.text();
233
235
  return this.parseXml(xml, feedUrl, maxItems);
234
- } catch (err) {
236
+ } finally {
235
237
  clearTimeout(timeoutId);
236
- throw err;
237
238
  }
238
239
  }
239
240
 
@@ -413,8 +414,32 @@ export default class RSSConnector extends ConnectorRuntime {
413
414
  case '#39':
414
415
  return "'";
415
416
  default:
416
- if (hex) return String.fromCharCode(parseInt(hex, 16));
417
- if (decimal) return String.fromCharCode(parseInt(decimal, 10));
417
+ // Use fromCodePoint, not fromCharCode — astral-plane characters
418
+ // (emoji, CJK extension B+, etc.) have code points > 0xFFFF which
419
+ // fromCharCode silently truncates, producing mojibake in feed
420
+ // titles. Guard the range so a malformed entity doesn't throw.
421
+ if (hex) {
422
+ const cp = parseInt(hex, 16);
423
+ if (Number.isFinite(cp) && cp >= 0 && cp <= 0x10ffff) {
424
+ try {
425
+ return String.fromCodePoint(cp);
426
+ } catch {
427
+ return _match;
428
+ }
429
+ }
430
+ return _match;
431
+ }
432
+ if (decimal) {
433
+ const cp = parseInt(decimal, 10);
434
+ if (Number.isFinite(cp) && cp >= 0 && cp <= 0x10ffff) {
435
+ try {
436
+ return String.fromCodePoint(cp);
437
+ } catch {
438
+ return _match;
439
+ }
440
+ }
441
+ return _match;
442
+ }
418
443
  return _match;
419
444
  }
420
445
  }