mcp-scraper 0.2.13 → 0.2.15

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 (47) hide show
  1. package/README.md +12 -2
  2. package/dist/bin/api-server.cjs +977 -200
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +3 -3
  5. package/dist/bin/browser-agent-stdio-server.cjs +1 -1
  6. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  7. package/dist/bin/browser-agent-stdio-server.js +2 -2
  8. package/dist/bin/mcp-scraper-cli.cjs +46 -3
  9. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-cli.js +42 -2
  11. package/dist/bin/mcp-scraper-cli.js.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +135 -5
  13. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  14. package/dist/bin/mcp-scraper-combined-stdio-server.js +3 -3
  15. package/dist/bin/mcp-scraper-install.cjs +4 -3
  16. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  17. package/dist/bin/mcp-scraper-install.js +4 -3
  18. package/dist/bin/mcp-scraper-install.js.map +1 -1
  19. package/dist/bin/mcp-stdio-server.cjs +135 -5
  20. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  21. package/dist/bin/mcp-stdio-server.js +2 -2
  22. package/dist/chunk-2CQXHSWC.js +7 -0
  23. package/dist/chunk-2CQXHSWC.js.map +1 -0
  24. package/dist/{chunk-ROS67BNV.js → chunk-BSYPATSM.js} +21 -2
  25. package/dist/chunk-BSYPATSM.js.map +1 -0
  26. package/dist/{chunk-AYTOCZBS.js → chunk-F44RBOJ5.js} +6 -3
  27. package/dist/chunk-F44RBOJ5.js.map +1 -0
  28. package/dist/{chunk-DKJ2XCY7.js → chunk-Q4DFONIK.js} +136 -6
  29. package/dist/chunk-Q4DFONIK.js.map +1 -0
  30. package/dist/{chunk-BQTWXY6G.js → chunk-TIPUIEJN.js} +2 -2
  31. package/dist/{chunk-7GCCOT3M.js → chunk-TL7YTFLH.js} +15 -1
  32. package/dist/chunk-TL7YTFLH.js.map +1 -0
  33. package/dist/{db-BVHYI57K.js → db-P5X6UQ3E.js} +2 -2
  34. package/dist/{server-LUNOI26E.js → server-VD2TD3AD.js} +773 -184
  35. package/dist/server-VD2TD3AD.js.map +1 -0
  36. package/dist/{worker-TDJQ6TH3.js → worker-OZSWIS3F.js} +3 -3
  37. package/package.json +1 -1
  38. package/dist/chunk-7GCCOT3M.js.map +0 -1
  39. package/dist/chunk-AYTOCZBS.js.map +0 -1
  40. package/dist/chunk-DKJ2XCY7.js.map +0 -1
  41. package/dist/chunk-RDROUQ4E.js +0 -7
  42. package/dist/chunk-RDROUQ4E.js.map +0 -1
  43. package/dist/chunk-ROS67BNV.js.map +0 -1
  44. package/dist/server-LUNOI26E.js.map +0 -1
  45. /package/dist/{chunk-BQTWXY6G.js.map → chunk-TIPUIEJN.js.map} +0 -0
  46. /package/dist/{db-BVHYI57K.js.map → db-P5X6UQ3E.js.map} +0 -0
  47. /package/dist/{worker-TDJQ6TH3.js.map → worker-OZSWIS3F.js.map} +0 -0
@@ -11,11 +11,12 @@ import {
11
11
  MC_PER_CREDIT,
12
12
  browserActiveCostMc,
13
13
  classifyHarvestProblem,
14
+ concurrencySlotBillingInfo,
14
15
  createHarvestAttemptRecorder,
15
16
  harvestProblemResponse,
16
17
  insufficientBalanceResponse,
17
18
  serializeHarvestProblem
18
- } from "./chunk-ROS67BNV.js";
19
+ } from "./chunk-BSYPATSM.js";
19
20
  import {
20
21
  SiteAuditJobRowSchema,
21
22
  cancelJob,
@@ -24,7 +25,6 @@ import {
24
25
  completeJob,
25
26
  completeWorkflowRunRecord,
26
27
  consumePasswordResetToken,
27
- countActiveJobsForUser,
28
28
  countActiveUsers,
29
29
  createJob,
30
30
  createPasswordResetToken,
@@ -74,7 +74,7 @@ import {
74
74
  setPassword,
75
75
  setStripeCustomerId,
76
76
  verifyPassword
77
- } from "./chunk-7GCCOT3M.js";
77
+ } from "./chunk-TL7YTFLH.js";
78
78
  import {
79
79
  BrowserDriver,
80
80
  MapsPlaceOptionsSchema,
@@ -100,7 +100,7 @@ import {
100
100
  harvestTimeoutBudget,
101
101
  liveWebToolAnnotations,
102
102
  outputBaseDir
103
- } from "./chunk-DKJ2XCY7.js";
103
+ } from "./chunk-Q4DFONIK.js";
104
104
  import {
105
105
  CaptchaError,
106
106
  RECAPTCHA_INSTRUCTIONS,
@@ -113,8 +113,8 @@ import {
113
113
  runWorkflow,
114
114
  workflowDefinition,
115
115
  workflowOutputBaseDir
116
- } from "./chunk-AYTOCZBS.js";
117
- import "./chunk-RDROUQ4E.js";
116
+ } from "./chunk-F44RBOJ5.js";
117
+ import "./chunk-2CQXHSWC.js";
118
118
 
119
119
  // src/api/outbound-sanitize.ts
120
120
  var KEY_RENAMES = {
@@ -8029,6 +8029,179 @@ var YoutubeTranscribeBodySchema = z11.object({
8029
8029
  videoId: z11.string().min(1, "videoId is required")
8030
8030
  });
8031
8031
 
8032
+ // src/api/concurrency-gates.ts
8033
+ import { randomUUID } from "crypto";
8034
+ var DEFAULT_LOCK_TTL_SECONDS = 15 * 60;
8035
+ var DEFAULT_RETRY_AFTER_SECONDS = 30;
8036
+ var MAX_LOCK_TTL_SECONDS = 24 * 60 * 60;
8037
+ var schemaReady = null;
8038
+ function concurrencyLimitForUser(user) {
8039
+ return 1 + Math.max(0, Number(user.extra_concurrency_slots ?? 0));
8040
+ }
8041
+ function concurrencyLimitExceededResponse(gate) {
8042
+ const upgrade = concurrencySlotBillingInfo();
8043
+ const message = `You have ${gate.active} active operation${gate.active !== 1 ? "s" : ""}. Your account allows ${gate.limit} concurrent operation${gate.limit !== 1 ? "s" : ""}. Wait for one to finish, or add an extra concurrency slot for ${upgrade.price_label} at ${upgrade.billing_url}. Terminal upgrade: ${upgrade.terminal_command}`;
8044
+ return {
8045
+ error: message,
8046
+ message,
8047
+ error_code: "concurrency_limit_exceeded",
8048
+ error_type: "concurrency_limit",
8049
+ retryable: true,
8050
+ active: gate.active,
8051
+ limit: gate.limit,
8052
+ operation: gate.operation,
8053
+ upgrade,
8054
+ upgrade_url: upgrade.billing_url,
8055
+ upgrade_command: upgrade.terminal_command
8056
+ };
8057
+ }
8058
+ function isConcurrencyLimitBypassed(user) {
8059
+ const raw = process.env.CONCURRENCY_LIMIT_BYPASS_EMAILS ?? process.env.HARVEST_LIMIT_BYPASS_EMAILS ?? "";
8060
+ return raw.split(",").map((email) => email.trim()).filter(Boolean).includes(user.email);
8061
+ }
8062
+ function lockTtlModifier(ttlSeconds) {
8063
+ const ttl = Number.isFinite(ttlSeconds) ? Math.max(30, Math.min(Math.trunc(Number(ttlSeconds)), MAX_LOCK_TTL_SECONDS)) : DEFAULT_LOCK_TTL_SECONDS;
8064
+ return `+${ttl} seconds`;
8065
+ }
8066
+ function safeMetadata(metadata) {
8067
+ if (!metadata) return null;
8068
+ try {
8069
+ return JSON.stringify(metadata).slice(0, 4e3);
8070
+ } catch {
8071
+ return null;
8072
+ }
8073
+ }
8074
+ async function ensureConcurrencyGateSchema() {
8075
+ if (!schemaReady) {
8076
+ schemaReady = (async () => {
8077
+ const db = getDb();
8078
+ await db.execute(`
8079
+ CREATE TABLE IF NOT EXISTS concurrency_locks (
8080
+ id TEXT PRIMARY KEY,
8081
+ user_id INTEGER NOT NULL REFERENCES users(id),
8082
+ operation TEXT NOT NULL,
8083
+ status TEXT NOT NULL DEFAULT 'active',
8084
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
8085
+ expires_at TEXT NOT NULL,
8086
+ released_at TEXT,
8087
+ metadata TEXT
8088
+ )
8089
+ `);
8090
+ await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_user_status ON concurrency_locks(user_id, status, expires_at)`);
8091
+ await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_operation ON concurrency_locks(operation, status, expires_at)`);
8092
+ })();
8093
+ }
8094
+ return schemaReady;
8095
+ }
8096
+ async function expireConcurrencyLocksForUser(userId) {
8097
+ await ensureConcurrencyGateSchema();
8098
+ await getDb().execute({
8099
+ sql: `UPDATE concurrency_locks
8100
+ SET status = 'expired', released_at = datetime('now')
8101
+ WHERE user_id = ? AND status = 'active' AND expires_at <= datetime('now')`,
8102
+ args: [userId]
8103
+ });
8104
+ }
8105
+ async function countActiveUsageForUser(userId) {
8106
+ await ensureConcurrencyGateSchema();
8107
+ await expireConcurrencyLocksForUser(userId);
8108
+ const res = await getDb().execute({
8109
+ sql: `SELECT
8110
+ (
8111
+ SELECT COUNT(*)
8112
+ FROM jobs
8113
+ WHERE user_id = ?
8114
+ AND (status = 'pending' OR (status = 'running' AND started_at > datetime('now', '-10 minutes')))
8115
+ ) +
8116
+ (
8117
+ SELECT COUNT(*)
8118
+ FROM concurrency_locks
8119
+ WHERE user_id = ? AND status = 'active' AND expires_at > datetime('now')
8120
+ ) AS n`,
8121
+ args: [userId, userId]
8122
+ });
8123
+ return Number(res.rows[0]?.n ?? 0);
8124
+ }
8125
+ async function reuseExistingConcurrencyGate(userId, lockId, ttlSeconds) {
8126
+ if (!/^cl_[a-f0-9]{24,32}$/.test(lockId)) return false;
8127
+ await ensureConcurrencyGateSchema();
8128
+ await expireConcurrencyLocksForUser(userId);
8129
+ const res = await getDb().execute({
8130
+ sql: `UPDATE concurrency_locks
8131
+ SET expires_at = datetime('now', ?)
8132
+ WHERE id = ?
8133
+ AND user_id = ?
8134
+ AND status = 'active'
8135
+ AND expires_at > datetime('now')`,
8136
+ args: [lockTtlModifier(ttlSeconds), lockId, userId]
8137
+ });
8138
+ return Number(res.rowsAffected ?? 0) > 0;
8139
+ }
8140
+ async function acquireConcurrencyGate(user, operation, options = {}) {
8141
+ await ensureConcurrencyGateSchema();
8142
+ const limit = concurrencyLimitForUser(user);
8143
+ if (isConcurrencyLimitBypassed(user)) {
8144
+ return { ok: true, lockId: null, active: 0, limit, operation, bypassed: true };
8145
+ }
8146
+ if (options.reuseLockId && await reuseExistingConcurrencyGate(user.id, options.reuseLockId, options.ttlSeconds)) {
8147
+ return { ok: true, lockId: null, active: await countActiveUsageForUser(user.id), limit, operation, reused: true };
8148
+ }
8149
+ await expireConcurrencyLocksForUser(user.id);
8150
+ const lockId = `cl_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
8151
+ const res = await getDb().execute({
8152
+ sql: `INSERT INTO concurrency_locks (id, user_id, operation, status, expires_at, metadata)
8153
+ SELECT ?, ?, ?, 'active', datetime('now', ?), ?
8154
+ WHERE (
8155
+ (
8156
+ SELECT COUNT(*)
8157
+ FROM jobs
8158
+ WHERE user_id = ?
8159
+ AND (status = 'pending' OR (status = 'running' AND started_at > datetime('now', '-10 minutes')))
8160
+ ) +
8161
+ (
8162
+ SELECT COUNT(*)
8163
+ FROM concurrency_locks
8164
+ WHERE user_id = ? AND status = 'active' AND expires_at > datetime('now')
8165
+ )
8166
+ ) < ?`,
8167
+ args: [
8168
+ lockId,
8169
+ user.id,
8170
+ operation,
8171
+ lockTtlModifier(options.ttlSeconds),
8172
+ safeMetadata(options.metadata),
8173
+ user.id,
8174
+ user.id,
8175
+ limit
8176
+ ]
8177
+ });
8178
+ const active = await countActiveUsageForUser(user.id);
8179
+ if (Number(res.rowsAffected ?? 0) > 0) {
8180
+ return { ok: true, lockId, active, limit, operation };
8181
+ }
8182
+ return { ok: false, active, limit, operation, retryAfterSeconds: DEFAULT_RETRY_AFTER_SECONDS };
8183
+ }
8184
+ async function releaseConcurrencyGate(lockId) {
8185
+ if (!lockId) return;
8186
+ await ensureConcurrencyGateSchema();
8187
+ await getDb().execute({
8188
+ sql: `UPDATE concurrency_locks
8189
+ SET status = 'released', released_at = datetime('now')
8190
+ WHERE id = ? AND status = 'active'`,
8191
+ args: [lockId]
8192
+ });
8193
+ }
8194
+ async function extendConcurrencyGate(lockId, ttlSeconds = DEFAULT_LOCK_TTL_SECONDS) {
8195
+ if (!lockId) return;
8196
+ await ensureConcurrencyGateSchema();
8197
+ await getDb().execute({
8198
+ sql: `UPDATE concurrency_locks
8199
+ SET expires_at = datetime('now', ?)
8200
+ WHERE id = ? AND status = 'active' AND expires_at > datetime('now')`,
8201
+ args: [lockTtlModifier(ttlSeconds), lockId]
8202
+ });
8203
+ }
8204
+
8032
8205
  // src/api/youtube-routes.ts
8033
8206
  var youtubeApp = new Hono2();
8034
8207
  youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
@@ -8043,9 +8216,16 @@ youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
8043
8216
  return c.json({ error: "channel mode requires channelHandle (@handle or UC... ID)" }, 400);
8044
8217
  }
8045
8218
  const user = c.get("user");
8046
- const { ok: ytOk, balance_mc: ytBal } = await debitMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL, query ?? channelHandle ?? "");
8047
- if (!ytOk) return c.json(insufficientBalanceResponse(ytBal, MC_COSTS.yt_channel), 402);
8219
+ const gate = await acquireConcurrencyGate(user, "youtube_harvest", {
8220
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
8221
+ metadata: { mode, query, channelHandle }
8222
+ });
8223
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
8224
+ let debited = false;
8048
8225
  try {
8226
+ const { ok: ytOk, balance_mc: ytBal } = await debitMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL, query ?? channelHandle ?? "");
8227
+ if (!ytOk) return c.json(insufficientBalanceResponse(ytBal, MC_COSTS.yt_channel), 402);
8228
+ debited = true;
8049
8229
  const normalizedChannelUrl = mode === "channel" ? buildYouTubeChannelVideosUrl(channelHandle.trim()) : void 0;
8050
8230
  const result = await ytHarvest({
8051
8231
  mode,
@@ -8070,13 +8250,15 @@ youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
8070
8250
  return c.json({ error: msg }, 400);
8071
8251
  }
8072
8252
  if (err instanceof CaptchaError || msg.includes("CAPTCHA") || msg.includes("blocked")) {
8073
- await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
8253
+ if (debited) await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
8074
8254
  await logRequestEvent({ userId: user.id, source: "youtube_harvest", status: "failed", query: mode === "search" ? query?.trim() ?? "" : channelHandle?.trim() ?? "", error: msg });
8075
8255
  return c.json({ error: msg }, 503);
8076
8256
  }
8077
- await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
8257
+ if (debited) await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
8078
8258
  await logRequestEvent({ userId: user.id, source: "youtube_harvest", status: "failed", query: mode === "search" ? query?.trim() ?? "" : channelHandle?.trim() ?? "", error: msg });
8079
8259
  return c.json({ error: msg }, 500);
8260
+ } finally {
8261
+ await releaseConcurrencyGate(gate.lockId);
8080
8262
  }
8081
8263
  });
8082
8264
  youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
@@ -8088,9 +8270,16 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
8088
8270
  const user = c.get("user");
8089
8271
  const holdMins = 5;
8090
8272
  const holdMc = MC_COSTS.yt_transcription * holdMins;
8091
- const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
8092
- if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
8273
+ const gate = await acquireConcurrencyGate(user, "youtube_transcribe", {
8274
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
8275
+ metadata: { videoId: id }
8276
+ });
8277
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
8278
+ let debited = false;
8093
8279
  try {
8280
+ const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
8281
+ if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
8282
+ debited = true;
8094
8283
  const result = await fetchCaptions(id);
8095
8284
  const lastChunk = result.chunks?.[result.chunks.length - 1];
8096
8285
  const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
@@ -8115,10 +8304,12 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
8115
8304
  html: buildTranscriptHtml(result)
8116
8305
  });
8117
8306
  } catch (err) {
8118
- await creditMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_REFUND, "failed call");
8307
+ if (debited) await creditMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_REFUND, "failed call");
8119
8308
  const msg = err instanceof Error ? err.message : String(err);
8120
8309
  await logRequestEvent({ userId: user.id, source: "youtube_transcribe", status: "failed", query: id, error: msg });
8121
8310
  return c.json({ error: msg }, 500);
8311
+ } finally {
8312
+ await releaseConcurrencyGate(gate.lockId);
8122
8313
  }
8123
8314
  });
8124
8315
  function buildTranscriptMarkdown(result) {
@@ -8187,6 +8378,7 @@ screenshotApp.post("/", async (c) => {
8187
8378
  }
8188
8379
  const body = parsed.data;
8189
8380
  const urlCheck = await validatePublicHttpUrl(body.url, { field: "url", requireHttps: false });
8381
+ let targetUrl = urlCheck.parsed?.href;
8190
8382
  if (urlCheck.error) {
8191
8383
  if (!body.allowLocal) {
8192
8384
  return c.json({ error_code: "invalid_request", message: urlCheck.error }, 400);
@@ -8200,25 +8392,17 @@ screenshotApp.post("/", async (c) => {
8200
8392
  if (parsedFallback.protocol !== "http:" && parsedFallback.protocol !== "https:") {
8201
8393
  return c.json({ error: "url must use http or https" }, 400);
8202
8394
  }
8203
- const device2 = body.device === "mobile" ? "mobile" : "desktop";
8204
- try {
8205
- const buf = await captureScreenshot(parsedFallback.href, browserServiceApiKey(), device2);
8206
- return new Response(new Uint8Array(buf), {
8207
- status: 200,
8208
- headers: {
8209
- "Content-Type": "image/png",
8210
- "Content-Length": String(buf.length),
8211
- "Cache-Control": "no-store"
8212
- }
8213
- });
8214
- } catch (err) {
8215
- const msg = err instanceof Error ? err.message : String(err);
8216
- return c.json({ error: msg }, 502);
8217
- }
8395
+ targetUrl = parsedFallback.href;
8218
8396
  }
8219
8397
  const device = body.device === "mobile" ? "mobile" : "desktop";
8398
+ const user = c.get("user");
8399
+ const gate = await acquireConcurrencyGate(user, "screenshot", {
8400
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
8401
+ metadata: { url: targetUrl, device }
8402
+ });
8403
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
8220
8404
  try {
8221
- const buf = await captureScreenshot(urlCheck.parsed.href, browserServiceApiKey(), device);
8405
+ const buf = await captureScreenshot(targetUrl, browserServiceApiKey(), device);
8222
8406
  return new Response(new Uint8Array(buf), {
8223
8407
  status: 200,
8224
8408
  headers: {
@@ -8230,6 +8414,8 @@ screenshotApp.post("/", async (c) => {
8230
8414
  } catch (err) {
8231
8415
  const msg = err instanceof Error ? err.message : String(err);
8232
8416
  return c.json({ error: msg }, 502);
8417
+ } finally {
8418
+ await releaseConcurrencyGate(gate.lockId);
8233
8419
  }
8234
8420
  });
8235
8421
 
@@ -9003,6 +9189,127 @@ function advertisersFromResults(results, maxResults) {
9003
9189
  return [...byPage.values()].map((e) => ({ pageName: e.pageName, pageId: e.pageId, sampleLibraryId: e.sampleLibraryId, adCount: Math.max(e.maxCollation, e.resultCount) })).sort((a, b) => b.adCount - a.adCount).slice(0, maxResults);
9004
9190
  }
9005
9191
 
9192
+ // src/extractor/FacebookOrganicVideoExtractor.ts
9193
+ function htmlDecode(value) {
9194
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
9195
+ }
9196
+ function decodeEscapedValue(raw) {
9197
+ let out = raw;
9198
+ try {
9199
+ out = JSON.parse(`"${raw.replace(/"/g, '\\"')}"`);
9200
+ } catch {
9201
+ out = raw.replace(/\\u0025/g, "%").replace(/\\u0026/g, "&").replace(/\\u003D/g, "=").replace(/\\u003d/g, "=").replace(/\\"/g, '"').replace(/\\\//g, "/");
9202
+ }
9203
+ return htmlDecode(out);
9204
+ }
9205
+ function facebookVideoIdFromUrl(url) {
9206
+ try {
9207
+ const parsed = new URL(url);
9208
+ const path5 = parsed.pathname;
9209
+ const direct = path5.match(/\/(?:reel|videos|watch)\/(?:[^/]+\/)?(\d{8,})/i);
9210
+ if (direct) return direct[1];
9211
+ const queryVideoId = parsed.searchParams.get("v") ?? parsed.searchParams.get("video_id");
9212
+ if (queryVideoId && /^\d{8,}$/.test(queryVideoId)) return queryVideoId;
9213
+ const numericTail = path5.match(/\/(\d{8,})\/?$/);
9214
+ if (numericTail) return numericTail[1];
9215
+ } catch {
9216
+ }
9217
+ return null;
9218
+ }
9219
+ function parseEfg(url) {
9220
+ try {
9221
+ const efg = new URL(url).searchParams.get("efg");
9222
+ if (!efg) return null;
9223
+ return JSON.parse(Buffer.from(efg, "base64").toString("utf8"));
9224
+ } catch {
9225
+ return null;
9226
+ }
9227
+ }
9228
+ function manifestDurationSec(htmlWindow) {
9229
+ const decoded = decodeEscapedValue(htmlWindow);
9230
+ const match = decoded.match(/mediaPresentationDuration="PT([\d.]+)S"/);
9231
+ return match ? Number(match[1]) : null;
9232
+ }
9233
+ function metadataContent(html, key) {
9234
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9235
+ const re = new RegExp(`<meta\\s+[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
9236
+ const m = html.match(re);
9237
+ return m ? htmlDecode(m[1]).trim() : null;
9238
+ }
9239
+ function ownerNameFromHtml(html) {
9240
+ const ogTitle = metadataContent(html, "og:title");
9241
+ const pipe = ogTitle?.split("|").map((s) => s.trim()).filter(Boolean);
9242
+ if (pipe && pipe.length > 1) return pipe[pipe.length - 1] ?? null;
9243
+ return null;
9244
+ }
9245
+ function extractFacebookOrganicVideo(html, pageUrl, sourceUrl = pageUrl, quality = "best") {
9246
+ const videoId = facebookVideoIdFromUrl(pageUrl) ?? facebookVideoIdFromUrl(sourceUrl);
9247
+ const manifestIndex = videoId ? html.indexOf(`dash_mpd_debug.mpd?v=${videoId}`) : -1;
9248
+ const searchWindow = manifestIndex >= 0 ? html.slice(Math.max(0, manifestIndex - 5e4), Math.min(html.length, manifestIndex + 5e4)) : html;
9249
+ const targetDuration = manifestDurationSec(searchWindow);
9250
+ const candidates = [];
9251
+ const seen = /* @__PURE__ */ new Set();
9252
+ const re = /browser_native_(sd|hd)_url\\?"\s*:\s*\\?"([^"<]+?)\\?"/g;
9253
+ let match;
9254
+ while ((match = re.exec(searchWindow)) !== null) {
9255
+ const url = decodeEscapedValue(match[2]);
9256
+ if (!url || seen.has(url)) continue;
9257
+ seen.add(url);
9258
+ const parsed = parseEfg(url);
9259
+ let bitrate = null;
9260
+ try {
9261
+ const value = new URL(url).searchParams.get("bitrate");
9262
+ bitrate = value ? Number(value) : null;
9263
+ } catch {
9264
+ }
9265
+ candidates.push({
9266
+ url,
9267
+ quality: match[1],
9268
+ bitrate: Number.isFinite(bitrate) ? bitrate : null,
9269
+ durationSec: typeof parsed?.duration_s === "number" ? parsed.duration_s : null,
9270
+ assetId: typeof parsed?.xpv_asset_id === "number" ? parsed.xpv_asset_id : null,
9271
+ vencodeTag: typeof parsed?.vencode_tag === "string" ? parsed.vencode_tag : null
9272
+ });
9273
+ }
9274
+ const targetCandidates = candidates.filter((candidate) => {
9275
+ if (!targetDuration || candidate.durationSec == null) return true;
9276
+ return Math.abs(candidate.durationSec - targetDuration) <= 2;
9277
+ });
9278
+ const selectable = targetCandidates.length ? targetCandidates : candidates;
9279
+ const selected = [...selectable].filter((candidate) => quality === "best" || candidate.quality === quality).sort((a, b) => {
9280
+ const aq = a.quality === "hd" ? 1 : 0;
9281
+ const bq = b.quality === "hd" ? 1 : 0;
9282
+ if (quality === "best" && aq !== bq) return bq - aq;
9283
+ return (b.bitrate ?? 0) - (a.bitrate ?? 0);
9284
+ })[0];
9285
+ if (!selected) {
9286
+ throw new Error("No progressive Facebook video URL was found in the rendered page state");
9287
+ }
9288
+ return {
9289
+ sourceUrl,
9290
+ pageUrl,
9291
+ videoId,
9292
+ title: metadataContent(html, "og:title") ?? metadataContent(html, "twitter:title"),
9293
+ description: metadataContent(html, "og:description") ?? metadataContent(html, "description") ?? metadataContent(html, "twitter:description"),
9294
+ ownerName: ownerNameFromHtml(html),
9295
+ videoUrl: selected.url,
9296
+ selectedQuality: selected.quality,
9297
+ bitrate: selected.bitrate,
9298
+ durationSec: targetDuration ?? selected.durationSec,
9299
+ assetId: selected.assetId,
9300
+ vencodeTag: selected.vencodeTag,
9301
+ candidates: selectable,
9302
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString()
9303
+ };
9304
+ }
9305
+ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "best") {
9306
+ await page.waitForLoadState("domcontentloaded", { timeout: 15e3 }).catch(() => {
9307
+ });
9308
+ await page.waitForTimeout(1500);
9309
+ const html = await page.content();
9310
+ return extractFacebookOrganicVideo(html, page.url(), sourceUrl, quality);
9311
+ }
9312
+
9006
9313
  // src/api/facebook-ad-routes.ts
9007
9314
  import { fal as fal2 } from "@fal-ai/client";
9008
9315
  var FacebookAdBodySchema = z13.object({
@@ -9022,6 +9329,10 @@ var FacebookPageIntelBodySchema = z13.object({
9022
9329
  var FacebookTranscribeBodySchema = z13.object({
9023
9330
  videoUrl: z13.string().trim().min(1, "videoUrl is required")
9024
9331
  });
9332
+ var FacebookVideoTranscribeBodySchema = z13.object({
9333
+ url: z13.string().trim().min(1, "url is required"),
9334
+ quality: z13.enum(["best", "hd", "sd"]).default("best")
9335
+ });
9025
9336
  var FacebookSearchBodySchema = z13.object({
9026
9337
  query: z13.string().trim().min(1, "query is required"),
9027
9338
  country: z13.string().trim().toUpperCase().optional(),
@@ -9034,6 +9345,39 @@ var FacebookMediaBodySchema = z13.object({
9034
9345
  function invalidRequest(message) {
9035
9346
  return { error_code: "invalid_request", message };
9036
9347
  }
9348
+ function isAllowedFacebookOrganicUrl(url) {
9349
+ const hostname = url.hostname.toLowerCase().replace(/^www\./, "").replace(/^m\./, "");
9350
+ return hostname === "facebook.com" || hostname === "fb.watch";
9351
+ }
9352
+ function facebookTranscriptMarkdown(title, text, chunks, durationMs) {
9353
+ const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
9354
+ const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
9355
+ if (chunks.length) {
9356
+ lines.push("## Timestamped Segments", "");
9357
+ for (const ch of chunks) {
9358
+ lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
9359
+ }
9360
+ }
9361
+ return lines.join("\n");
9362
+ }
9363
+ async function transcribeFacebookVideoUrl(videoUrl, markdownTitle = "# Facebook Video Transcript") {
9364
+ const startMs = Date.now();
9365
+ const result = await fal2.subscribe("fal-ai/wizper", {
9366
+ input: { audio_url: videoUrl, task: "transcribe", language: "en" },
9367
+ logs: false,
9368
+ pollInterval: 3e3
9369
+ });
9370
+ const data = result.data;
9371
+ const text = data.text ?? "";
9372
+ const chunks = data.chunks ?? [];
9373
+ const durationMs = Date.now() - startMs;
9374
+ return {
9375
+ text,
9376
+ chunks,
9377
+ durationMs,
9378
+ markdown: facebookTranscriptMarkdown(markdownTitle, text, chunks, durationMs)
9379
+ };
9380
+ }
9037
9381
  async function detectSoftBlock(driver) {
9038
9382
  const page = driver.getPage();
9039
9383
  const bodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
@@ -9075,10 +9419,17 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
9075
9419
  const libraryId = FacebookAdExtractor.resolveLibraryId(raw2);
9076
9420
  if (!libraryId) return c.json({ error: "Could not resolve a valid Facebook Ad Library ID from the provided input" }, 400);
9077
9421
  const fbUser = c.get("user");
9078
- const { ok: adOk, balance_mc: adBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, raw2);
9079
- if (!adOk) return c.json(insufficientBalanceResponse(adBal, MC_COSTS.fb_ad), 402);
9422
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_ad", {
9423
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9424
+ metadata: { libraryId }
9425
+ });
9426
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9080
9427
  const driver = new BrowserDriver();
9428
+ let debited = false;
9081
9429
  try {
9430
+ const { ok: adOk, balance_mc: adBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, raw2);
9431
+ if (!adOk) return c.json(insufficientBalanceResponse(adBal, MC_COSTS.fb_ad), 402);
9432
+ debited = true;
9082
9433
  await driver.launch(kernelLaunchOpts());
9083
9434
  const extractor = new FacebookAdExtractor(driver);
9084
9435
  const result = await extractor.extract(libraryId, { openModal: body.openModal !== false });
@@ -9092,7 +9443,7 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
9092
9443
  });
9093
9444
  return c.json(result);
9094
9445
  } catch (err) {
9095
- await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9446
+ if (debited) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9096
9447
  const msg = err instanceof Error ? err.message : String(err);
9097
9448
  await logRequestEvent({ userId: fbUser.id, source: "facebook_ad", status: "failed", query: raw2, error: msg });
9098
9449
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
@@ -9101,24 +9452,26 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
9101
9452
  return c.json({ error: msg }, 500);
9102
9453
  } finally {
9103
9454
  await driver.close();
9455
+ await releaseConcurrencyGate(gate.lockId);
9104
9456
  }
9105
9457
  });
9106
9458
  facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
9107
9459
  const raw = await c.req.json().catch(() => ({}));
9108
9460
  const parsed = FacebookPageIntelBodySchema.safeParse(raw);
9109
- if (!parsed.success) {
9110
- return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
9111
- }
9461
+ if (!parsed.success) return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
9112
9462
  const body = parsed.data;
9113
9463
  const maxAds = Math.min(200, Math.max(1, body.maxAds ?? 50));
9114
9464
  const country = body.country?.trim().toUpperCase() ?? "US";
9115
9465
  const listingUrl = buildPageIntelUrl(body, country);
9116
9466
  const fbUser = c.get("user");
9117
- const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, body.pageId ?? body.query ?? body.libraryId ?? "");
9118
- if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_ad), 402);
9467
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_page_intel", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { pageId: body.pageId, query: body.query, libraryId: body.libraryId, country } });
9468
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9119
9469
  const driver = new BrowserDriver();
9120
- let refunded = false;
9470
+ let refunded = false, debited = false;
9121
9471
  try {
9472
+ const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, body.pageId ?? body.query ?? body.libraryId ?? "");
9473
+ if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_ad), 402);
9474
+ debited = true;
9122
9475
  await driver.launch(await kernelLaunchOptsResidential());
9123
9476
  await driver.navigateTo(listingUrl);
9124
9477
  const extractor = new FacebookAdExtractor(driver);
@@ -9133,7 +9486,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
9133
9486
  return c.json(result);
9134
9487
  } catch (err) {
9135
9488
  const msg = err instanceof Error ? err.message : String(err);
9136
- if (!refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9489
+ if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9137
9490
  await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: body.pageId ?? body.query ?? body.libraryId ?? "", error: msg });
9138
9491
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
9139
9492
  return c.json({ error: msg }, 503);
@@ -9141,6 +9494,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
9141
9494
  return c.json({ error: msg }, 500);
9142
9495
  } finally {
9143
9496
  await driver.close();
9497
+ await releaseConcurrencyGate(gate.lockId);
9144
9498
  }
9145
9499
  });
9146
9500
  facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
@@ -9156,35 +9510,83 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
9156
9510
  }
9157
9511
  const videoUrl = urlCheck.parsed.href;
9158
9512
  const fbUser = c.get("user");
9159
- const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
9160
- if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
9513
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_transcribe", {
9514
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9515
+ metadata: { videoUrl }
9516
+ });
9517
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9161
9518
  fal2.config({ credentials: process.env.FAL_KEY });
9519
+ let debited = false;
9162
9520
  try {
9163
- const startMs = Date.now();
9164
- const result = await fal2.subscribe("fal-ai/wizper", {
9165
- input: { audio_url: videoUrl, task: "transcribe", language: "en" },
9166
- logs: false,
9167
- pollInterval: 3e3
9168
- });
9169
- const data = result.data;
9170
- const text = data.text ?? "";
9171
- const chunks = data.chunks ?? [];
9172
- const durationMs = Date.now() - startMs;
9173
- const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
9174
- const lines = ["# Facebook Ad Transcript", "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
9175
- if (chunks.length) {
9176
- lines.push("## Timestamped Segments", "");
9177
- for (const ch of chunks) {
9178
- lines.push(`**[${fmtTs2(ch.timestamp[0])} \u2192 ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
9179
- }
9180
- }
9181
- await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "done", query: videoUrl, resultCount: chunks.length, result: { text, chunks, durationMs } });
9182
- return c.json({ text, chunks, durationMs, markdown: lines.join("\n") });
9521
+ const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
9522
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
9523
+ debited = true;
9524
+ const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
9525
+ await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "done", query: videoUrl, resultCount: transcript.chunks.length, result: { text: transcript.text, chunks: transcript.chunks, durationMs: transcript.durationMs } });
9526
+ return c.json(transcript);
9183
9527
  } catch (err) {
9184
9528
  const msg = err instanceof Error ? err.message : String(err);
9185
- await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
9529
+ if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
9186
9530
  await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "failed", query: videoUrl, error: msg });
9187
9531
  return c.json({ error: msg }, 500);
9532
+ } finally {
9533
+ await releaseConcurrencyGate(gate.lockId);
9534
+ }
9535
+ });
9536
+ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
9537
+ const raw = await c.req.json().catch(() => ({}));
9538
+ const parsed = FacebookVideoTranscribeBodySchema.safeParse(raw);
9539
+ if (!parsed.success) {
9540
+ return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
9541
+ }
9542
+ const body = parsed.data;
9543
+ const urlCheck = await validatePublicHttpUrl(body.url, { field: "url", requireHttps: false });
9544
+ if (urlCheck.error) {
9545
+ return c.json(invalidRequest(urlCheck.error), 400);
9546
+ }
9547
+ const sourceUrl = urlCheck.parsed;
9548
+ if (!isAllowedFacebookOrganicUrl(sourceUrl)) {
9549
+ return c.json(invalidRequest("url must be a Facebook organic video, reel, or share URL"), 400);
9550
+ }
9551
+ const fbUser = c.get("user");
9552
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_video_transcribe", {
9553
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9554
+ metadata: { url: sourceUrl.href }
9555
+ });
9556
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9557
+ fal2.config({ credentials: process.env.FAL_KEY });
9558
+ const driver = new BrowserDriver();
9559
+ let debited = false;
9560
+ try {
9561
+ const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
9562
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
9563
+ debited = true;
9564
+ await driver.launch(await kernelLaunchOptsResidential());
9565
+ await driver.navigateTo(sourceUrl.href);
9566
+ const page = driver.getPage();
9567
+ const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
9568
+ const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
9569
+ const result = {
9570
+ ...video,
9571
+ videoDurationSec: video.durationSec,
9572
+ text: transcript.text,
9573
+ chunks: transcript.chunks,
9574
+ durationMs: transcript.durationMs,
9575
+ markdown: transcript.markdown
9576
+ };
9577
+ await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "done", query: sourceUrl.href, resultCount: transcript.chunks.length, result });
9578
+ return c.json(result);
9579
+ } catch (err) {
9580
+ const msg = err instanceof Error ? err.message : String(err);
9581
+ if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
9582
+ await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "failed", query: sourceUrl.href, error: msg });
9583
+ if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
9584
+ return c.json({ error: msg }, 503);
9585
+ }
9586
+ return c.json({ error: msg }, 500);
9587
+ } finally {
9588
+ await driver.close();
9589
+ await releaseConcurrencyGate(gate.lockId);
9188
9590
  }
9189
9591
  });
9190
9592
  facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
@@ -9198,11 +9600,18 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9198
9600
  const maxResults = Math.min(20, Math.max(1, body.maxResults ?? 10));
9199
9601
  const searchUrl = `https://www.facebook.com/ads/library/?active_status=all&ad_type=all&country=${country}&q=${encodeURIComponent(body.query.trim())}&search_type=keyword_unordered`;
9200
9602
  const fbUser = c.get("user");
9201
- const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH, body.query.trim());
9202
- if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_search), 402);
9603
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_search", {
9604
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9605
+ metadata: { query: body.query.trim(), country }
9606
+ });
9607
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9203
9608
  const driver = new BrowserDriver();
9204
9609
  let searchRefunded = false;
9610
+ let debited = false;
9205
9611
  try {
9612
+ const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH, body.query.trim());
9613
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_search), 402);
9614
+ debited = true;
9206
9615
  await driver.launch(await kernelLaunchOptsResidential());
9207
9616
  const page = driver.getPage();
9208
9617
  const collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
@@ -9263,7 +9672,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9263
9672
  return c.json(searchResult);
9264
9673
  } catch (err) {
9265
9674
  const msg = err instanceof Error ? err.message : String(err);
9266
- if (!searchRefunded) await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "failed call");
9675
+ if (debited && !searchRefunded) await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "failed call");
9267
9676
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: msg });
9268
9677
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
9269
9678
  return c.json({ error: msg }, 503);
@@ -9271,6 +9680,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9271
9680
  return c.json({ error: msg }, 500);
9272
9681
  } finally {
9273
9682
  await driver.close();
9683
+ await releaseConcurrencyGate(gate.lockId);
9274
9684
  }
9275
9685
  });
9276
9686
  var ALLOWED_MEDIA_HOSTS = ["fbcdn.net", "cdninstagram.com", "scontent.facebook.com", "scontent.cdninstagram.com"];
@@ -10129,14 +10539,21 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
10129
10539
  if (!parsed.success) {
10130
10540
  return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
10131
10541
  }
10132
- const { ok, balance_mc } = await debitMc(
10133
- user.id,
10134
- MC_COSTS.maps_search,
10135
- LedgerOperation.MAPS_SEARCH,
10136
- [parsed.data.query, parsed.data.location].filter(Boolean).join(" ")
10137
- );
10138
- if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
10542
+ const gate = await acquireConcurrencyGate(user, "maps_search", {
10543
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
10544
+ metadata: { query: parsed.data.query, location: parsed.data.location }
10545
+ });
10546
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10547
+ let debited = false;
10139
10548
  try {
10549
+ const { ok, balance_mc } = await debitMc(
10550
+ user.id,
10551
+ MC_COSTS.maps_search,
10552
+ LedgerOperation.MAPS_SEARCH,
10553
+ [parsed.data.query, parsed.data.location].filter(Boolean).join(" ")
10554
+ );
10555
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
10556
+ debited = true;
10140
10557
  const result = await runMapsSearchWithRotation({
10141
10558
  ...parsed.data,
10142
10559
  configuredKernelProxyId: browserServiceProxyId()
@@ -10152,7 +10569,7 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
10152
10569
  });
10153
10570
  return c.json(result);
10154
10571
  } catch (err) {
10155
- await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
10572
+ if (debited) await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
10156
10573
  const msg = err instanceof Error ? err.message : String(err);
10157
10574
  await logRequestEvent({
10158
10575
  userId: user.id,
@@ -10164,6 +10581,8 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
10164
10581
  result: err instanceof MapsSearchRotationError ? { attempts: err.attempts } : void 0
10165
10582
  });
10166
10583
  return mapsErrorResponse(c, err, "maps_search_failed");
10584
+ } finally {
10585
+ await releaseConcurrencyGate(gate.lockId);
10167
10586
  }
10168
10587
  });
10169
10588
  mapsApp.post("/place", createApiKeyAuth(), async (c) => {
@@ -10176,17 +10595,24 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
10176
10595
  if (!parsed.success) {
10177
10596
  return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
10178
10597
  }
10179
- const { ok, balance_mc } = await debitMc(
10180
- user.id,
10181
- MC_COSTS.maps_place,
10182
- LedgerOperation.MAPS_PLACE,
10183
- `${parsed.data.businessName} ${parsed.data.location}`
10184
- );
10185
- if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
10598
+ const gate = await acquireConcurrencyGate(user, "maps_place", {
10599
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
10600
+ metadata: { businessName: parsed.data.businessName, location: parsed.data.location }
10601
+ });
10602
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10186
10603
  const driver = new BrowserDriver();
10187
10604
  const extractor = new MapsExtractor(driver);
10188
10605
  let reviewDebitMc = 0;
10606
+ let debited = false;
10189
10607
  try {
10608
+ const { ok, balance_mc } = await debitMc(
10609
+ user.id,
10610
+ MC_COSTS.maps_place,
10611
+ LedgerOperation.MAPS_PLACE,
10612
+ `${parsed.data.businessName} ${parsed.data.location}`
10613
+ );
10614
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
10615
+ debited = true;
10190
10616
  const result = await extractor.extract(parsed.data);
10191
10617
  const reviewCount = Array.isArray(result.reviews) ? result.reviews.length : 0;
10192
10618
  if (reviewCount > 0) {
@@ -10214,7 +10640,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
10214
10640
  });
10215
10641
  return c.json(result);
10216
10642
  } catch (err) {
10217
- await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
10643
+ if (debited) await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
10218
10644
  if (reviewDebitMc > 0) {
10219
10645
  await creditMc(
10220
10646
  user.id,
@@ -10235,6 +10661,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
10235
10661
  return mapsErrorResponse(c, msg, "maps_place_failed");
10236
10662
  } finally {
10237
10663
  await driver.close();
10664
+ await releaseConcurrencyGate(gate.lockId);
10238
10665
  }
10239
10666
  });
10240
10667
 
@@ -10658,18 +11085,27 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
10658
11085
  if (!kernelApiKey && parsed.data.proxyMode !== "none") {
10659
11086
  return c.json({ error: "Browser service API key is required for directory workflow Maps searches unless proxyMode is none" }, 503);
10660
11087
  }
10661
- const plan = await resolveDirectoryMarkets(parsed.data);
10662
- const requiredMc = plan.markets.length * MC_COSTS.maps_search;
10663
- if (requiredMc > 0) {
10664
- const debit = await debitMc(
10665
- user.id,
10666
- requiredMc,
10667
- LedgerOperation.MAPS_SEARCH,
10668
- `directory_workflow ${parsed.data.query} ${parsed.data.state} ${plan.markets.length} cities`
10669
- );
10670
- if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
10671
- }
11088
+ const gate = await acquireConcurrencyGate(user, "directory_workflow", {
11089
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
11090
+ ttlSeconds: 30 * 60,
11091
+ metadata: { query: parsed.data.query, state: parsed.data.state }
11092
+ });
11093
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11094
+ let debited = false;
11095
+ let requiredMc = 0;
10672
11096
  try {
11097
+ const plan = await resolveDirectoryMarkets(parsed.data);
11098
+ requiredMc = plan.markets.length * MC_COSTS.maps_search;
11099
+ if (requiredMc > 0) {
11100
+ const debit = await debitMc(
11101
+ user.id,
11102
+ requiredMc,
11103
+ LedgerOperation.MAPS_SEARCH,
11104
+ `directory_workflow ${parsed.data.query} ${parsed.data.state} ${plan.markets.length} cities`
11105
+ );
11106
+ if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
11107
+ debited = true;
11108
+ }
10673
11109
  const result = await runDirectoryWorkflowFromPlan(parsed.data, plan);
10674
11110
  const failedCities = result.cities.filter((city) => city.status === "failed").length;
10675
11111
  if (failedCities > 0) {
@@ -10686,7 +11122,7 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
10686
11122
  });
10687
11123
  return c.json(result);
10688
11124
  } catch (err) {
10689
- if (requiredMc > 0) {
11125
+ if (debited && requiredMc > 0) {
10690
11126
  await creditMc(user.id, requiredMc, LedgerOperation.REFUND, "failed directory_workflow call");
10691
11127
  }
10692
11128
  const message = err instanceof Error ? err.message : String(err);
@@ -10699,6 +11135,8 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
10699
11135
  error: message
10700
11136
  });
10701
11137
  return c.json({ error: message, error_code: "directory_workflow_failed", retryable: true }, 500);
11138
+ } finally {
11139
+ await releaseConcurrencyGate(gate.lockId);
10702
11140
  }
10703
11141
  });
10704
11142
 
@@ -10845,7 +11283,8 @@ async function executeWorkflowRun(input) {
10845
11283
  apiKey: input.user.api_key,
10846
11284
  apiUrl: input.apiUrl,
10847
11285
  outputDir: hostedWorkflowOutputDir(),
10848
- runId: input.runId
11286
+ runId: input.runId,
11287
+ headers: input.concurrencyLockId ? { "x-mcp-scraper-concurrency-lock": input.concurrencyLockId } : void 0
10849
11288
  });
10850
11289
  const manifest = await readManifestFromSummary(summary);
10851
11290
  await completeWorkflowRunRecord(input.runId, summary.status, manifest ?? { summary });
@@ -10878,20 +11317,29 @@ workflowApp.post("/run", createApiKeyAuth(), async (c) => {
10878
11317
  } catch (err) {
10879
11318
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
10880
11319
  }
10881
- const run = await createWorkflowRun({ userId: user.id, workflowId: parsed.data.workflowId, workflowInput: input });
11320
+ const gate = await acquireConcurrencyGate(user, "workflow_run", {
11321
+ ttlSeconds: 60 * 60,
11322
+ metadata: { workflowId: parsed.data.workflowId }
11323
+ });
11324
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11325
+ let run = null;
10882
11326
  try {
11327
+ run = await createWorkflowRun({ userId: user.id, workflowId: parsed.data.workflowId, workflowInput: input });
10883
11328
  const executed = await executeWorkflowRun({
10884
11329
  runId: run.id,
10885
11330
  user,
10886
11331
  workflowId: parsed.data.workflowId,
10887
11332
  workflowInput: input,
10888
11333
  apiUrl: originFromUrl(c.req.url),
10889
- webhookUrl
11334
+ webhookUrl,
11335
+ concurrencyLockId: gate.lockId
10890
11336
  });
10891
11337
  return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
10892
11338
  } catch (err) {
10893
- const failed = await getWorkflowRun(run.id, user.id);
11339
+ const failed = run ? await getWorkflowRun(run.id, user.id) : null;
10894
11340
  return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
11341
+ } finally {
11342
+ await releaseConcurrencyGate(gate.lockId);
10895
11343
  }
10896
11344
  });
10897
11345
  workflowApp.get("/runs", createApiKeyAuth(), async (c) => {
@@ -10989,8 +11437,14 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
10989
11437
  const user = c.get("user");
10990
11438
  const schedule = await getWorkflowSchedule(c.req.param("id"), user.id);
10991
11439
  if (!schedule) return c.json({ error: "Schedule not found" }, 404);
10992
- const run = await createWorkflowRun({ userId: user.id, scheduleId: schedule.id, workflowId: schedule.workflow_id, workflowInput: schedule.input });
11440
+ const gate = await acquireConcurrencyGate(user, "workflow_schedule_run", {
11441
+ ttlSeconds: 60 * 60,
11442
+ metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
11443
+ });
11444
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11445
+ let run = null;
10993
11446
  try {
11447
+ run = await createWorkflowRun({ userId: user.id, scheduleId: schedule.id, workflowId: schedule.workflow_id, workflowInput: schedule.input });
10994
11448
  const executed = await executeWorkflowRun({
10995
11449
  runId: run.id,
10996
11450
  user,
@@ -10998,12 +11452,15 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
10998
11452
  workflowInput: schedule.input,
10999
11453
  apiUrl: originFromUrl(c.req.url),
11000
11454
  scheduleId: schedule.id,
11001
- webhookUrl: schedule.webhook_url
11455
+ webhookUrl: schedule.webhook_url,
11456
+ concurrencyLockId: gate.lockId
11002
11457
  });
11003
11458
  return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
11004
11459
  } catch (err) {
11005
- const failed = await getWorkflowRun(run.id, user.id);
11460
+ const failed = run ? await getWorkflowRun(run.id, user.id) : null;
11006
11461
  return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
11462
+ } finally {
11463
+ await releaseConcurrencyGate(gate.lockId);
11007
11464
  }
11008
11465
  });
11009
11466
  async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
@@ -11017,19 +11474,28 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
11017
11474
  continue;
11018
11475
  }
11019
11476
  const scheduledFor = schedule.next_run_at ?? now;
11020
- const run = await createWorkflowRun({
11021
- userId: user.id,
11022
- scheduleId: schedule.id,
11023
- workflowId: schedule.workflow_id,
11024
- workflowInput: schedule.input,
11025
- idempotencyKey: `${schedule.id}:${scheduledFor}`
11477
+ const gate = await acquireConcurrencyGate(user, "workflow_schedule_dispatch", {
11478
+ ttlSeconds: 60 * 60,
11479
+ metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
11026
11480
  });
11027
- await markWorkflowScheduleRan(schedule.id, now, addCadence(scheduledFor, schedule.cadence));
11028
- if (run.status !== "queued") {
11029
- results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
11481
+ if (!gate.ok) {
11482
+ results.push({ scheduleId: schedule.id, status: "deferred", error: concurrencyLimitExceededResponse(gate).message });
11030
11483
  continue;
11031
11484
  }
11485
+ let run = null;
11032
11486
  try {
11487
+ run = await createWorkflowRun({
11488
+ userId: user.id,
11489
+ scheduleId: schedule.id,
11490
+ workflowId: schedule.workflow_id,
11491
+ workflowInput: schedule.input,
11492
+ idempotencyKey: `${schedule.id}:${scheduledFor}`
11493
+ });
11494
+ await markWorkflowScheduleRan(schedule.id, now, addCadence(scheduledFor, schedule.cadence));
11495
+ if (run.status !== "queued") {
11496
+ results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
11497
+ continue;
11498
+ }
11033
11499
  const executed = await executeWorkflowRun({
11034
11500
  runId: run.id,
11035
11501
  user,
@@ -11037,11 +11503,14 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
11037
11503
  workflowInput: schedule.input,
11038
11504
  apiUrl,
11039
11505
  scheduleId: schedule.id,
11040
- webhookUrl: schedule.webhook_url
11506
+ webhookUrl: schedule.webhook_url,
11507
+ concurrencyLockId: gate.lockId
11041
11508
  });
11042
11509
  results.push({ scheduleId: schedule.id, runId: executed.run.id, status: executed.run.status });
11043
11510
  } catch (err) {
11044
- results.push({ scheduleId: schedule.id, runId: run.id, status: "failed", error: err instanceof Error ? err.message : String(err) });
11511
+ results.push({ scheduleId: schedule.id, runId: run?.id, status: "failed", error: err instanceof Error ? err.message : String(err) });
11512
+ } finally {
11513
+ await releaseConcurrencyGate(gate.lockId);
11045
11514
  }
11046
11515
  }
11047
11516
  return { dispatched: results.length, results };
@@ -11803,26 +12272,6 @@ async function enforceSerpIntelligenceRateLimit(c, userId) {
11803
12272
  { "Retry-After": String(result.resetSeconds) }
11804
12273
  );
11805
12274
  }
11806
- async function enforceSerpIntelligenceConcurrency(user) {
11807
- const active = await countActiveJobsForUser(user.id);
11808
- const limit = 1 + (user.extra_concurrency_slots ?? 0);
11809
- if (active < limit) return null;
11810
- return new Response(
11811
- JSON.stringify(structuredError({
11812
- error_code: "concurrency_limit_exceeded",
11813
- error_type: "concurrency_limit",
11814
- message: `You have ${active} active job${active !== 1 ? "s" : ""}. Your account allows ${limit} concurrent job${limit !== 1 ? "s" : ""}.`,
11815
- retryable: true
11816
- })),
11817
- {
11818
- status: 429,
11819
- headers: {
11820
- "Content-Type": "application/json",
11821
- "Retry-After": "30"
11822
- }
11823
- }
11824
- );
11825
- }
11826
12275
  function pageSnapshotTargetsFromBody(body) {
11827
12276
  return body.targets?.length ? body.targets : body.urls.map((url) => ({
11828
12277
  url,
@@ -11838,17 +12287,22 @@ serpIntelligenceApp.post("/capture", async (c) => {
11838
12287
  if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
11839
12288
  const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
11840
12289
  if (limited) return limited;
11841
- const concurrencyLimited = await enforceSerpIntelligenceConcurrency(user);
11842
- if (concurrencyLimited) return concurrencyLimited;
12290
+ const gate = await acquireConcurrencyGate(user, "serp_intelligence_capture", {
12291
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
12292
+ metadata: { query: parsed.data.query, location: parsed.data.location }
12293
+ });
12294
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11843
12295
  const cost = MC_COSTS.serp;
11844
- const { ok, balance_mc } = await debitMc(
11845
- user.id,
11846
- cost,
11847
- LedgerOperation.SERP,
11848
- parsed.data.query
11849
- );
11850
- if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
12296
+ let debited = false;
11851
12297
  try {
12298
+ const { ok, balance_mc } = await debitMc(
12299
+ user.id,
12300
+ cost,
12301
+ LedgerOperation.SERP,
12302
+ parsed.data.query
12303
+ );
12304
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
12305
+ debited = true;
11852
12306
  const result = await captureSerpIntelligenceSnapshot(parsed.data, {
11853
12307
  kernelApiKey: browserServiceApiKey(),
11854
12308
  kernelProxyId: browserServiceProxyId(),
@@ -11866,7 +12320,7 @@ serpIntelligenceApp.post("/capture", async (c) => {
11866
12320
  });
11867
12321
  return c.json(result);
11868
12322
  } catch (error) {
11869
- await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
12323
+ if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
11870
12324
  const body = error instanceof SerpIntelligenceCaptureError ? error.toJSON() : structuredError({
11871
12325
  error_code: "capture_failed",
11872
12326
  error_type: "capture_error",
@@ -11883,6 +12337,8 @@ serpIntelligenceApp.post("/capture", async (c) => {
11883
12337
  });
11884
12338
  const status = error instanceof SerpIntelligenceCaptureError ? error.httpStatus : 500;
11885
12339
  return c.json(body, status);
12340
+ } finally {
12341
+ await releaseConcurrencyGate(gate.lockId);
11886
12342
  }
11887
12343
  });
11888
12344
  serpIntelligenceApp.post("/page-snapshots", async (c) => {
@@ -11893,18 +12349,23 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
11893
12349
  if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
11894
12350
  const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
11895
12351
  if (limited) return limited;
11896
- const concurrencyLimited = await enforceSerpIntelligenceConcurrency(user);
11897
- if (concurrencyLimited) return concurrencyLimited;
11898
12352
  const targets = pageSnapshotTargetsFromBody(parsed.data);
12353
+ const gate = await acquireConcurrencyGate(user, "serp_intelligence_page_snapshots", {
12354
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
12355
+ metadata: { targets: targets.length }
12356
+ });
12357
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11899
12358
  const cost = targets.length * MC_COSTS.page_scrape;
11900
- const { ok, balance_mc } = await debitMc(
11901
- user.id,
11902
- cost,
11903
- LedgerOperation.EXTRACT_URL,
11904
- `serp intelligence page snapshots: ${targets.length}`
11905
- );
11906
- if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
12359
+ let debited = false;
11907
12360
  try {
12361
+ const { ok, balance_mc } = await debitMc(
12362
+ user.id,
12363
+ cost,
12364
+ LedgerOperation.EXTRACT_URL,
12365
+ `serp intelligence page snapshots: ${targets.length}`
12366
+ );
12367
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
12368
+ debited = true;
11908
12369
  const result = await capturePageSnapshots(targets, {
11909
12370
  kernelApiKey: browserServiceApiKey(),
11910
12371
  timeoutMs: parsed.data.timeoutMs,
@@ -11921,7 +12382,7 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
11921
12382
  });
11922
12383
  return c.json(result);
11923
12384
  } catch (error) {
11924
- await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
12385
+ if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
11925
12386
  const body = structuredError({
11926
12387
  error_code: "page_snapshot_failed",
11927
12388
  error_type: "capture_error",
@@ -11936,6 +12397,8 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
11936
12397
  error: body.message
11937
12398
  });
11938
12399
  return c.json(body, 500);
12400
+ } finally {
12401
+ await releaseConcurrencyGate(gate.lockId);
11939
12402
  }
11940
12403
  });
11941
12404
 
@@ -12009,7 +12472,7 @@ mcpApp.all("/", async (c) => {
12009
12472
  import { Hono as Hono10 } from "hono";
12010
12473
 
12011
12474
  // src/api/browser-agent-db.ts
12012
- import { randomUUID } from "crypto";
12475
+ import { randomUUID as randomUUID2 } from "crypto";
12013
12476
  var _ready = false;
12014
12477
  async function migrateBrowserAgent() {
12015
12478
  if (_ready) return;
@@ -12023,6 +12486,7 @@ async function migrateBrowserAgent() {
12023
12486
  status TEXT NOT NULL DEFAULT 'open',
12024
12487
  label TEXT,
12025
12488
  user_id INTEGER,
12489
+ concurrency_lock_id TEXT,
12026
12490
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
12027
12491
  closed_at TEXT,
12028
12492
  last_action_at TEXT,
@@ -12032,6 +12496,11 @@ async function migrateBrowserAgent() {
12032
12496
  `);
12033
12497
  await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_status ON browser_agent_sessions(status)`);
12034
12498
  await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_user ON browser_agent_sessions(user_id)`);
12499
+ try {
12500
+ await db.execute(`ALTER TABLE browser_agent_sessions ADD COLUMN concurrency_lock_id TEXT`);
12501
+ } catch {
12502
+ }
12503
+ await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_concurrency_lock ON browser_agent_sessions(concurrency_lock_id)`);
12035
12504
  await db.execute(`
12036
12505
  CREATE TABLE IF NOT EXISTS browser_agent_actions (
12037
12506
  id TEXT PRIMARY KEY,
@@ -12059,11 +12528,11 @@ async function migrateBrowserAgent() {
12059
12528
  }
12060
12529
  async function createSessionRow(input) {
12061
12530
  const db = getDb();
12062
- const id = `bas_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
12531
+ const id = `bas_${randomUUID2().replace(/-/g, "").slice(0, 20)}`;
12063
12532
  await db.execute({
12064
- sql: `INSERT INTO browser_agent_sessions (id, runtime_session_id, live_view_url, cdp_ws_url, status, label, user_id, last_action_at)
12065
- VALUES (?, ?, ?, ?, 'open', ?, ?, datetime('now'))`,
12066
- args: [id, input.runtimeSessionId, input.liveViewUrl, input.cdpWsUrl, input.label, input.userId]
12533
+ sql: `INSERT INTO browser_agent_sessions (id, runtime_session_id, live_view_url, cdp_ws_url, status, label, user_id, concurrency_lock_id, last_action_at)
12534
+ VALUES (?, ?, ?, ?, 'open', ?, ?, ?, datetime('now'))`,
12535
+ args: [id, input.runtimeSessionId, input.liveViewUrl, input.cdpWsUrl, input.label, input.userId, input.concurrencyLockId ?? null]
12067
12536
  });
12068
12537
  const row = await getSessionRow(id);
12069
12538
  if (!row) throw new Error("session insert failed");
@@ -12120,7 +12589,7 @@ async function recordAction(input) {
12120
12589
  sql: `INSERT INTO browser_agent_actions (id, session_id, type, params_json, ok, error)
12121
12590
  VALUES (?, ?, ?, ?, ?, ?)`,
12122
12591
  args: [
12123
- `baa_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
12592
+ `baa_${randomUUID2().replace(/-/g, "").slice(0, 20)}`,
12124
12593
  input.sessionId,
12125
12594
  input.type,
12126
12595
  input.params == null ? null : JSON.stringify(input.params),
@@ -12469,6 +12938,13 @@ async function replayList(runtimeSessionId) {
12469
12938
 
12470
12939
  // src/api/browser-agent-routes.ts
12471
12940
  var auth = createApiKeyAuth();
12941
+ var DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS = 2 * 60 * 60;
12942
+ function browserSessionLockTtlSeconds(timeoutSeconds) {
12943
+ if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
12944
+ return Math.max(30 * 60, Math.min(Math.trunc(timeoutSeconds) + 5 * 60, 24 * 60 * 60));
12945
+ }
12946
+ return DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS;
12947
+ }
12472
12948
  async function charge(sessionId, userId, startedAtMs) {
12473
12949
  const elapsedMs = Date.now() - startedAtMs;
12474
12950
  const { active_ms, billed_mc } = await addActiveMs(sessionId, elapsedMs);
@@ -12524,6 +13000,8 @@ async function loadOpenSession(id, userId) {
12524
13000
  const row = await getSessionRow(id);
12525
13001
  if (!row) return null;
12526
13002
  if (row.user_id != null && row.user_id !== userId) return null;
13003
+ if (row.status !== "open") return null;
13004
+ await extendConcurrencyGate(row.concurrency_lock_id, DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS);
12527
13005
  return row;
12528
13006
  }
12529
13007
  function buildBrowserAgentRoutes() {
@@ -12539,23 +13017,39 @@ function buildBrowserAgentRoutes() {
12539
13017
  return c.json(insufficientBalanceResponse(Number(user.balance_mc ?? 0), BROWSER_OPEN_MIN_BALANCE_MC), 402);
12540
13018
  }
12541
13019
  const body = await c.req.json().catch(() => ({}));
13020
+ const timeoutSeconds = typeof body.timeout_seconds === "number" ? body.timeout_seconds : void 0;
13021
+ const gate = await acquireConcurrencyGate(user, "browser_agent_session", {
13022
+ ttlSeconds: browserSessionLockTtlSeconds(timeoutSeconds),
13023
+ metadata: { label: typeof body.label === "string" ? body.label : null }
13024
+ });
13025
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
13026
+ let runtimeSessionId = null;
12542
13027
  try {
12543
13028
  const created = await createSession({
12544
- timeoutSeconds: typeof body.timeout_seconds === "number" ? body.timeout_seconds : void 0,
13029
+ timeoutSeconds,
12545
13030
  proxyId: typeof body.proxy_id === "string" ? body.proxy_id : void 0,
12546
13031
  profileName: typeof body.profile === "string" ? body.profile : void 0,
12547
13032
  disableDefaultProxy: body.disable_default_proxy === true,
12548
13033
  viewport: body.viewport && typeof body.viewport === "object" ? body.viewport : void 0
12549
13034
  });
13035
+ runtimeSessionId = created.runtimeSessionId;
12550
13036
  const row = await createSessionRow({
12551
13037
  runtimeSessionId: created.runtimeSessionId,
12552
13038
  liveViewUrl: created.liveViewUrl,
12553
13039
  cdpWsUrl: created.cdpWsUrl,
12554
13040
  label: typeof body.label === "string" ? body.label : null,
12555
- userId: user.id
13041
+ userId: user.id,
13042
+ concurrencyLockId: gate.lockId
12556
13043
  });
12557
13044
  return c.json({ ...publicSession(row), watch_url: `/console/${row.id}` });
12558
13045
  } catch (err) {
13046
+ if (runtimeSessionId) {
13047
+ try {
13048
+ await closeSession(runtimeSessionId);
13049
+ } catch {
13050
+ }
13051
+ }
13052
+ await releaseConcurrencyGate(gate.lockId);
12559
13053
  return c.json(failure(err), 502);
12560
13054
  }
12561
13055
  });
@@ -12586,6 +13080,7 @@ function buildBrowserAgentRoutes() {
12586
13080
  } catch {
12587
13081
  }
12588
13082
  await markSessionClosed(row.id);
13083
+ await releaseConcurrencyGate(row.concurrency_lock_id);
12589
13084
  return c.json({ ok: true });
12590
13085
  });
12591
13086
  app2.post("/sessions/:id/goto", async (c) => {
@@ -13542,9 +14037,6 @@ app.delete("/api-key", requireAllowedOrigin, sessionAuth, async (c) => {
13542
14037
  await revokeApiKey(user.id);
13543
14038
  return c.json({ ok: true });
13544
14039
  });
13545
- var BYPASS_EMAILS = new Set(
13546
- (process.env.HARVEST_LIMIT_BYPASS_EMAILS ?? "").split(",").map((e) => e.trim()).filter(Boolean)
13547
- );
13548
14040
  var SYNC_HARVEST_TIMEOUT_OVERRIDE_MS = (() => {
13549
14041
  const raw = process.env.SYNC_HARVEST_TIMEOUT_MS;
13550
14042
  const parsed = raw === void 0 ? NaN : Number(raw);
@@ -13573,11 +14065,20 @@ function countPaaQuestions(result) {
13573
14065
  function paaCostForQuestionCount(questionCount) {
13574
14066
  return Math.max(1, questionCount) * MC_COSTS.paa;
13575
14067
  }
13576
- async function checkHarvestLimits(userId, email, extraSlots = 0) {
13577
- if (BYPASS_EMAILS.has(email)) return null;
13578
- const limit = 1 + extraSlots;
13579
- const active = await countActiveJobsForUser(userId);
13580
- if (active >= limit) return { error: `You have ${active} job${active !== 1 ? "s" : ""} running. Your account allows ${limit} concurrent job${limit !== 1 ? "s" : ""}. Wait for one to finish or add a concurrency slot at mcpscraper.dev/billing.` };
14068
+ async function checkHarvestLimits(user, reuseLockId) {
14069
+ if (reuseLockId && await reuseExistingConcurrencyGate(user.id, reuseLockId, 30 * 60)) return null;
14070
+ if (isConcurrencyLimitBypassed(user)) return null;
14071
+ const limit = concurrencyLimitForUser(user);
14072
+ const active = await countActiveUsageForUser(user.id);
14073
+ if (active >= limit) {
14074
+ return concurrencyLimitExceededResponse({
14075
+ ok: false,
14076
+ active,
14077
+ limit,
14078
+ operation: "harvest",
14079
+ retryAfterSeconds: 30
14080
+ });
14081
+ }
13581
14082
  return null;
13582
14083
  }
13583
14084
  app.post("/harvest", auth2, async (c) => {
@@ -13592,8 +14093,8 @@ app.post("/harvest", auth2, async (c) => {
13592
14093
  if (checked.error) return c.json({ error: checked.error }, 400);
13593
14094
  body.callback_url = checked.parsed?.href;
13594
14095
  }
13595
- const limitErr = await checkHarvestLimits(user.id, user.email, user.extra_concurrency_slots);
13596
- if (limitErr) return c.json(limitErr, 429);
14096
+ const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
14097
+ if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
13597
14098
  const options = {
13598
14099
  query: body.query.trim(),
13599
14100
  location: body.location,
@@ -13628,8 +14129,8 @@ app.post("/harvest/sync", auth2, async (c) => {
13628
14129
  const bodyResult = HarvestBodySchema.safeParse(raw);
13629
14130
  if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
13630
14131
  const body = bodyResult.data;
13631
- const limitErr = await checkHarvestLimits(user.id, user.email, user.extra_concurrency_slots);
13632
- if (limitErr) return c.json(limitErr, 429);
14132
+ const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
14133
+ if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
13633
14134
  const options = {
13634
14135
  query: body.query.trim(),
13635
14136
  location: body.location,
@@ -13788,9 +14289,16 @@ app.post("/extract-url", auth2, async (c) => {
13788
14289
  }
13789
14290
  })();
13790
14291
  const user = c.get("user");
13791
- const { ok: euOk, balance_mc: euBal } = await debitMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, new URL(canonicalUrl).hostname);
13792
- if (!euOk) return c.json(insufficientBalanceResponse(euBal, MC_COSTS.page_scrape), 402);
14292
+ const gate = await acquireConcurrencyGate(user, "extract_url", {
14293
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
14294
+ metadata: { url: canonicalUrl }
14295
+ });
14296
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
14297
+ let debited = false;
13793
14298
  try {
14299
+ const { ok: euOk, balance_mc: euBal } = await debitMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, new URL(canonicalUrl).hostname);
14300
+ if (!euOk) return c.json(insufficientBalanceResponse(euBal, MC_COSTS.page_scrape), 402);
14301
+ debited = true;
13794
14302
  const kernelApiKey = browserServiceApiKey();
13795
14303
  const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
13796
14304
  const [result, pageData] = await Promise.all([
@@ -13808,9 +14316,11 @@ app.post("/extract-url", auth2, async (c) => {
13808
14316
  return c.json({ ...result, screenshot: screenshotMeta, branding: brandingData, media: mediaMeta });
13809
14317
  } catch (err) {
13810
14318
  const msg = err instanceof Error ? err.message : String(err);
13811
- await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
14319
+ if (debited) await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
13812
14320
  await logRequestEvent({ userId: user.id, source: "extract_url", status: "failed", query: canonicalUrl, error: msg });
13813
14321
  return c.json({ error: msg }, 500);
14322
+ } finally {
14323
+ await releaseConcurrencyGate(gate.lockId);
13814
14324
  }
13815
14325
  });
13816
14326
  app.post("/map-urls", auth2, async (c) => {
@@ -13822,9 +14332,16 @@ app.post("/map-urls", auth2, async (c) => {
13822
14332
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
13823
14333
  const parsed = checked.parsed;
13824
14334
  const user = c.get("user");
13825
- const { ok: mapOk, balance_mc: mapBal } = await debitMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP, parsed.hostname);
13826
- if (!mapOk) return c.json(insufficientBalanceResponse(mapBal, MC_COSTS.url_map), 402);
14335
+ const gate = await acquireConcurrencyGate(user, "map_urls", {
14336
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
14337
+ metadata: { url: parsed.href }
14338
+ });
14339
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
14340
+ let debited = false;
13827
14341
  try {
14342
+ const { ok: mapOk, balance_mc: mapBal } = await debitMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP, parsed.hostname);
14343
+ if (!mapOk) return c.json(insufficientBalanceResponse(mapBal, MC_COSTS.url_map), 402);
14344
+ debited = true;
13828
14345
  const result = await spiderSite({
13829
14346
  startUrl: parsed.href,
13830
14347
  maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
@@ -13842,7 +14359,7 @@ app.post("/map-urls", auth2, async (c) => {
13842
14359
  return c.json(result);
13843
14360
  } catch (err) {
13844
14361
  const msg = err instanceof Error ? err.message : String(err);
13845
- await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
14362
+ if (debited) await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
13846
14363
  await logRequestEvent({
13847
14364
  userId: user.id,
13848
14365
  source: "map_urls",
@@ -13851,6 +14368,8 @@ app.post("/map-urls", auth2, async (c) => {
13851
14368
  error: msg
13852
14369
  });
13853
14370
  return c.json({ error: msg }, 500);
14371
+ } finally {
14372
+ await releaseConcurrencyGate(gate.lockId);
13854
14373
  }
13855
14374
  });
13856
14375
  app.post("/extract-site", auth2, async (c) => {
@@ -13863,9 +14382,16 @@ app.post("/extract-site", auth2, async (c) => {
13863
14382
  const parsed = checked.parsed;
13864
14383
  const user = c.get("user");
13865
14384
  const siteHoldMc = MC_COSTS.page_scrape * 10;
13866
- const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
13867
- if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
14385
+ const gate = await acquireConcurrencyGate(user, "extract_site", {
14386
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
14387
+ metadata: { url: parsed.href }
14388
+ });
14389
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
14390
+ let debited = false;
13868
14391
  try {
14392
+ const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
14393
+ if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
14394
+ debited = true;
13869
14395
  const result = await extractSite({
13870
14396
  startUrl: parsed.href,
13871
14397
  maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
@@ -13886,7 +14412,7 @@ app.post("/extract-site", auth2, async (c) => {
13886
14412
  });
13887
14413
  return c.json(result);
13888
14414
  } catch (err) {
13889
- await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
14415
+ if (debited) await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
13890
14416
  const msg = err instanceof Error ? err.message : String(err);
13891
14417
  await logRequestEvent({
13892
14418
  userId: user.id,
@@ -13896,6 +14422,8 @@ app.post("/extract-site", auth2, async (c) => {
13896
14422
  error: msg
13897
14423
  });
13898
14424
  return c.json({ error: msg }, 500);
14425
+ } finally {
14426
+ await releaseConcurrencyGate(gate.lockId);
13899
14427
  }
13900
14428
  });
13901
14429
  app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
@@ -13964,6 +14492,60 @@ app.post("/billing/concurrency/checkout", requireAllowedOrigin, sessionAuth, asy
13964
14492
  return c.json({ error: message }, 500);
13965
14493
  }
13966
14494
  });
14495
+ app.post("/billing/concurrency/terminal-checkout", auth2, async (c) => {
14496
+ try {
14497
+ const user = c.get("user");
14498
+ const upgrade = concurrencySlotBillingInfo();
14499
+ if (user.concurrency_stripe_sub_id) {
14500
+ return c.json({
14501
+ error: "Already subscribed. Your account already has an active extra concurrency slot subscription.",
14502
+ error_code: "concurrency_subscription_exists",
14503
+ concurrency: {
14504
+ extra_slots: user.extra_concurrency_slots,
14505
+ limit: concurrencyLimitForUser(user),
14506
+ has_subscription: true,
14507
+ upgrade
14508
+ }
14509
+ }, 409);
14510
+ }
14511
+ const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
14512
+ if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
14513
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
14514
+ let customerId = user.stripe_customer_id;
14515
+ if (!customerId) {
14516
+ const customer = await stripeClient.customers.create({ email: user.email });
14517
+ customerId = customer.id;
14518
+ await setStripeCustomerId(user.id, customerId);
14519
+ }
14520
+ const session = await stripeClient.checkout.sessions.create({
14521
+ customer: customerId,
14522
+ mode: "subscription",
14523
+ line_items: [{ price: CONCURRENCY_PRICE_ID, quantity: 1 }],
14524
+ automatic_tax: { enabled: true },
14525
+ billing_address_collection: "required",
14526
+ customer_update: { address: "auto", name: "auto" },
14527
+ tax_id_collection: { enabled: true },
14528
+ success_url: `${appOrigin()}/billing?slot_added=1&source=terminal`,
14529
+ cancel_url: `${appOrigin()}/billing?slot_cancelled=1&source=terminal`
14530
+ });
14531
+ if (!session.url) return c.json({ error: "Stripe did not return a checkout URL." }, 502);
14532
+ return c.json({
14533
+ checkout_url: session.url,
14534
+ price: upgrade,
14535
+ concurrency: {
14536
+ current_extra_slots: user.extra_concurrency_slots,
14537
+ current_limit: concurrencyLimitForUser(user),
14538
+ after_checkout_extra_slots: user.extra_concurrency_slots + 1,
14539
+ after_checkout_limit: concurrencyLimitForUser({ extra_concurrency_slots: user.extra_concurrency_slots + 1 })
14540
+ },
14541
+ next_step: "Open checkout_url in a browser, complete checkout, then restart or retry the MCP request."
14542
+ });
14543
+ } catch (err) {
14544
+ const message = err instanceof Error ? err.message : "Unable to start terminal checkout.";
14545
+ console.error("[billing/concurrency/terminal-checkout]", message);
14546
+ return c.json({ error: message }, 500);
14547
+ }
14548
+ });
13967
14549
  app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async (c) => {
13968
14550
  const user = c.get("sessionUser");
13969
14551
  if (!user.concurrency_stripe_sub_id) return c.json({ error: "No active concurrency subscription." }, 404);
@@ -13971,9 +14553,10 @@ app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async
13971
14553
  if (!stripeSecret) return c.json({ error: "Stripe is not configured." }, 503);
13972
14554
  const stripeClient = new Stripe2(stripeSecret, { apiVersion: STRIPE_API_VERSION });
13973
14555
  await stripeClient.subscriptions.cancel(user.concurrency_stripe_sub_id);
13974
- await setExtraConcurrencySlots(user.id, Math.max(0, user.extra_concurrency_slots - 1));
14556
+ const nextSlots = Math.max(0, user.extra_concurrency_slots - 1);
14557
+ await setExtraConcurrencySlots(user.id, nextSlots);
13975
14558
  await setConcurrencySubId(user.id, null);
13976
- return c.json({ ok: true, concurrency_limit: user.extra_concurrency_slots });
14559
+ return c.json({ ok: true, extra_concurrency_slots: nextSlots, concurrency_limit: 1 + nextSlots });
13977
14560
  });
13978
14561
  app.get("/billing/balance", auth2, async (c) => {
13979
14562
  const user = c.get("user");
@@ -14008,6 +14591,12 @@ app.post("/billing/credits", auth2, async (c) => {
14008
14591
  item: body.item ?? null,
14009
14592
  matched_cost: matchedCost ? (({ aliases, ...cost }) => cost)(matchedCost) : null,
14010
14593
  costs,
14594
+ concurrency: {
14595
+ current_extra_slots: user.extra_concurrency_slots,
14596
+ current_limit: concurrencyLimitForUser(user),
14597
+ has_subscription: !!user.concurrency_stripe_sub_id,
14598
+ upgrade: concurrencySlotBillingInfo()
14599
+ },
14011
14600
  ledger
14012
14601
  });
14013
14602
  });
@@ -14016,7 +14605,7 @@ app.get("/cron/tick", async (c) => {
14016
14605
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
14017
14606
  return c.json({ error: "Unauthorized" }, 401);
14018
14607
  }
14019
- const { drainQueue } = await import("./worker-TDJQ6TH3.js");
14608
+ const { drainQueue } = await import("./worker-OZSWIS3F.js");
14020
14609
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
14021
14610
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
14022
14611
  const [results, sweepResult] = await Promise.all([
@@ -14144,4 +14733,4 @@ app.get("/blog/:slug/", (c) => {
14144
14733
  export {
14145
14734
  app
14146
14735
  };
14147
- //# sourceMappingURL=server-LUNOI26E.js.map
14736
+ //# sourceMappingURL=server-VD2TD3AD.js.map