@melaya/runner 1.0.92 → 1.0.93

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.
@@ -32,9 +32,22 @@
32
32
  * port, mints a per-session token, and exposes both via env vars
33
33
  * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
34
34
  * Python pipeline subprocess.
35
- * 3. Python's `luma_register_event` POSTs the registration body to
36
- * `${MEL_LUMA_BROWSER_URL}/luma/forward` with
37
- * `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
35
+ * 3. Two endpoints:
36
+ *
37
+ * POST /luma/register (PREFERRED, Option A — UI-driven)
38
+ * Python's `luma_register_event` posts `{ eventApiId, answers,
39
+ * ticketPreference }`. The bridge opens the REAL event page, installs a
40
+ * response interceptor on Luma's own register XHR, clicks the
41
+ * Register/RSVP button, fills required custom questions + ticket, submits,
42
+ * and returns Luma's REAL request body + endpoint + response. This lets
43
+ * Luma's frontend build the correct wire schema (whatever the current
44
+ * release wants) and handles fresh / invited / ticket / approval flows
45
+ * uniformly — no hand-built body. If the operator already has a spot the
46
+ * page shows a manage state and we return `pageState: already_registered`
47
+ * instead of hunting for a Register button.
48
+ *
49
+ * POST /luma/forward (LEGACY fallback — hand-built body replay)
50
+ * Python posts a full `{ url, method, headers, body }`. The bridge:
38
51
  * a. Spawns / reuses a headless Chromium with the saved storage
39
52
  * state, freshly reloaded from disk so a recent reconnect's
40
53
  * cookies are picked up immediately.
@@ -52,9 +65,10 @@
52
65
  * processes on the same machine can't fire requests without it
53
66
  * (the env var is only injected into the runner-spawned pipeline
54
67
  * subprocess).
55
- * - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
56
- * api2.luma.com / lu.ma origin and the method must be GET / POST.
57
- * Anything else is rejected with 400.
68
+ * - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com /
69
+ * api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register
70
+ * requires an evt-… id and only ever opens a luma.com event page. Anything
71
+ * else is rejected with 400.
58
72
  * - Body size is capped at 64 KB.
59
73
  *
60
74
  * Cleanup
@@ -32,9 +32,22 @@
32
32
  * port, mints a per-session token, and exposes both via env vars
33
33
  * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
34
34
  * Python pipeline subprocess.
35
- * 3. Python's `luma_register_event` POSTs the registration body to
36
- * `${MEL_LUMA_BROWSER_URL}/luma/forward` with
37
- * `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
35
+ * 3. Two endpoints:
36
+ *
37
+ * POST /luma/register (PREFERRED, Option A — UI-driven)
38
+ * Python's `luma_register_event` posts `{ eventApiId, answers,
39
+ * ticketPreference }`. The bridge opens the REAL event page, installs a
40
+ * response interceptor on Luma's own register XHR, clicks the
41
+ * Register/RSVP button, fills required custom questions + ticket, submits,
42
+ * and returns Luma's REAL request body + endpoint + response. This lets
43
+ * Luma's frontend build the correct wire schema (whatever the current
44
+ * release wants) and handles fresh / invited / ticket / approval flows
45
+ * uniformly — no hand-built body. If the operator already has a spot the
46
+ * page shows a manage state and we return `pageState: already_registered`
47
+ * instead of hunting for a Register button.
48
+ *
49
+ * POST /luma/forward (LEGACY fallback — hand-built body replay)
50
+ * Python posts a full `{ url, method, headers, body }`. The bridge:
38
51
  * a. Spawns / reuses a headless Chromium with the saved storage
39
52
  * state, freshly reloaded from disk so a recent reconnect's
40
53
  * cookies are picked up immediately.
@@ -52,9 +65,10 @@
52
65
  * processes on the same machine can't fire requests without it
53
66
  * (the env var is only injected into the runner-spawned pipeline
54
67
  * subprocess).
55
- * - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
56
- * api2.luma.com / lu.ma origin and the method must be GET / POST.
57
- * Anything else is rejected with 400.
68
+ * - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com /
69
+ * api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register
70
+ * requires an evt-… id and only ever opens a luma.com event page. Anything
71
+ * else is rejected with 400.
58
72
  * - Body size is capped at 64 KB.
59
73
  *
60
74
  * Cleanup
@@ -90,6 +104,176 @@ function _stateDir() {
90
104
  export function lumaStorageStatePath() {
91
105
  return path.join(_stateDir(), "storage-state.json");
92
106
  }
107
+ /** Detect the "you already have a spot" state. Luma shows a manage / "You're
108
+ * In" / "Request Sent" / "On the Waitlist" panel instead of a Register button
109
+ * for guests it already knows. Returns a short state label or "" if fresh. */
110
+ async function _detectAlreadyRegistered(page) {
111
+ try {
112
+ return await page.evaluate(() => {
113
+ const txt = (document.body.innerText || "").toLowerCase();
114
+ // Order matters: more specific first.
115
+ if (/pending approval|request sent|awaiting approval|pending review/.test(txt))
116
+ return "approval_pending";
117
+ if (/on the waitlist|you.?re on the waitlist|waitlist.?ed/.test(txt))
118
+ return "waitlisted";
119
+ if (/you.?re in|you are in|you.?re going|manage registration|change registration|cancel registration|edit registration/.test(txt))
120
+ return "already_registered";
121
+ return "";
122
+ });
123
+ }
124
+ catch {
125
+ return "";
126
+ }
127
+ }
128
+ /** Click the primary Register / RSVP / Join button. Returns true on click. */
129
+ async function _clickRegisterButton(page) {
130
+ // 1) data-testid / known attribute hooks first (most stable).
131
+ const testidSelectors = [
132
+ '[data-testid="register-button"]',
133
+ '[data-testid="rsvp-button"]',
134
+ 'button[data-luma-action="register"]',
135
+ ];
136
+ for (const sel of testidSelectors) {
137
+ try {
138
+ const el = page.locator(sel).first();
139
+ if (await el.count() && await el.isVisible()) {
140
+ await el.click({ timeout: 4000 });
141
+ return true;
142
+ }
143
+ }
144
+ catch { /* next */ }
145
+ }
146
+ // 2) button/anchor text — try each verb. getByRole with an accessible-name
147
+ // regex catches "Register", "RSVP", "Join", "One-Click Register", "Get
148
+ // Ticket", "Request to Join" without over-matching "Register a friend".
149
+ const nameRx = /^(register|rsvp|join|one[- ]?click register|get ticket|request to join|reserve( a)? spot)/i;
150
+ for (const role of ["button", "link"]) {
151
+ try {
152
+ const el = page.getByRole(role, { name: nameRx }).first();
153
+ if (await el.count() && await el.isVisible()) {
154
+ await el.click({ timeout: 4000 });
155
+ return true;
156
+ }
157
+ }
158
+ catch { /* next */ }
159
+ }
160
+ // 3) last resort: any visible button whose text starts with a register verb.
161
+ try {
162
+ const btn = page.locator("button, a[role='button']").filter({ hasText: nameRx }).first();
163
+ if (await btn.count() && await btn.isVisible()) {
164
+ await btn.click({ timeout: 4000 });
165
+ return true;
166
+ }
167
+ }
168
+ catch { /* give up */ }
169
+ return false;
170
+ }
171
+ /** Select a ticket when the event offers a choice. Best-effort: if a preferred
172
+ * ticket name/id is given, click the row containing it; otherwise pick the
173
+ * first free ("Free"/$0) ticket, else the first ticket. No-op when there's no
174
+ * ticket selector (plain free RSVP). */
175
+ async function _selectTicketIfPresent(page, preference) {
176
+ try {
177
+ // Luma renders ticket options as clickable rows/radios. We look for a
178
+ // container that mentions "ticket" then click a matching option.
179
+ if (preference) {
180
+ const pref = page.getByText(preference, { exact: false }).first();
181
+ if (await pref.count() && await pref.isVisible()) {
182
+ await pref.click({ timeout: 2500 });
183
+ return;
184
+ }
185
+ }
186
+ // Prefer a free ticket.
187
+ const free = page.locator("[class*='ticket'], [data-testid*='ticket']")
188
+ .filter({ hasText: /free|\$0(\.00)?/i }).first();
189
+ if (await free.count() && await free.isVisible()) {
190
+ await free.click({ timeout: 2500 });
191
+ return;
192
+ }
193
+ }
194
+ catch { /* no ticket selector — plain RSVP */ }
195
+ }
196
+ /** Fill required custom questions in the registration modal from the provided
197
+ * answers map, matched by visible label. Handles text inputs, textareas, and
198
+ * select dropdowns. Checkboxes labelled with agreement text are left to the
199
+ * operator's profile-driven answers (passed in `answers` as "yes"). */
200
+ async function _fillCustomQuestions(page, answers) {
201
+ for (const [label, rawVal] of Object.entries(answers)) {
202
+ const val = rawVal == null ? "" : String(rawVal);
203
+ if (!val)
204
+ continue;
205
+ const labelRx = new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
206
+ try {
207
+ // Try an input/textarea associated with a label matching the question.
208
+ const field = page.getByLabel(labelRx).first();
209
+ if (await field.count() && await field.isVisible()) {
210
+ const tag = await field.evaluate((el) => el.tagName.toLowerCase()).catch(() => "");
211
+ if (tag === "select") {
212
+ await field.selectOption({ label: val }).catch(async () => {
213
+ await field.selectOption(val).catch(() => { });
214
+ });
215
+ }
216
+ else if (val.toLowerCase() === "yes") {
217
+ await field.check({ timeout: 2000 }).catch(() => field.click({ timeout: 2000 }).catch(() => { }));
218
+ }
219
+ else {
220
+ await field.fill(val, { timeout: 2500 }).catch(() => { });
221
+ }
222
+ continue;
223
+ }
224
+ }
225
+ catch { /* fall through to placeholder match */ }
226
+ // Fallback: match by placeholder text.
227
+ try {
228
+ const ph = page.getByPlaceholder(labelRx).first();
229
+ if (await ph.count() && await ph.isVisible()) {
230
+ await ph.fill(val, { timeout: 2500 }).catch(() => { });
231
+ }
232
+ }
233
+ catch { /* skip this question — submit will surface if it was required */ }
234
+ }
235
+ }
236
+ /** Click the modal's final submit/confirm button. Distinct from the initial
237
+ * Register button: the modal's CTA is usually "Register" / "Confirm" / "Get
238
+ * Ticket" / "Request to Join". Idempotent — safe if there's no modal. */
239
+ async function _submitRegistration(page) {
240
+ const nameRx = /^(register|confirm|rsvp|get ticket|request to join|submit|one[- ]?click register|reserve)/i;
241
+ // Prefer a submit inside a dialog/modal so we don't re-click the page's
242
+ // primary button (which may just re-open the modal).
243
+ const scopes = [
244
+ page.getByRole("dialog"),
245
+ page.locator("[role='dialog'], [class*='modal'], [class*='Modal']"),
246
+ page, // whole page fallback
247
+ ];
248
+ for (const scope of scopes) {
249
+ try {
250
+ const anyScope = scope;
251
+ const btn = (typeof anyScope.getByRole === "function"
252
+ ? anyScope.getByRole("button", { name: nameRx })
253
+ : page.getByRole("button", { name: nameRx })).first();
254
+ if (await btn.count() && await btn.isVisible()) {
255
+ await btn.click({ timeout: 4000 });
256
+ return;
257
+ }
258
+ }
259
+ catch { /* next scope */ }
260
+ }
261
+ }
262
+ /** Screenshot to base64 PNG, capped, best-effort. Returns "" on failure. */
263
+ async function _safeScreenshot(page) {
264
+ try {
265
+ const buf = await page.screenshot({ type: "png", fullPage: false });
266
+ // Cap at ~1.5MB base64 to stay well under the bridge's 64KB *request*
267
+ // limit is irrelevant here (this is a RESPONSE); but keep it bounded so a
268
+ // huge PNG doesn't bloat the trace. Truncate rather than drop so the
269
+ // operator still gets a partial visual.
270
+ const b64 = buf.toString("base64");
271
+ return b64.length > 1_500_000 ? b64.slice(0, 1_500_000) : b64;
272
+ }
273
+ catch {
274
+ return "";
275
+ }
276
+ }
93
277
  // ── Public starter ──────────────────────────────────────────────────────────
94
278
  /**
95
279
  * Starts the bridge if a saved Luma storage state exists. Returns null
@@ -237,6 +421,165 @@ export async function startLumaBrowserBridge(opts) {
237
421
  }
238
422
  }
239
423
  }
424
+ // ── UI-driven register (Option A) ──────────────────────────────────────────
425
+ //
426
+ // Open the real event page, install a response interceptor on Luma's own
427
+ // register XHR, then click the Register/RSVP button and fill the form. This
428
+ // lets Luma's frontend build the CORRECT wire body (whatever the current
429
+ // release's schema is) and clears Cloudflare (cf_bm refreshes on-page, TLS
430
+ // is Chrome's). We return the intercepted request body + endpoint + upstream
431
+ // response verbatim.
432
+ async function _register(req) {
433
+ const eid = String(req.eventApiId || "").trim();
434
+ if (!eid.startsWith("evt-")) {
435
+ return { ok: false, status: 0, body: "", error: `invalid_event_api_id: ${eid}` };
436
+ }
437
+ const eventUrl = req.eventUrl && /^https?:\/\//.test(req.eventUrl)
438
+ ? req.eventUrl
439
+ : `https://luma.com/${eid}`;
440
+ try {
441
+ const u = new URL(eventUrl);
442
+ if (!ALLOWED_HOSTNAMES.has(u.hostname)) {
443
+ return { ok: false, status: 0, body: "", error: `host_not_allowed: ${u.hostname}` };
444
+ }
445
+ }
446
+ catch {
447
+ return { ok: false, status: 0, body: "", error: "invalid_event_url" };
448
+ }
449
+ const b = await _ensureBrowser();
450
+ let ctx = null;
451
+ try {
452
+ const storageState = await _readStorageState();
453
+ ctx = await b.newContext({
454
+ storageState: storageState,
455
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
456
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
457
+ viewport: { width: 1280, height: 900 },
458
+ });
459
+ const page = await ctx.newPage();
460
+ // Response interceptor: capture the FIRST register/RSVP XHR Luma fires.
461
+ // We match on the URL path so we catch whichever surface the current
462
+ // release uses (/event/register, /event/independent-register-for-event,
463
+ // or a future rename that still contains "register").
464
+ let capturedStatus = 0;
465
+ let capturedBody = "";
466
+ let capturedEndpoint = "";
467
+ let capturedRequestBody = "";
468
+ const REGISTER_PATH_RX = /\/event\/[a-z-]*register[a-z-]*/i;
469
+ page.on("request", (request) => {
470
+ try {
471
+ const url = request.url();
472
+ if (request.method() === "POST" && REGISTER_PATH_RX.test(new URL(url).pathname)) {
473
+ if (!capturedRequestBody) {
474
+ capturedEndpoint = new URL(url).pathname;
475
+ capturedRequestBody = request.postData() || "";
476
+ }
477
+ }
478
+ }
479
+ catch { /* ignore */ }
480
+ });
481
+ page.on("response", async (response) => {
482
+ try {
483
+ const url = response.url();
484
+ if (response.request().method() === "POST" && REGISTER_PATH_RX.test(new URL(url).pathname)) {
485
+ // First register response wins.
486
+ if (!capturedStatus) {
487
+ capturedStatus = response.status();
488
+ try {
489
+ capturedBody = await response.text();
490
+ }
491
+ catch {
492
+ capturedBody = "";
493
+ }
494
+ if (!capturedEndpoint)
495
+ capturedEndpoint = new URL(url).pathname;
496
+ }
497
+ }
498
+ }
499
+ catch { /* ignore */ }
500
+ });
501
+ try {
502
+ await page.goto(eventUrl, {
503
+ waitUntil: "domcontentloaded",
504
+ timeout: req.navigationTimeoutMs || PAGE_GOTO_TIMEOUT_MS,
505
+ });
506
+ }
507
+ catch (e) {
508
+ opts.log(`[luma-bridge] register goto failed (${e?.message || e}).`);
509
+ }
510
+ await page.waitForTimeout(PAGE_SETTLE_MS);
511
+ // If the operator already has a spot, Luma shows a "You're In" / manage
512
+ // state instead of a Register button. Detect it up-front so we don't
513
+ // hunt for a button that isn't there.
514
+ const alreadyState = await _detectAlreadyRegistered(page);
515
+ if (alreadyState) {
516
+ return {
517
+ ok: true, status: 0, body: "", pageState: alreadyState,
518
+ capturedEndpoint, capturedRequestBody,
519
+ };
520
+ }
521
+ // Click the Register / RSVP / Join button. Resilient to Luma's copy +
522
+ // markup drift: try data-testids, then a set of button texts, then any
523
+ // button whose accessible name contains a register-ish verb.
524
+ const clicked = await _clickRegisterButton(page);
525
+ if (!clicked) {
526
+ const shot = await _safeScreenshot(page);
527
+ return {
528
+ ok: false, status: 0, body: "",
529
+ pageState: "no_register_button",
530
+ screenshotB64: shot,
531
+ error: "could not locate a Register/RSVP button on the event page",
532
+ };
533
+ }
534
+ // A registration modal may appear with required custom questions and/or a
535
+ // ticket selector. Fill what we can, then submit. Best-effort — Luma's own
536
+ // client-side validation will block submit if something required is empty,
537
+ // and we surface that as a precise error rather than a silent 400.
538
+ await page.waitForTimeout(800);
539
+ await _selectTicketIfPresent(page, req.ticketPreference);
540
+ await _fillCustomQuestions(page, req.answers || {});
541
+ await _submitRegistration(page);
542
+ // Wait for the register XHR to fire + resolve. Poll up to ~15s.
543
+ const deadline = Date.now() + 15_000;
544
+ while (Date.now() < deadline && !capturedStatus) {
545
+ await page.waitForTimeout(300);
546
+ }
547
+ if (capturedStatus) {
548
+ return {
549
+ ok: true,
550
+ status: capturedStatus,
551
+ body: capturedBody,
552
+ capturedEndpoint,
553
+ capturedRequestBody,
554
+ };
555
+ }
556
+ // No register XHR observed. Re-check for an "already in" state the submit
557
+ // may have produced, else return a diagnosable failure with a screenshot.
558
+ const postState = await _detectAlreadyRegistered(page);
559
+ if (postState) {
560
+ return { ok: true, status: 0, body: "", pageState: postState, capturedEndpoint, capturedRequestBody };
561
+ }
562
+ const shot = await _safeScreenshot(page);
563
+ return {
564
+ ok: false, status: 0, body: "",
565
+ pageState: "no_register_xhr",
566
+ capturedEndpoint, capturedRequestBody,
567
+ screenshotB64: shot,
568
+ error: "clicked register but no register XHR fired (form may have unmet required fields)",
569
+ };
570
+ }
571
+ catch (e) {
572
+ return { ok: false, status: 0, body: "", error: `register_bridge_error: ${e?.message || e}` };
573
+ }
574
+ finally {
575
+ if (ctx) {
576
+ try {
577
+ await ctx.close();
578
+ }
579
+ catch { /* ignore */ }
580
+ }
581
+ }
582
+ }
240
583
  // ── HTTP server ──────────────────────────────────────────────────────────
241
584
  const server = createServer(async (req, res) => {
242
585
  res.setHeader("X-Frame-Options", "DENY");
@@ -256,7 +599,8 @@ export async function startLumaBrowserBridge(opts) {
256
599
  res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
257
600
  return;
258
601
  }
259
- if (req.url !== "/luma/forward") {
602
+ const route = req.url || "";
603
+ if (route !== "/luma/forward" && route !== "/luma/register") {
260
604
  res.statusCode = 404;
261
605
  res.end(JSON.stringify({ ok: false, error: "not_found" }));
262
606
  return;
@@ -287,12 +631,32 @@ export async function startLumaBrowserBridge(opts) {
287
631
  res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
288
632
  return;
289
633
  }
290
- if (!payload || typeof payload !== "object" || !payload.url) {
634
+ if (!payload || typeof payload !== "object") {
635
+ res.statusCode = 400;
636
+ res.end(JSON.stringify({ ok: false, error: "invalid_payload" }));
637
+ return;
638
+ }
639
+ // Route to the UI-driven register path (Option A) or the legacy forward.
640
+ if (route === "/luma/register") {
641
+ const rreq = payload;
642
+ if (!rreq.eventApiId) {
643
+ res.statusCode = 400;
644
+ res.end(JSON.stringify({ ok: false, error: "missing_eventApiId" }));
645
+ return;
646
+ }
647
+ const rout = await _register(rreq);
648
+ res.statusCode = 200;
649
+ res.setHeader("Content-Type", "application/json");
650
+ res.end(JSON.stringify(rout));
651
+ return;
652
+ }
653
+ const freq = payload;
654
+ if (!freq.url) {
291
655
  res.statusCode = 400;
292
656
  res.end(JSON.stringify({ ok: false, error: "missing_url" }));
293
657
  return;
294
658
  }
295
- const out = await _forward(payload);
659
+ const out = await _forward(freq);
296
660
  res.statusCode = 200;
297
661
  res.setHeader("Content-Type", "application/json");
298
662
  res.end(JSON.stringify(out));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.92",
3
+ "version": "1.0.93",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,