mcp-scraper 0.2.13 → 0.2.14

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 +9 -0
  2. package/dist/bin/api-server.cjs +664 -178
  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 +47 -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 +2 -1
  16. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  17. package/dist/bin/mcp-scraper-install.js +2 -1
  18. package/dist/bin/mcp-scraper-install.js.map +1 -1
  19. package/dist/bin/mcp-stdio-server.cjs +47 -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-ROS67BNV.js → chunk-BSYPATSM.js} +21 -2
  23. package/dist/chunk-BSYPATSM.js.map +1 -0
  24. package/dist/{chunk-AYTOCZBS.js → chunk-F44RBOJ5.js} +6 -3
  25. package/dist/chunk-F44RBOJ5.js.map +1 -0
  26. package/dist/{chunk-DKJ2XCY7.js → chunk-IWQTNY2E.js} +48 -6
  27. package/dist/chunk-IWQTNY2E.js.map +1 -0
  28. package/dist/chunk-ROCXJOVY.js +7 -0
  29. package/dist/chunk-ROCXJOVY.js.map +1 -0
  30. package/dist/{chunk-7GCCOT3M.js → chunk-TL7YTFLH.js} +15 -1
  31. package/dist/chunk-TL7YTFLH.js.map +1 -0
  32. package/dist/{chunk-BQTWXY6G.js → chunk-ZV2XXYR7.js} +2 -2
  33. package/dist/{db-BVHYI57K.js → db-P5X6UQ3E.js} +2 -2
  34. package/dist/{server-LUNOI26E.js → server-7QP6HQJJ.js} +556 -164
  35. package/dist/server-7QP6HQJJ.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-ZV2XXYR7.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-IWQTNY2E.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-ROCXJOVY.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
 
@@ -9075,10 +9261,17 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
9075
9261
  const libraryId = FacebookAdExtractor.resolveLibraryId(raw2);
9076
9262
  if (!libraryId) return c.json({ error: "Could not resolve a valid Facebook Ad Library ID from the provided input" }, 400);
9077
9263
  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);
9264
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_ad", {
9265
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9266
+ metadata: { libraryId }
9267
+ });
9268
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9080
9269
  const driver = new BrowserDriver();
9270
+ let debited = false;
9081
9271
  try {
9272
+ const { ok: adOk, balance_mc: adBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, raw2);
9273
+ if (!adOk) return c.json(insufficientBalanceResponse(adBal, MC_COSTS.fb_ad), 402);
9274
+ debited = true;
9082
9275
  await driver.launch(kernelLaunchOpts());
9083
9276
  const extractor = new FacebookAdExtractor(driver);
9084
9277
  const result = await extractor.extract(libraryId, { openModal: body.openModal !== false });
@@ -9092,7 +9285,7 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
9092
9285
  });
9093
9286
  return c.json(result);
9094
9287
  } catch (err) {
9095
- await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9288
+ if (debited) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9096
9289
  const msg = err instanceof Error ? err.message : String(err);
9097
9290
  await logRequestEvent({ userId: fbUser.id, source: "facebook_ad", status: "failed", query: raw2, error: msg });
9098
9291
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
@@ -9101,24 +9294,26 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
9101
9294
  return c.json({ error: msg }, 500);
9102
9295
  } finally {
9103
9296
  await driver.close();
9297
+ await releaseConcurrencyGate(gate.lockId);
9104
9298
  }
9105
9299
  });
9106
9300
  facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
9107
9301
  const raw = await c.req.json().catch(() => ({}));
9108
9302
  const parsed = FacebookPageIntelBodySchema.safeParse(raw);
9109
- if (!parsed.success) {
9110
- return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
9111
- }
9303
+ if (!parsed.success) return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
9112
9304
  const body = parsed.data;
9113
9305
  const maxAds = Math.min(200, Math.max(1, body.maxAds ?? 50));
9114
9306
  const country = body.country?.trim().toUpperCase() ?? "US";
9115
9307
  const listingUrl = buildPageIntelUrl(body, country);
9116
9308
  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);
9309
+ 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 } });
9310
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9119
9311
  const driver = new BrowserDriver();
9120
- let refunded = false;
9312
+ let refunded = false, debited = false;
9121
9313
  try {
9314
+ const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, body.pageId ?? body.query ?? body.libraryId ?? "");
9315
+ if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_ad), 402);
9316
+ debited = true;
9122
9317
  await driver.launch(await kernelLaunchOptsResidential());
9123
9318
  await driver.navigateTo(listingUrl);
9124
9319
  const extractor = new FacebookAdExtractor(driver);
@@ -9133,7 +9328,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
9133
9328
  return c.json(result);
9134
9329
  } catch (err) {
9135
9330
  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");
9331
+ if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
9137
9332
  await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: body.pageId ?? body.query ?? body.libraryId ?? "", error: msg });
9138
9333
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
9139
9334
  return c.json({ error: msg }, 503);
@@ -9141,6 +9336,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
9141
9336
  return c.json({ error: msg }, 500);
9142
9337
  } finally {
9143
9338
  await driver.close();
9339
+ await releaseConcurrencyGate(gate.lockId);
9144
9340
  }
9145
9341
  });
9146
9342
  facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
@@ -9156,10 +9352,17 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
9156
9352
  }
9157
9353
  const videoUrl = urlCheck.parsed.href;
9158
9354
  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);
9355
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_transcribe", {
9356
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9357
+ metadata: { videoUrl }
9358
+ });
9359
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9161
9360
  fal2.config({ credentials: process.env.FAL_KEY });
9361
+ let debited = false;
9162
9362
  try {
9363
+ const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
9364
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
9365
+ debited = true;
9163
9366
  const startMs = Date.now();
9164
9367
  const result = await fal2.subscribe("fal-ai/wizper", {
9165
9368
  input: { audio_url: videoUrl, task: "transcribe", language: "en" },
@@ -9182,9 +9385,11 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
9182
9385
  return c.json({ text, chunks, durationMs, markdown: lines.join("\n") });
9183
9386
  } catch (err) {
9184
9387
  const msg = err instanceof Error ? err.message : String(err);
9185
- await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
9388
+ if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
9186
9389
  await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "failed", query: videoUrl, error: msg });
9187
9390
  return c.json({ error: msg }, 500);
9391
+ } finally {
9392
+ await releaseConcurrencyGate(gate.lockId);
9188
9393
  }
9189
9394
  });
9190
9395
  facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
@@ -9198,11 +9403,18 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9198
9403
  const maxResults = Math.min(20, Math.max(1, body.maxResults ?? 10));
9199
9404
  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
9405
  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);
9406
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_search", {
9407
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
9408
+ metadata: { query: body.query.trim(), country }
9409
+ });
9410
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
9203
9411
  const driver = new BrowserDriver();
9204
9412
  let searchRefunded = false;
9413
+ let debited = false;
9205
9414
  try {
9415
+ const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH, body.query.trim());
9416
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_search), 402);
9417
+ debited = true;
9206
9418
  await driver.launch(await kernelLaunchOptsResidential());
9207
9419
  const page = driver.getPage();
9208
9420
  const collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
@@ -9263,7 +9475,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9263
9475
  return c.json(searchResult);
9264
9476
  } catch (err) {
9265
9477
  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");
9478
+ if (debited && !searchRefunded) await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "failed call");
9267
9479
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: msg });
9268
9480
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
9269
9481
  return c.json({ error: msg }, 503);
@@ -9271,6 +9483,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
9271
9483
  return c.json({ error: msg }, 500);
9272
9484
  } finally {
9273
9485
  await driver.close();
9486
+ await releaseConcurrencyGate(gate.lockId);
9274
9487
  }
9275
9488
  });
9276
9489
  var ALLOWED_MEDIA_HOSTS = ["fbcdn.net", "cdninstagram.com", "scontent.facebook.com", "scontent.cdninstagram.com"];
@@ -10129,14 +10342,21 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
10129
10342
  if (!parsed.success) {
10130
10343
  return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
10131
10344
  }
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);
10345
+ const gate = await acquireConcurrencyGate(user, "maps_search", {
10346
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
10347
+ metadata: { query: parsed.data.query, location: parsed.data.location }
10348
+ });
10349
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10350
+ let debited = false;
10139
10351
  try {
10352
+ const { ok, balance_mc } = await debitMc(
10353
+ user.id,
10354
+ MC_COSTS.maps_search,
10355
+ LedgerOperation.MAPS_SEARCH,
10356
+ [parsed.data.query, parsed.data.location].filter(Boolean).join(" ")
10357
+ );
10358
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
10359
+ debited = true;
10140
10360
  const result = await runMapsSearchWithRotation({
10141
10361
  ...parsed.data,
10142
10362
  configuredKernelProxyId: browserServiceProxyId()
@@ -10152,7 +10372,7 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
10152
10372
  });
10153
10373
  return c.json(result);
10154
10374
  } catch (err) {
10155
- await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
10375
+ if (debited) await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
10156
10376
  const msg = err instanceof Error ? err.message : String(err);
10157
10377
  await logRequestEvent({
10158
10378
  userId: user.id,
@@ -10164,6 +10384,8 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
10164
10384
  result: err instanceof MapsSearchRotationError ? { attempts: err.attempts } : void 0
10165
10385
  });
10166
10386
  return mapsErrorResponse(c, err, "maps_search_failed");
10387
+ } finally {
10388
+ await releaseConcurrencyGate(gate.lockId);
10167
10389
  }
10168
10390
  });
10169
10391
  mapsApp.post("/place", createApiKeyAuth(), async (c) => {
@@ -10176,17 +10398,24 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
10176
10398
  if (!parsed.success) {
10177
10399
  return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
10178
10400
  }
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);
10401
+ const gate = await acquireConcurrencyGate(user, "maps_place", {
10402
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
10403
+ metadata: { businessName: parsed.data.businessName, location: parsed.data.location }
10404
+ });
10405
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10186
10406
  const driver = new BrowserDriver();
10187
10407
  const extractor = new MapsExtractor(driver);
10188
10408
  let reviewDebitMc = 0;
10409
+ let debited = false;
10189
10410
  try {
10411
+ const { ok, balance_mc } = await debitMc(
10412
+ user.id,
10413
+ MC_COSTS.maps_place,
10414
+ LedgerOperation.MAPS_PLACE,
10415
+ `${parsed.data.businessName} ${parsed.data.location}`
10416
+ );
10417
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
10418
+ debited = true;
10190
10419
  const result = await extractor.extract(parsed.data);
10191
10420
  const reviewCount = Array.isArray(result.reviews) ? result.reviews.length : 0;
10192
10421
  if (reviewCount > 0) {
@@ -10214,7 +10443,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
10214
10443
  });
10215
10444
  return c.json(result);
10216
10445
  } catch (err) {
10217
- await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
10446
+ if (debited) await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
10218
10447
  if (reviewDebitMc > 0) {
10219
10448
  await creditMc(
10220
10449
  user.id,
@@ -10235,6 +10464,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
10235
10464
  return mapsErrorResponse(c, msg, "maps_place_failed");
10236
10465
  } finally {
10237
10466
  await driver.close();
10467
+ await releaseConcurrencyGate(gate.lockId);
10238
10468
  }
10239
10469
  });
10240
10470
 
@@ -10658,18 +10888,27 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
10658
10888
  if (!kernelApiKey && parsed.data.proxyMode !== "none") {
10659
10889
  return c.json({ error: "Browser service API key is required for directory workflow Maps searches unless proxyMode is none" }, 503);
10660
10890
  }
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
- }
10891
+ const gate = await acquireConcurrencyGate(user, "directory_workflow", {
10892
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
10893
+ ttlSeconds: 30 * 60,
10894
+ metadata: { query: parsed.data.query, state: parsed.data.state }
10895
+ });
10896
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10897
+ let debited = false;
10898
+ let requiredMc = 0;
10672
10899
  try {
10900
+ const plan = await resolveDirectoryMarkets(parsed.data);
10901
+ requiredMc = plan.markets.length * MC_COSTS.maps_search;
10902
+ if (requiredMc > 0) {
10903
+ const debit = await debitMc(
10904
+ user.id,
10905
+ requiredMc,
10906
+ LedgerOperation.MAPS_SEARCH,
10907
+ `directory_workflow ${parsed.data.query} ${parsed.data.state} ${plan.markets.length} cities`
10908
+ );
10909
+ if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
10910
+ debited = true;
10911
+ }
10673
10912
  const result = await runDirectoryWorkflowFromPlan(parsed.data, plan);
10674
10913
  const failedCities = result.cities.filter((city) => city.status === "failed").length;
10675
10914
  if (failedCities > 0) {
@@ -10686,7 +10925,7 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
10686
10925
  });
10687
10926
  return c.json(result);
10688
10927
  } catch (err) {
10689
- if (requiredMc > 0) {
10928
+ if (debited && requiredMc > 0) {
10690
10929
  await creditMc(user.id, requiredMc, LedgerOperation.REFUND, "failed directory_workflow call");
10691
10930
  }
10692
10931
  const message = err instanceof Error ? err.message : String(err);
@@ -10699,6 +10938,8 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
10699
10938
  error: message
10700
10939
  });
10701
10940
  return c.json({ error: message, error_code: "directory_workflow_failed", retryable: true }, 500);
10941
+ } finally {
10942
+ await releaseConcurrencyGate(gate.lockId);
10702
10943
  }
10703
10944
  });
10704
10945
 
@@ -10845,7 +11086,8 @@ async function executeWorkflowRun(input) {
10845
11086
  apiKey: input.user.api_key,
10846
11087
  apiUrl: input.apiUrl,
10847
11088
  outputDir: hostedWorkflowOutputDir(),
10848
- runId: input.runId
11089
+ runId: input.runId,
11090
+ headers: input.concurrencyLockId ? { "x-mcp-scraper-concurrency-lock": input.concurrencyLockId } : void 0
10849
11091
  });
10850
11092
  const manifest = await readManifestFromSummary(summary);
10851
11093
  await completeWorkflowRunRecord(input.runId, summary.status, manifest ?? { summary });
@@ -10878,20 +11120,29 @@ workflowApp.post("/run", createApiKeyAuth(), async (c) => {
10878
11120
  } catch (err) {
10879
11121
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
10880
11122
  }
10881
- const run = await createWorkflowRun({ userId: user.id, workflowId: parsed.data.workflowId, workflowInput: input });
11123
+ const gate = await acquireConcurrencyGate(user, "workflow_run", {
11124
+ ttlSeconds: 60 * 60,
11125
+ metadata: { workflowId: parsed.data.workflowId }
11126
+ });
11127
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11128
+ let run = null;
10882
11129
  try {
11130
+ run = await createWorkflowRun({ userId: user.id, workflowId: parsed.data.workflowId, workflowInput: input });
10883
11131
  const executed = await executeWorkflowRun({
10884
11132
  runId: run.id,
10885
11133
  user,
10886
11134
  workflowId: parsed.data.workflowId,
10887
11135
  workflowInput: input,
10888
11136
  apiUrl: originFromUrl(c.req.url),
10889
- webhookUrl
11137
+ webhookUrl,
11138
+ concurrencyLockId: gate.lockId
10890
11139
  });
10891
11140
  return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
10892
11141
  } catch (err) {
10893
- const failed = await getWorkflowRun(run.id, user.id);
11142
+ const failed = run ? await getWorkflowRun(run.id, user.id) : null;
10894
11143
  return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
11144
+ } finally {
11145
+ await releaseConcurrencyGate(gate.lockId);
10895
11146
  }
10896
11147
  });
10897
11148
  workflowApp.get("/runs", createApiKeyAuth(), async (c) => {
@@ -10989,8 +11240,14 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
10989
11240
  const user = c.get("user");
10990
11241
  const schedule = await getWorkflowSchedule(c.req.param("id"), user.id);
10991
11242
  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 });
11243
+ const gate = await acquireConcurrencyGate(user, "workflow_schedule_run", {
11244
+ ttlSeconds: 60 * 60,
11245
+ metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
11246
+ });
11247
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11248
+ let run = null;
10993
11249
  try {
11250
+ run = await createWorkflowRun({ userId: user.id, scheduleId: schedule.id, workflowId: schedule.workflow_id, workflowInput: schedule.input });
10994
11251
  const executed = await executeWorkflowRun({
10995
11252
  runId: run.id,
10996
11253
  user,
@@ -10998,12 +11255,15 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
10998
11255
  workflowInput: schedule.input,
10999
11256
  apiUrl: originFromUrl(c.req.url),
11000
11257
  scheduleId: schedule.id,
11001
- webhookUrl: schedule.webhook_url
11258
+ webhookUrl: schedule.webhook_url,
11259
+ concurrencyLockId: gate.lockId
11002
11260
  });
11003
11261
  return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
11004
11262
  } catch (err) {
11005
- const failed = await getWorkflowRun(run.id, user.id);
11263
+ const failed = run ? await getWorkflowRun(run.id, user.id) : null;
11006
11264
  return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
11265
+ } finally {
11266
+ await releaseConcurrencyGate(gate.lockId);
11007
11267
  }
11008
11268
  });
11009
11269
  async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
@@ -11017,19 +11277,28 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
11017
11277
  continue;
11018
11278
  }
11019
11279
  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}`
11280
+ const gate = await acquireConcurrencyGate(user, "workflow_schedule_dispatch", {
11281
+ ttlSeconds: 60 * 60,
11282
+ metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
11026
11283
  });
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 });
11284
+ if (!gate.ok) {
11285
+ results.push({ scheduleId: schedule.id, status: "deferred", error: concurrencyLimitExceededResponse(gate).message });
11030
11286
  continue;
11031
11287
  }
11288
+ let run = null;
11032
11289
  try {
11290
+ run = await createWorkflowRun({
11291
+ userId: user.id,
11292
+ scheduleId: schedule.id,
11293
+ workflowId: schedule.workflow_id,
11294
+ workflowInput: schedule.input,
11295
+ idempotencyKey: `${schedule.id}:${scheduledFor}`
11296
+ });
11297
+ await markWorkflowScheduleRan(schedule.id, now, addCadence(scheduledFor, schedule.cadence));
11298
+ if (run.status !== "queued") {
11299
+ results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
11300
+ continue;
11301
+ }
11033
11302
  const executed = await executeWorkflowRun({
11034
11303
  runId: run.id,
11035
11304
  user,
@@ -11037,11 +11306,14 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
11037
11306
  workflowInput: schedule.input,
11038
11307
  apiUrl,
11039
11308
  scheduleId: schedule.id,
11040
- webhookUrl: schedule.webhook_url
11309
+ webhookUrl: schedule.webhook_url,
11310
+ concurrencyLockId: gate.lockId
11041
11311
  });
11042
11312
  results.push({ scheduleId: schedule.id, runId: executed.run.id, status: executed.run.status });
11043
11313
  } catch (err) {
11044
- results.push({ scheduleId: schedule.id, runId: run.id, status: "failed", error: err instanceof Error ? err.message : String(err) });
11314
+ results.push({ scheduleId: schedule.id, runId: run?.id, status: "failed", error: err instanceof Error ? err.message : String(err) });
11315
+ } finally {
11316
+ await releaseConcurrencyGate(gate.lockId);
11045
11317
  }
11046
11318
  }
11047
11319
  return { dispatched: results.length, results };
@@ -11803,26 +12075,6 @@ async function enforceSerpIntelligenceRateLimit(c, userId) {
11803
12075
  { "Retry-After": String(result.resetSeconds) }
11804
12076
  );
11805
12077
  }
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
12078
  function pageSnapshotTargetsFromBody(body) {
11827
12079
  return body.targets?.length ? body.targets : body.urls.map((url) => ({
11828
12080
  url,
@@ -11838,17 +12090,22 @@ serpIntelligenceApp.post("/capture", async (c) => {
11838
12090
  if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
11839
12091
  const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
11840
12092
  if (limited) return limited;
11841
- const concurrencyLimited = await enforceSerpIntelligenceConcurrency(user);
11842
- if (concurrencyLimited) return concurrencyLimited;
12093
+ const gate = await acquireConcurrencyGate(user, "serp_intelligence_capture", {
12094
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
12095
+ metadata: { query: parsed.data.query, location: parsed.data.location }
12096
+ });
12097
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11843
12098
  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);
12099
+ let debited = false;
11851
12100
  try {
12101
+ const { ok, balance_mc } = await debitMc(
12102
+ user.id,
12103
+ cost,
12104
+ LedgerOperation.SERP,
12105
+ parsed.data.query
12106
+ );
12107
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
12108
+ debited = true;
11852
12109
  const result = await captureSerpIntelligenceSnapshot(parsed.data, {
11853
12110
  kernelApiKey: browserServiceApiKey(),
11854
12111
  kernelProxyId: browserServiceProxyId(),
@@ -11866,7 +12123,7 @@ serpIntelligenceApp.post("/capture", async (c) => {
11866
12123
  });
11867
12124
  return c.json(result);
11868
12125
  } catch (error) {
11869
- await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
12126
+ if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
11870
12127
  const body = error instanceof SerpIntelligenceCaptureError ? error.toJSON() : structuredError({
11871
12128
  error_code: "capture_failed",
11872
12129
  error_type: "capture_error",
@@ -11883,6 +12140,8 @@ serpIntelligenceApp.post("/capture", async (c) => {
11883
12140
  });
11884
12141
  const status = error instanceof SerpIntelligenceCaptureError ? error.httpStatus : 500;
11885
12142
  return c.json(body, status);
12143
+ } finally {
12144
+ await releaseConcurrencyGate(gate.lockId);
11886
12145
  }
11887
12146
  });
11888
12147
  serpIntelligenceApp.post("/page-snapshots", async (c) => {
@@ -11893,18 +12152,23 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
11893
12152
  if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
11894
12153
  const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
11895
12154
  if (limited) return limited;
11896
- const concurrencyLimited = await enforceSerpIntelligenceConcurrency(user);
11897
- if (concurrencyLimited) return concurrencyLimited;
11898
12155
  const targets = pageSnapshotTargetsFromBody(parsed.data);
12156
+ const gate = await acquireConcurrencyGate(user, "serp_intelligence_page_snapshots", {
12157
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
12158
+ metadata: { targets: targets.length }
12159
+ });
12160
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11899
12161
  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);
12162
+ let debited = false;
11907
12163
  try {
12164
+ const { ok, balance_mc } = await debitMc(
12165
+ user.id,
12166
+ cost,
12167
+ LedgerOperation.EXTRACT_URL,
12168
+ `serp intelligence page snapshots: ${targets.length}`
12169
+ );
12170
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
12171
+ debited = true;
11908
12172
  const result = await capturePageSnapshots(targets, {
11909
12173
  kernelApiKey: browserServiceApiKey(),
11910
12174
  timeoutMs: parsed.data.timeoutMs,
@@ -11921,7 +12185,7 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
11921
12185
  });
11922
12186
  return c.json(result);
11923
12187
  } catch (error) {
11924
- await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
12188
+ if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
11925
12189
  const body = structuredError({
11926
12190
  error_code: "page_snapshot_failed",
11927
12191
  error_type: "capture_error",
@@ -11936,6 +12200,8 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
11936
12200
  error: body.message
11937
12201
  });
11938
12202
  return c.json(body, 500);
12203
+ } finally {
12204
+ await releaseConcurrencyGate(gate.lockId);
11939
12205
  }
11940
12206
  });
11941
12207
 
@@ -12009,7 +12275,7 @@ mcpApp.all("/", async (c) => {
12009
12275
  import { Hono as Hono10 } from "hono";
12010
12276
 
12011
12277
  // src/api/browser-agent-db.ts
12012
- import { randomUUID } from "crypto";
12278
+ import { randomUUID as randomUUID2 } from "crypto";
12013
12279
  var _ready = false;
12014
12280
  async function migrateBrowserAgent() {
12015
12281
  if (_ready) return;
@@ -12023,6 +12289,7 @@ async function migrateBrowserAgent() {
12023
12289
  status TEXT NOT NULL DEFAULT 'open',
12024
12290
  label TEXT,
12025
12291
  user_id INTEGER,
12292
+ concurrency_lock_id TEXT,
12026
12293
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
12027
12294
  closed_at TEXT,
12028
12295
  last_action_at TEXT,
@@ -12032,6 +12299,11 @@ async function migrateBrowserAgent() {
12032
12299
  `);
12033
12300
  await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_status ON browser_agent_sessions(status)`);
12034
12301
  await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_user ON browser_agent_sessions(user_id)`);
12302
+ try {
12303
+ await db.execute(`ALTER TABLE browser_agent_sessions ADD COLUMN concurrency_lock_id TEXT`);
12304
+ } catch {
12305
+ }
12306
+ await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_concurrency_lock ON browser_agent_sessions(concurrency_lock_id)`);
12035
12307
  await db.execute(`
12036
12308
  CREATE TABLE IF NOT EXISTS browser_agent_actions (
12037
12309
  id TEXT PRIMARY KEY,
@@ -12059,11 +12331,11 @@ async function migrateBrowserAgent() {
12059
12331
  }
12060
12332
  async function createSessionRow(input) {
12061
12333
  const db = getDb();
12062
- const id = `bas_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
12334
+ const id = `bas_${randomUUID2().replace(/-/g, "").slice(0, 20)}`;
12063
12335
  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]
12336
+ 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)
12337
+ VALUES (?, ?, ?, ?, 'open', ?, ?, ?, datetime('now'))`,
12338
+ args: [id, input.runtimeSessionId, input.liveViewUrl, input.cdpWsUrl, input.label, input.userId, input.concurrencyLockId ?? null]
12067
12339
  });
12068
12340
  const row = await getSessionRow(id);
12069
12341
  if (!row) throw new Error("session insert failed");
@@ -12120,7 +12392,7 @@ async function recordAction(input) {
12120
12392
  sql: `INSERT INTO browser_agent_actions (id, session_id, type, params_json, ok, error)
12121
12393
  VALUES (?, ?, ?, ?, ?, ?)`,
12122
12394
  args: [
12123
- `baa_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
12395
+ `baa_${randomUUID2().replace(/-/g, "").slice(0, 20)}`,
12124
12396
  input.sessionId,
12125
12397
  input.type,
12126
12398
  input.params == null ? null : JSON.stringify(input.params),
@@ -12469,6 +12741,13 @@ async function replayList(runtimeSessionId) {
12469
12741
 
12470
12742
  // src/api/browser-agent-routes.ts
12471
12743
  var auth = createApiKeyAuth();
12744
+ var DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS = 2 * 60 * 60;
12745
+ function browserSessionLockTtlSeconds(timeoutSeconds) {
12746
+ if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
12747
+ return Math.max(30 * 60, Math.min(Math.trunc(timeoutSeconds) + 5 * 60, 24 * 60 * 60));
12748
+ }
12749
+ return DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS;
12750
+ }
12472
12751
  async function charge(sessionId, userId, startedAtMs) {
12473
12752
  const elapsedMs = Date.now() - startedAtMs;
12474
12753
  const { active_ms, billed_mc } = await addActiveMs(sessionId, elapsedMs);
@@ -12524,6 +12803,8 @@ async function loadOpenSession(id, userId) {
12524
12803
  const row = await getSessionRow(id);
12525
12804
  if (!row) return null;
12526
12805
  if (row.user_id != null && row.user_id !== userId) return null;
12806
+ if (row.status !== "open") return null;
12807
+ await extendConcurrencyGate(row.concurrency_lock_id, DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS);
12527
12808
  return row;
12528
12809
  }
12529
12810
  function buildBrowserAgentRoutes() {
@@ -12539,23 +12820,39 @@ function buildBrowserAgentRoutes() {
12539
12820
  return c.json(insufficientBalanceResponse(Number(user.balance_mc ?? 0), BROWSER_OPEN_MIN_BALANCE_MC), 402);
12540
12821
  }
12541
12822
  const body = await c.req.json().catch(() => ({}));
12823
+ const timeoutSeconds = typeof body.timeout_seconds === "number" ? body.timeout_seconds : void 0;
12824
+ const gate = await acquireConcurrencyGate(user, "browser_agent_session", {
12825
+ ttlSeconds: browserSessionLockTtlSeconds(timeoutSeconds),
12826
+ metadata: { label: typeof body.label === "string" ? body.label : null }
12827
+ });
12828
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
12829
+ let runtimeSessionId = null;
12542
12830
  try {
12543
12831
  const created = await createSession({
12544
- timeoutSeconds: typeof body.timeout_seconds === "number" ? body.timeout_seconds : void 0,
12832
+ timeoutSeconds,
12545
12833
  proxyId: typeof body.proxy_id === "string" ? body.proxy_id : void 0,
12546
12834
  profileName: typeof body.profile === "string" ? body.profile : void 0,
12547
12835
  disableDefaultProxy: body.disable_default_proxy === true,
12548
12836
  viewport: body.viewport && typeof body.viewport === "object" ? body.viewport : void 0
12549
12837
  });
12838
+ runtimeSessionId = created.runtimeSessionId;
12550
12839
  const row = await createSessionRow({
12551
12840
  runtimeSessionId: created.runtimeSessionId,
12552
12841
  liveViewUrl: created.liveViewUrl,
12553
12842
  cdpWsUrl: created.cdpWsUrl,
12554
12843
  label: typeof body.label === "string" ? body.label : null,
12555
- userId: user.id
12844
+ userId: user.id,
12845
+ concurrencyLockId: gate.lockId
12556
12846
  });
12557
12847
  return c.json({ ...publicSession(row), watch_url: `/console/${row.id}` });
12558
12848
  } catch (err) {
12849
+ if (runtimeSessionId) {
12850
+ try {
12851
+ await closeSession(runtimeSessionId);
12852
+ } catch {
12853
+ }
12854
+ }
12855
+ await releaseConcurrencyGate(gate.lockId);
12559
12856
  return c.json(failure(err), 502);
12560
12857
  }
12561
12858
  });
@@ -12586,6 +12883,7 @@ function buildBrowserAgentRoutes() {
12586
12883
  } catch {
12587
12884
  }
12588
12885
  await markSessionClosed(row.id);
12886
+ await releaseConcurrencyGate(row.concurrency_lock_id);
12589
12887
  return c.json({ ok: true });
12590
12888
  });
12591
12889
  app2.post("/sessions/:id/goto", async (c) => {
@@ -13542,9 +13840,6 @@ app.delete("/api-key", requireAllowedOrigin, sessionAuth, async (c) => {
13542
13840
  await revokeApiKey(user.id);
13543
13841
  return c.json({ ok: true });
13544
13842
  });
13545
- var BYPASS_EMAILS = new Set(
13546
- (process.env.HARVEST_LIMIT_BYPASS_EMAILS ?? "").split(",").map((e) => e.trim()).filter(Boolean)
13547
- );
13548
13843
  var SYNC_HARVEST_TIMEOUT_OVERRIDE_MS = (() => {
13549
13844
  const raw = process.env.SYNC_HARVEST_TIMEOUT_MS;
13550
13845
  const parsed = raw === void 0 ? NaN : Number(raw);
@@ -13573,11 +13868,20 @@ function countPaaQuestions(result) {
13573
13868
  function paaCostForQuestionCount(questionCount) {
13574
13869
  return Math.max(1, questionCount) * MC_COSTS.paa;
13575
13870
  }
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.` };
13871
+ async function checkHarvestLimits(user, reuseLockId) {
13872
+ if (reuseLockId && await reuseExistingConcurrencyGate(user.id, reuseLockId, 30 * 60)) return null;
13873
+ if (isConcurrencyLimitBypassed(user)) return null;
13874
+ const limit = concurrencyLimitForUser(user);
13875
+ const active = await countActiveUsageForUser(user.id);
13876
+ if (active >= limit) {
13877
+ return concurrencyLimitExceededResponse({
13878
+ ok: false,
13879
+ active,
13880
+ limit,
13881
+ operation: "harvest",
13882
+ retryAfterSeconds: 30
13883
+ });
13884
+ }
13581
13885
  return null;
13582
13886
  }
13583
13887
  app.post("/harvest", auth2, async (c) => {
@@ -13592,8 +13896,8 @@ app.post("/harvest", auth2, async (c) => {
13592
13896
  if (checked.error) return c.json({ error: checked.error }, 400);
13593
13897
  body.callback_url = checked.parsed?.href;
13594
13898
  }
13595
- const limitErr = await checkHarvestLimits(user.id, user.email, user.extra_concurrency_slots);
13596
- if (limitErr) return c.json(limitErr, 429);
13899
+ const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
13900
+ if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
13597
13901
  const options = {
13598
13902
  query: body.query.trim(),
13599
13903
  location: body.location,
@@ -13628,8 +13932,8 @@ app.post("/harvest/sync", auth2, async (c) => {
13628
13932
  const bodyResult = HarvestBodySchema.safeParse(raw);
13629
13933
  if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
13630
13934
  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);
13935
+ const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
13936
+ if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
13633
13937
  const options = {
13634
13938
  query: body.query.trim(),
13635
13939
  location: body.location,
@@ -13788,9 +14092,16 @@ app.post("/extract-url", auth2, async (c) => {
13788
14092
  }
13789
14093
  })();
13790
14094
  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);
14095
+ const gate = await acquireConcurrencyGate(user, "extract_url", {
14096
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
14097
+ metadata: { url: canonicalUrl }
14098
+ });
14099
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
14100
+ let debited = false;
13793
14101
  try {
14102
+ const { ok: euOk, balance_mc: euBal } = await debitMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, new URL(canonicalUrl).hostname);
14103
+ if (!euOk) return c.json(insufficientBalanceResponse(euBal, MC_COSTS.page_scrape), 402);
14104
+ debited = true;
13794
14105
  const kernelApiKey = browserServiceApiKey();
13795
14106
  const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
13796
14107
  const [result, pageData] = await Promise.all([
@@ -13808,9 +14119,11 @@ app.post("/extract-url", auth2, async (c) => {
13808
14119
  return c.json({ ...result, screenshot: screenshotMeta, branding: brandingData, media: mediaMeta });
13809
14120
  } catch (err) {
13810
14121
  const msg = err instanceof Error ? err.message : String(err);
13811
- await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
14122
+ if (debited) await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
13812
14123
  await logRequestEvent({ userId: user.id, source: "extract_url", status: "failed", query: canonicalUrl, error: msg });
13813
14124
  return c.json({ error: msg }, 500);
14125
+ } finally {
14126
+ await releaseConcurrencyGate(gate.lockId);
13814
14127
  }
13815
14128
  });
13816
14129
  app.post("/map-urls", auth2, async (c) => {
@@ -13822,9 +14135,16 @@ app.post("/map-urls", auth2, async (c) => {
13822
14135
  if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
13823
14136
  const parsed = checked.parsed;
13824
14137
  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);
14138
+ const gate = await acquireConcurrencyGate(user, "map_urls", {
14139
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
14140
+ metadata: { url: parsed.href }
14141
+ });
14142
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
14143
+ let debited = false;
13827
14144
  try {
14145
+ const { ok: mapOk, balance_mc: mapBal } = await debitMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP, parsed.hostname);
14146
+ if (!mapOk) return c.json(insufficientBalanceResponse(mapBal, MC_COSTS.url_map), 402);
14147
+ debited = true;
13828
14148
  const result = await spiderSite({
13829
14149
  startUrl: parsed.href,
13830
14150
  maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
@@ -13842,7 +14162,7 @@ app.post("/map-urls", auth2, async (c) => {
13842
14162
  return c.json(result);
13843
14163
  } catch (err) {
13844
14164
  const msg = err instanceof Error ? err.message : String(err);
13845
- await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
14165
+ if (debited) await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
13846
14166
  await logRequestEvent({
13847
14167
  userId: user.id,
13848
14168
  source: "map_urls",
@@ -13851,6 +14171,8 @@ app.post("/map-urls", auth2, async (c) => {
13851
14171
  error: msg
13852
14172
  });
13853
14173
  return c.json({ error: msg }, 500);
14174
+ } finally {
14175
+ await releaseConcurrencyGate(gate.lockId);
13854
14176
  }
13855
14177
  });
13856
14178
  app.post("/extract-site", auth2, async (c) => {
@@ -13863,9 +14185,16 @@ app.post("/extract-site", auth2, async (c) => {
13863
14185
  const parsed = checked.parsed;
13864
14186
  const user = c.get("user");
13865
14187
  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);
14188
+ const gate = await acquireConcurrencyGate(user, "extract_site", {
14189
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
14190
+ metadata: { url: parsed.href }
14191
+ });
14192
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
14193
+ let debited = false;
13868
14194
  try {
14195
+ const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
14196
+ if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
14197
+ debited = true;
13869
14198
  const result = await extractSite({
13870
14199
  startUrl: parsed.href,
13871
14200
  maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
@@ -13886,7 +14215,7 @@ app.post("/extract-site", auth2, async (c) => {
13886
14215
  });
13887
14216
  return c.json(result);
13888
14217
  } catch (err) {
13889
- await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
14218
+ if (debited) await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
13890
14219
  const msg = err instanceof Error ? err.message : String(err);
13891
14220
  await logRequestEvent({
13892
14221
  userId: user.id,
@@ -13896,6 +14225,8 @@ app.post("/extract-site", auth2, async (c) => {
13896
14225
  error: msg
13897
14226
  });
13898
14227
  return c.json({ error: msg }, 500);
14228
+ } finally {
14229
+ await releaseConcurrencyGate(gate.lockId);
13899
14230
  }
13900
14231
  });
13901
14232
  app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
@@ -13964,6 +14295,60 @@ app.post("/billing/concurrency/checkout", requireAllowedOrigin, sessionAuth, asy
13964
14295
  return c.json({ error: message }, 500);
13965
14296
  }
13966
14297
  });
14298
+ app.post("/billing/concurrency/terminal-checkout", auth2, async (c) => {
14299
+ try {
14300
+ const user = c.get("user");
14301
+ const upgrade = concurrencySlotBillingInfo();
14302
+ if (user.concurrency_stripe_sub_id) {
14303
+ return c.json({
14304
+ error: "Already subscribed. Your account already has an active extra concurrency slot subscription.",
14305
+ error_code: "concurrency_subscription_exists",
14306
+ concurrency: {
14307
+ extra_slots: user.extra_concurrency_slots,
14308
+ limit: concurrencyLimitForUser(user),
14309
+ has_subscription: true,
14310
+ upgrade
14311
+ }
14312
+ }, 409);
14313
+ }
14314
+ const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
14315
+ if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
14316
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
14317
+ let customerId = user.stripe_customer_id;
14318
+ if (!customerId) {
14319
+ const customer = await stripeClient.customers.create({ email: user.email });
14320
+ customerId = customer.id;
14321
+ await setStripeCustomerId(user.id, customerId);
14322
+ }
14323
+ const session = await stripeClient.checkout.sessions.create({
14324
+ customer: customerId,
14325
+ mode: "subscription",
14326
+ line_items: [{ price: CONCURRENCY_PRICE_ID, quantity: 1 }],
14327
+ automatic_tax: { enabled: true },
14328
+ billing_address_collection: "required",
14329
+ customer_update: { address: "auto", name: "auto" },
14330
+ tax_id_collection: { enabled: true },
14331
+ success_url: `${appOrigin()}/billing?slot_added=1&source=terminal`,
14332
+ cancel_url: `${appOrigin()}/billing?slot_cancelled=1&source=terminal`
14333
+ });
14334
+ if (!session.url) return c.json({ error: "Stripe did not return a checkout URL." }, 502);
14335
+ return c.json({
14336
+ checkout_url: session.url,
14337
+ price: upgrade,
14338
+ concurrency: {
14339
+ current_extra_slots: user.extra_concurrency_slots,
14340
+ current_limit: concurrencyLimitForUser(user),
14341
+ after_checkout_extra_slots: user.extra_concurrency_slots + 1,
14342
+ after_checkout_limit: concurrencyLimitForUser({ extra_concurrency_slots: user.extra_concurrency_slots + 1 })
14343
+ },
14344
+ next_step: "Open checkout_url in a browser, complete checkout, then restart or retry the MCP request."
14345
+ });
14346
+ } catch (err) {
14347
+ const message = err instanceof Error ? err.message : "Unable to start terminal checkout.";
14348
+ console.error("[billing/concurrency/terminal-checkout]", message);
14349
+ return c.json({ error: message }, 500);
14350
+ }
14351
+ });
13967
14352
  app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async (c) => {
13968
14353
  const user = c.get("sessionUser");
13969
14354
  if (!user.concurrency_stripe_sub_id) return c.json({ error: "No active concurrency subscription." }, 404);
@@ -13971,9 +14356,10 @@ app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async
13971
14356
  if (!stripeSecret) return c.json({ error: "Stripe is not configured." }, 503);
13972
14357
  const stripeClient = new Stripe2(stripeSecret, { apiVersion: STRIPE_API_VERSION });
13973
14358
  await stripeClient.subscriptions.cancel(user.concurrency_stripe_sub_id);
13974
- await setExtraConcurrencySlots(user.id, Math.max(0, user.extra_concurrency_slots - 1));
14359
+ const nextSlots = Math.max(0, user.extra_concurrency_slots - 1);
14360
+ await setExtraConcurrencySlots(user.id, nextSlots);
13975
14361
  await setConcurrencySubId(user.id, null);
13976
- return c.json({ ok: true, concurrency_limit: user.extra_concurrency_slots });
14362
+ return c.json({ ok: true, extra_concurrency_slots: nextSlots, concurrency_limit: 1 + nextSlots });
13977
14363
  });
13978
14364
  app.get("/billing/balance", auth2, async (c) => {
13979
14365
  const user = c.get("user");
@@ -14008,6 +14394,12 @@ app.post("/billing/credits", auth2, async (c) => {
14008
14394
  item: body.item ?? null,
14009
14395
  matched_cost: matchedCost ? (({ aliases, ...cost }) => cost)(matchedCost) : null,
14010
14396
  costs,
14397
+ concurrency: {
14398
+ current_extra_slots: user.extra_concurrency_slots,
14399
+ current_limit: concurrencyLimitForUser(user),
14400
+ has_subscription: !!user.concurrency_stripe_sub_id,
14401
+ upgrade: concurrencySlotBillingInfo()
14402
+ },
14011
14403
  ledger
14012
14404
  });
14013
14405
  });
@@ -14016,7 +14408,7 @@ app.get("/cron/tick", async (c) => {
14016
14408
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
14017
14409
  return c.json({ error: "Unauthorized" }, 401);
14018
14410
  }
14019
- const { drainQueue } = await import("./worker-TDJQ6TH3.js");
14411
+ const { drainQueue } = await import("./worker-OZSWIS3F.js");
14020
14412
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
14021
14413
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
14022
14414
  const [results, sweepResult] = await Promise.all([
@@ -14144,4 +14536,4 @@ app.get("/blog/:slug/", (c) => {
14144
14536
  export {
14145
14537
  app
14146
14538
  };
14147
- //# sourceMappingURL=server-LUNOI26E.js.map
14539
+ //# sourceMappingURL=server-7QP6HQJJ.js.map