autokap 1.3.30 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -565,6 +565,12 @@ autokap run <preset-id> --headed
565
565
 
566
566
  # Save the artifacts to a local directory in addition to uploading them:
567
567
  autokap run <preset-id> --output ./screenshots
568
+
569
+ # Dry run: validate the opcode program end-to-end without capturing or
570
+ # uploading anything (0 credits charged). Use this right after generating
571
+ # or updating a preset to confirm navigation, clicks, and postconditions
572
+ # work before spending credits on a real capture.
573
+ autokap run <preset-id> --dry
568
574
  ```
569
575
 
570
576
  ## Complete Examples
@@ -15,6 +15,7 @@ export declare const CLI_FALLBACK_PROGRAM_COMMAND = "autokap run <preset-id> --p
15
15
  export declare function buildCliRunCommand(presetId: string, options?: {
16
16
  local?: boolean;
17
17
  env?: string;
18
+ dry?: boolean;
18
19
  }): string;
19
20
  export declare function buildCliInstalledSetupCommand(cliKey: string): string;
20
21
  export declare const CLI_PUBLIC_COMMANDS: CliPublicCommandDescriptor[];
@@ -7,7 +7,12 @@ export const CLI_DEFAULT_SETUP_COMMAND = "npx autokap@latest init --cli-key <you
7
7
  export const CLI_ADVANCED_SKILL_COMMAND = "autokap skill --agent <agent>";
8
8
  export const CLI_FALLBACK_PROGRAM_COMMAND = "autokap run <preset-id> --program <file>";
9
9
  export function buildCliRunCommand(presetId, options = {}) {
10
- return `autokap run${options.local ? " --local" : ""}${options.env ? ` --env ${options.env}` : ""} ${presetId}`;
10
+ const flags = [
11
+ options.local ? " --local" : "",
12
+ options.env ? ` --env ${options.env}` : "",
13
+ options.dry ? " --dry" : "",
14
+ ].join("");
15
+ return `autokap run${flags} ${presetId}`;
11
16
  }
12
17
  export function buildCliInstalledSetupCommand(cliKey) {
13
18
  return `autokap init --cli-key ${cliKey}`;
@@ -61,6 +66,12 @@ export const CLI_PUBLIC_COMMANDS = [
61
66
  summary: "Show the browser window for debugging",
62
67
  docsDescriptionKey: "cliCmdHeaded",
63
68
  },
69
+ {
70
+ id: "run-dry",
71
+ command: "autokap run <preset-id> --dry",
72
+ summary: "Dry run: execute the opcode program without capturing or uploading (0 credits charged)",
73
+ docsDescriptionKey: "cliCmdRunDry",
74
+ },
64
75
  {
65
76
  id: "project-list",
66
77
  command: "autokap project list",
@@ -11,4 +11,5 @@ export declare function runLocal(presetId: string, opts: {
11
11
  debug?: boolean;
12
12
  env?: string;
13
13
  allowUploadFailure?: boolean;
14
+ dry?: boolean;
14
15
  }): Promise<void>;
@@ -22,11 +22,15 @@ export async function runLocal(presetId, opts) {
22
22
  process.exit(1);
23
23
  }
24
24
  }
25
+ if (opts.dry) {
26
+ logger.info('[capture] DRY RUN — opcodes will execute but no captures or uploads');
27
+ }
25
28
  const run = await runCapture({
26
29
  presetId,
27
30
  env: opts.env,
28
31
  program,
29
32
  allowUploadFailure: opts.allowUploadFailure,
33
+ dryRun: opts.dry,
30
34
  headed: opts.headed,
31
35
  onProgress: (event) => {
32
36
  const prefix = `[capture][${event.variantId}]`;
@@ -29,6 +29,8 @@ export interface CLIRunnerOptions {
29
29
  program?: ExecutionProgram;
30
30
  /** Keep the legacy success result when upload/telemetry persistence fails */
31
31
  allowUploadFailure?: boolean;
32
+ /** Dry run: skip capture opcodes (CAPTURE_SCREENSHOT/BEGIN_CLIP/END_CLIP) and upload. 0 credits charged. */
33
+ dryRun?: boolean;
32
34
  /** Selector memory map (fetched from server or cached locally) */
33
35
  selectorMemory?: Record<string, string[]>;
34
36
  /** Show browser window. Default: false (headless) */
@@ -195,6 +195,7 @@ export async function runCapture(options) {
195
195
  maxParallelVariants,
196
196
  llmConfig,
197
197
  presetName: program.presetId,
198
+ dryRun: options.dryRun,
198
199
  onProgress: (event) => {
199
200
  if (!options.onProgress) {
200
201
  logProgress(event);
@@ -262,6 +263,10 @@ export async function runCapture(options) {
262
263
  else {
263
264
  logger.error(`[capture] Run failed: ${runResult.error}`);
264
265
  }
266
+ if (options.dryRun) {
267
+ logger.info(`[capture] DRY RUN complete — ${runResult.telemetry.totalOpcodes} opcodes executed, 0 captures, 0 credits charged`);
268
+ return { success: runResult.success, runId, runResult };
269
+ }
265
270
  try {
266
271
  logger.info('[capture] Saving captures, might take a few seconds...');
267
272
  options.onProgress?.({
package/dist/cli.js CHANGED
@@ -249,6 +249,7 @@ program
249
249
  .option('--allow-upload-failure', 'Keep a successful capture exit code even if artifact upload fails', false)
250
250
  .option('--output <dir>', 'Optional output directory for local artifact copies')
251
251
  .option('--program <file>', 'Path to a program JSON file')
252
+ .option('--dry', 'Dry run: execute all opcodes without capturing or uploading artifacts (0 credits charged)', false)
252
253
  .option('--debug', 'Verbose logging: per-substep timing, opcode dumps, recovery strategy traces', false)
253
254
  .action(async (presetId, opts) => {
254
255
  if (opts.debug) {
@@ -41,6 +41,8 @@ export interface RunOptions {
41
41
  llmConfig?: LLMProviderConfig;
42
42
  /** Preset name for alt text context */
43
43
  presetName?: string;
44
+ /** Dry run: skip CAPTURE_SCREENSHOT/BEGIN_CLIP/END_CLIP — other opcodes still execute. */
45
+ dryRun?: boolean;
44
46
  }
45
47
  export interface ProgressEvent {
46
48
  type: 'variant_start' | 'variant_end' | 'opcode_start' | 'opcode_end' | 'recovery' | 'breaker_trip' | 'upload_start' | 'upload_end';
@@ -494,6 +494,10 @@ async function handleFailure(opcode, index, adapter, verifier, isInteraction, br
494
494
  async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, telemetry, currentVariant, executionState, artifactPlan, mockDataGroups, runOptions, credentials) {
495
495
  try {
496
496
  void artifactPlan;
497
+ if (runOptions?.dryRun &&
498
+ (opcode.kind === 'CAPTURE_SCREENSHOT' || opcode.kind === 'BEGIN_CLIP' || opcode.kind === 'END_CLIP')) {
499
+ return { success: true };
500
+ }
497
501
  switch (opcode.kind) {
498
502
  case 'NAVIGATE':
499
503
  case 'DISMISS_OVERLAYS':
@@ -40,6 +40,12 @@ export interface TtsResponse {
40
40
  model: string;
41
41
  /** Voice used. */
42
42
  voice: string;
43
+ /**
44
+ * OpenRouter generation id for async cost resolution via /api/v1/generation.
45
+ * Sourced from the `x-request-id` header. May be null if the provider does
46
+ * not return one.
47
+ */
48
+ generationId: string | null;
43
49
  }
44
50
  export declare class TtsError extends Error {
45
51
  readonly status?: number | undefined;
@@ -70,7 +70,7 @@ export async function generateTtsChunk(config, request) {
70
70
  const format = request.format ?? DEFAULT_RESPONSE_FORMAT;
71
71
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
72
72
  const maxRetries = Math.max(0, config.maxRetries ?? DEFAULT_MAX_RETRIES);
73
- const audioBuffer = await fetchAudioWithRetry({
73
+ const { audioBuffer, generationId } = await fetchAudioWithRetry({
74
74
  baseUrl,
75
75
  apiKey: config.apiKey,
76
76
  model,
@@ -89,6 +89,7 @@ export async function generateTtsChunk(config, request) {
89
89
  durationMs,
90
90
  model,
91
91
  voice: request.voice,
92
+ generationId,
92
93
  };
93
94
  }
94
95
  async function fetchAudioWithRetry(params) {
@@ -153,7 +154,10 @@ async function fetchAudio(params) {
153
154
  if (arrayBuffer.byteLength === 0) {
154
155
  throw new TtsError('TTS provider returned empty body');
155
156
  }
156
- return Buffer.from(arrayBuffer);
157
+ return {
158
+ audioBuffer: Buffer.from(arrayBuffer),
159
+ generationId: response.headers.get('x-request-id'),
160
+ };
157
161
  }
158
162
  finally {
159
163
  clearTimeout(timeoutHandle);
package/dist/types.d.ts CHANGED
@@ -583,7 +583,7 @@ export interface ClipOptions {
583
583
  /** Usage metadata from a single OpenRouter API call */
584
584
  export interface StepUsage {
585
585
  stepNumber: number;
586
- stepType: 'agent_iteration' | 'verification' | 'element_capture' | 'video_planning' | 'video_variant_classification' | 'video_step_verification' | 'video_step_fix' | 'assistant_chat' | 'studio_creation' | 'studio_iteration' | 'mock_data_generation' | 'page_identity_classification' | 'capture_verification' | 'alt_text_generation' | 'healer_invocation';
586
+ stepType: 'agent_iteration' | 'verification' | 'element_capture' | 'video_planning' | 'video_variant_classification' | 'video_step_verification' | 'video_step_fix' | 'assistant_chat' | 'studio_creation' | 'studio_iteration' | 'studio_capture_suggestion' | 'mock_data_generation' | 'page_identity_classification' | 'capture_verification' | 'alt_text_generation' | 'healer_invocation' | 'cron_feedback_classification' | 'tts_generation';
587
587
  generationId: string | null;
588
588
  modelRequested: string;
589
589
  modelUsed: string | null;
@@ -497,7 +497,15 @@ export class WebPlaywrightLocal {
497
497
  const ffmpegResult = await this.recording.ffmpegRecorder.stop();
498
498
  logger.info(`[capture] Clip ffmpeg+nvenc capture: ${(ffmpegResult.durationMs / 1000).toFixed(2)}s wall, ` +
499
499
  `trim ${Math.round(ffmpegResult.trimStartMs)}ms, output ${ffmpegResult.outputPath}`);
500
- await this.browser.closeContext();
500
+ // Persistent context (cloud) — DO NOT closeContext here. Closing the
501
+ // persistent context tears down the underlying browser process, which
502
+ // breaks the END_CLIP opcode handler's `adapter.getCurrentUrl()` call
503
+ // that runs immediately after this returns (and any subsequent
504
+ // NAVIGATE/CLICK/BEGIN_CLIP opcodes in the same preset). The CDP
505
+ // screencast path (non-ffmpeg branch below) closes context here to
506
+ // release the loop's CDP session — that's not needed for x11grab,
507
+ // ffmpeg has already finalized the MP4. Browser cleanup happens in
508
+ // Browser.close() at end of session.
501
509
  this.recording.finalized = true;
502
510
  this.recording.ffmpegResult = ffmpegResult;
503
511
  this.recording.sourcePath = ffmpegResult.outputPath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.3.30",
3
+ "version": "1.4.0",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,124 +0,0 @@
1
- import type { SupabaseClient } from '@supabase/supabase-js';
2
- export type BillingPlanId = 'free' | 'maker' | 'team';
3
- export interface BillingPlanEntitlements {
4
- creditsPerMonth: number;
5
- maxProjects: number | null;
6
- maxPresetsPerProject: number | null;
7
- allowFullPageCapture: boolean;
8
- allowElementCapture: boolean;
9
- retentionDays: number;
10
- watermarkScreenshots: boolean;
11
- maxLanguages: number | null;
12
- maxThemes: number | null;
13
- apiAccess: boolean;
14
- captureCompleteWebhook: boolean;
15
- priorityQueue: boolean;
16
- maxParallelCaptures: number;
17
- teamMembers: boolean;
18
- aiDailyCostLimitUsd: number;
19
- apiRateLimitRpm: number;
20
- maxDevLinks: number | null;
21
- maxTeamMembersPerProject: number | null;
22
- }
23
- export interface BillingPlan {
24
- id: BillingPlanId;
25
- nameKey: BillingPlanId;
26
- descriptionKey: 'forEvaluation' | 'forMakers' | 'forTeams';
27
- monthlyPriceEur: number;
28
- yearlyPriceEur: number;
29
- highlighted?: boolean;
30
- previewCurrent?: boolean;
31
- highlights: string[];
32
- entitlements: BillingPlanEntitlements;
33
- }
34
- export interface BillingAccount {
35
- user_id: string;
36
- email: string | null;
37
- admin_override_plan_id: BillingPlanId | null;
38
- stripe_customer_id: string | null;
39
- stripe_subscription_id: string | null;
40
- stripe_plan_id: BillingPlanId | null;
41
- stripe_subscription_status: string | null;
42
- stripe_current_period_start: string | null;
43
- stripe_current_period_end: string | null;
44
- allow_capture_overages: boolean;
45
- capture_overage_limit_cents: number | null;
46
- signup_bonus_credits: number;
47
- created_at: string;
48
- }
49
- export declare class PlanLimitError extends Error {
50
- status: number;
51
- code: string;
52
- constructor(message: string, status?: number, code?: string);
53
- }
54
- export declare const SCREENSHOT_CREDIT_COST = 1;
55
- export declare function getBillingPlan(planId: BillingPlanId): BillingPlan;
56
- export declare function coerceBillingPlanId(value: unknown): BillingPlanId | null;
57
- export declare function getBillingAccountForUser(supabase: SupabaseClient, userId: string): Promise<BillingAccount | null>;
58
- export declare function getProjectOwnerBillingContext(supabase: SupabaseClient, projectId: string): Promise<{
59
- ownerUserId: string;
60
- plan: BillingPlan;
61
- }>;
62
- export declare function shouldUseStripePlan(status: string | null | undefined): boolean;
63
- export declare function isYearlySubscription(account: BillingAccount | null): boolean;
64
- export declare function getStripeOveragePriceIdForPlan(planId: BillingPlanId): string | null;
65
- export declare function recordStripeMeterEvent(params: {
66
- customerId: string;
67
- value: number;
68
- identifier: string;
69
- }): Promise<void>;
70
- export declare function getCaptureConfigLimitViolations(config: {
71
- langs: string[];
72
- themes: string[];
73
- elements?: unknown[];
74
- }, entitlements: BillingPlanEntitlements): string[];
75
- export declare function ensureCaptureConfigAllowed(plan: BillingPlan, config: {
76
- langs: string[];
77
- themes: string[];
78
- elements?: unknown[];
79
- }): void;
80
- export declare function getLockedResourceIds(supabase: SupabaseClient, ownerUserId: string, plan: BillingPlan): Promise<{
81
- lockedProjectIds: Set<string>;
82
- lockedPresetIdsByProject: Map<string, Set<string>>;
83
- }>;
84
- export declare function ensureResourceNotLocked(resourceId: string, lockedIds: Set<string>, resourceType?: 'project' | 'preset'): void;
85
- export declare function getRemainingOverageCredits(params: {
86
- planId: BillingPlanId;
87
- quota: number;
88
- usedCredits: number;
89
- overageLimitCents: number | null;
90
- }): number | null;
91
- export declare function getCaptureOverageState(params: {
92
- planId: BillingPlanId;
93
- quota: number;
94
- usedCredits: number;
95
- allowOverages: boolean;
96
- overageLimitCents: number | null;
97
- hasActiveSubscription: boolean;
98
- hasOveragePrice: boolean;
99
- isYearlySubscription: boolean;
100
- }): {
101
- eligible: boolean;
102
- enabled: boolean;
103
- limitReached: boolean;
104
- spendCents: number;
105
- remainingBudgetCents: number | null;
106
- remainingCredits: number | null;
107
- };
108
- export declare function getSignupBonusCredits(account: {
109
- signup_bonus_credits?: number;
110
- created_at?: string;
111
- } | null, billingPeriodStart: string): number;
112
- export declare function ensureMonthlyCreditsQuota(plan: BillingPlan, usedCredits: number, requestedCredits: number, allowOverage?: boolean, overageLimitCents?: number | null, bonusCredits?: number): void;
113
- export declare function getOwnerBillingUsage(supabase: SupabaseClient, ownerUserId: string, billingPeriodStartOverride?: string): Promise<{
114
- billingPeriodStart: string;
115
- credits: number;
116
- }>;
117
- export declare function getIncrementalOverageCount(params: {
118
- quota: number;
119
- usedBefore: number;
120
- completedCount: number;
121
- }): number;
122
- export declare function cleanupExpiredCapturesForOwner(supabase: SupabaseClient, ownerUserId: string): Promise<{
123
- deletedCaptures: number;
124
- }>;
@@ -1,582 +0,0 @@
1
- export class PlanLimitError extends Error {
2
- status;
3
- code;
4
- constructor(message, status = 403, code = 'plan_limit') {
5
- super(message);
6
- this.name = 'PlanLimitError';
7
- this.status = status;
8
- this.code = code;
9
- }
10
- }
11
- export const SCREENSHOT_CREDIT_COST = 1;
12
- const CREDIT_OVERAGE_CENTS = {
13
- free: 0,
14
- maker: 6,
15
- team: 5,
16
- };
17
- const STRIPE_ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due']);
18
- const BILLING_PLANS = [
19
- {
20
- id: 'free',
21
- nameKey: 'free',
22
- descriptionKey: 'forEvaluation',
23
- monthlyPriceEur: 0,
24
- yearlyPriceEur: 0,
25
- previewCurrent: true,
26
- highlights: ['credits', 'projects', 'devLinks', 'retention', 'support'],
27
- entitlements: {
28
- creditsPerMonth: 25,
29
- maxProjects: 1,
30
- maxPresetsPerProject: 2,
31
- allowFullPageCapture: true,
32
- allowElementCapture: true,
33
- retentionDays: 7,
34
- watermarkScreenshots: false,
35
- maxLanguages: null,
36
- maxThemes: null,
37
- apiAccess: true,
38
- captureCompleteWebhook: false,
39
- priorityQueue: false,
40
- maxParallelCaptures: 1,
41
- teamMembers: false,
42
- aiDailyCostLimitUsd: 0.05,
43
- apiRateLimitRpm: 10,
44
- maxDevLinks: 1,
45
- maxTeamMembersPerProject: null,
46
- },
47
- },
48
- {
49
- id: 'maker',
50
- nameKey: 'maker',
51
- descriptionKey: 'forMakers',
52
- monthlyPriceEur: 15,
53
- yearlyPriceEur: 144,
54
- highlighted: true,
55
- highlights: ['credits', 'projects', 'devLinks', 'retention', 'parallelCaptures', 'captureCompleteWebhook', 'support'],
56
- entitlements: {
57
- creditsPerMonth: 100,
58
- maxProjects: 3,
59
- maxPresetsPerProject: 5,
60
- allowFullPageCapture: true,
61
- allowElementCapture: true,
62
- retentionDays: 30,
63
- watermarkScreenshots: false,
64
- maxLanguages: null,
65
- maxThemes: null,
66
- apiAccess: true,
67
- captureCompleteWebhook: true,
68
- priorityQueue: false,
69
- maxParallelCaptures: 2,
70
- teamMembers: false,
71
- aiDailyCostLimitUsd: 0.10,
72
- apiRateLimitRpm: 60,
73
- maxDevLinks: 10,
74
- maxTeamMembersPerProject: null,
75
- },
76
- },
77
- {
78
- id: 'team',
79
- nameKey: 'team',
80
- descriptionKey: 'forTeams',
81
- monthlyPriceEur: 49,
82
- yearlyPriceEur: 468,
83
- highlights: ['credits', 'projects', 'devLinks', 'retention', 'parallelCaptures', 'teamMembers', 'priorityQueue', 'support'],
84
- entitlements: {
85
- creditsPerMonth: 500,
86
- maxProjects: null,
87
- maxPresetsPerProject: null,
88
- allowFullPageCapture: true,
89
- allowElementCapture: true,
90
- retentionDays: 90,
91
- watermarkScreenshots: false,
92
- maxLanguages: null,
93
- maxThemes: null,
94
- apiAccess: true,
95
- captureCompleteWebhook: true,
96
- priorityQueue: true,
97
- maxParallelCaptures: 5,
98
- teamMembers: true,
99
- aiDailyCostLimitUsd: 0.15,
100
- apiRateLimitRpm: 200,
101
- maxDevLinks: null,
102
- maxTeamMembersPerProject: 5,
103
- },
104
- },
105
- ];
106
- export function getBillingPlan(planId) {
107
- return BILLING_PLANS.find((plan) => plan.id === planId) ?? BILLING_PLANS[0];
108
- }
109
- export function coerceBillingPlanId(value) {
110
- if (typeof value !== 'string')
111
- return null;
112
- switch (value) {
113
- case 'free':
114
- case 'maker':
115
- case 'team':
116
- return value;
117
- default:
118
- return null;
119
- }
120
- }
121
- function getServerDefaultBillingPlanId() {
122
- return (coerceBillingPlanId(process.env.DEFAULT_BILLING_PLAN)
123
- ?? coerceBillingPlanId(process.env.NEXT_PUBLIC_DEFAULT_BILLING_PLAN)
124
- ?? 'free');
125
- }
126
- function resolvePlanFromBillingAccount(account, defaultPlanId) {
127
- const adminPlanId = coerceBillingPlanId(account?.admin_override_plan_id);
128
- if (adminPlanId) {
129
- return { planId: adminPlanId, source: 'admin_override' };
130
- }
131
- const stripePlanId = coerceBillingPlanId(account?.stripe_plan_id);
132
- if (stripePlanId && shouldUseStripePlan(account?.stripe_subscription_status)) {
133
- return { planId: stripePlanId, source: 'stripe' };
134
- }
135
- return { planId: defaultPlanId, source: 'default' };
136
- }
137
- export async function getBillingAccountForUser(supabase, userId) {
138
- const { data, error } = await supabase
139
- .from('billing_accounts')
140
- .select('*')
141
- .eq('user_id', userId)
142
- .maybeSingle();
143
- if (error) {
144
- if (error.code === '42P01') {
145
- return null;
146
- }
147
- throw new Error(error.message);
148
- }
149
- return data ?? null;
150
- }
151
- async function getResolvedBillingPlanForUserId(supabase, userId) {
152
- const account = await getBillingAccountForUser(supabase, userId);
153
- const resolved = resolvePlanFromBillingAccount(account, getServerDefaultBillingPlanId());
154
- return getBillingPlan(resolved.planId);
155
- }
156
- export async function getProjectOwnerBillingContext(supabase, projectId) {
157
- const { data, error } = await supabase
158
- .from('projects')
159
- .select('user_id')
160
- .eq('id', projectId)
161
- .single();
162
- if (error || !data?.user_id) {
163
- throw new Error('Project not found');
164
- }
165
- return {
166
- ownerUserId: data.user_id,
167
- plan: await getResolvedBillingPlanForUserId(supabase, data.user_id),
168
- };
169
- }
170
- export function shouldUseStripePlan(status) {
171
- return !!status && STRIPE_ACTIVE_STATUSES.has(status);
172
- }
173
- export function isYearlySubscription(account) {
174
- if (!account?.stripe_current_period_start || !account?.stripe_current_period_end) {
175
- return false;
176
- }
177
- const start = new Date(account.stripe_current_period_start).getTime();
178
- const end = new Date(account.stripe_current_period_end).getTime();
179
- return (end - start) / (1000 * 60 * 60 * 24) > 60;
180
- }
181
- export function getStripeOveragePriceIdForPlan(planId) {
182
- switch (planId) {
183
- case 'maker':
184
- return process.env.STRIPE_PRICE_ID_MAKER_OVERAGE ?? null;
185
- case 'team':
186
- return process.env.STRIPE_PRICE_ID_TEAM_OVERAGE ?? null;
187
- case 'free':
188
- return null;
189
- }
190
- }
191
- function getStripeSecretKey() {
192
- const key = process.env.STRIPE_SECRET_KEY;
193
- if (!key) {
194
- throw new Error('Missing STRIPE_SECRET_KEY');
195
- }
196
- return key;
197
- }
198
- function getStripeMeterEventName() {
199
- return process.env.STRIPE_METER_EVENT_NAME ?? 'credit';
200
- }
201
- async function stripeRequest(path, body, method) {
202
- const resolvedMethod = method ?? (body ? 'POST' : 'GET');
203
- const response = await fetch(`https://api.stripe.com${path}`, {
204
- method: resolvedMethod,
205
- headers: {
206
- Authorization: `Bearer ${getStripeSecretKey()}`,
207
- ...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
208
- },
209
- body: body?.toString(),
210
- });
211
- const data = (await response.json());
212
- if (!response.ok) {
213
- throw new Error(data.error?.message || `Stripe request failed: ${path}`);
214
- }
215
- return data;
216
- }
217
- export async function recordStripeMeterEvent(params) {
218
- const body = new URLSearchParams();
219
- body.set('event_name', getStripeMeterEventName());
220
- body.set('payload[stripe_customer_id]', params.customerId);
221
- body.set('payload[value]', String(params.value));
222
- body.set('identifier', params.identifier.slice(0, 100));
223
- body.set('timestamp', String(Math.floor(Date.now() / 1000)));
224
- await stripeRequest('/v1/billing/meter_events', body);
225
- }
226
- export function getCaptureConfigLimitViolations(config, entitlements) {
227
- const violations = [];
228
- if (entitlements.maxLanguages !== null
229
- && config.langs.length > entitlements.maxLanguages) {
230
- violations.push(`This plan allows up to ${entitlements.maxLanguages} language${entitlements.maxLanguages > 1 ? 's' : ''}.`);
231
- }
232
- if (entitlements.maxThemes !== null
233
- && config.themes.length > entitlements.maxThemes) {
234
- violations.push(`This plan allows up to ${entitlements.maxThemes} theme${entitlements.maxThemes > 1 ? 's' : ''}.`);
235
- }
236
- if ((config.elements?.length ?? 0) > 0 && !entitlements.allowElementCapture) {
237
- violations.push('Element captures are not included in this plan.');
238
- }
239
- return violations;
240
- }
241
- export function ensureCaptureConfigAllowed(plan, config) {
242
- const violations = getCaptureConfigLimitViolations(config, plan.entitlements);
243
- if (violations.length > 0) {
244
- throw new PlanLimitError(violations[0], 403, 'capture_feature_unavailable');
245
- }
246
- }
247
- function sortByMostRecent(items) {
248
- return [...items].sort((left, right) => {
249
- const diff = new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime();
250
- if (diff !== 0)
251
- return diff;
252
- return left.id.localeCompare(right.id);
253
- });
254
- }
255
- function computeLockedProjectIds(projects, maxProjects) {
256
- if (maxProjects === null || projects.length <= maxProjects) {
257
- return new Set();
258
- }
259
- return new Set(sortByMostRecent(projects).slice(maxProjects).map((project) => project.id));
260
- }
261
- function computeLockedPresetIds(presets, maxPresetsPerProject) {
262
- if (maxPresetsPerProject === null || presets.length <= maxPresetsPerProject) {
263
- return new Set();
264
- }
265
- return new Set(sortByMostRecent(presets).slice(maxPresetsPerProject).map((preset) => preset.id));
266
- }
267
- export async function getLockedResourceIds(supabase, ownerUserId, plan) {
268
- const { data: projects } = await supabase
269
- .from('projects')
270
- .select('id, updated_at')
271
- .eq('user_id', ownerUserId)
272
- .order('updated_at', { ascending: false });
273
- const projectList = (projects ?? []);
274
- const lockedProjectIds = computeLockedProjectIds(projectList, plan.entitlements.maxProjects);
275
- const lockedPresetIdsByProject = new Map();
276
- if (plan.entitlements.maxPresetsPerProject !== null) {
277
- const activeProjectIds = projectList
278
- .map((project) => project.id)
279
- .filter((id) => !lockedProjectIds.has(id));
280
- if (activeProjectIds.length > 0) {
281
- const { data: presets } = await supabase
282
- .from('presets')
283
- .select('id, project_id, updated_at')
284
- .in('project_id', activeProjectIds)
285
- .order('updated_at', { ascending: false });
286
- const presetsByProject = new Map();
287
- for (const preset of (presets ?? [])) {
288
- const list = presetsByProject.get(preset.project_id) ?? [];
289
- list.push({ id: preset.id, updated_at: preset.updated_at });
290
- presetsByProject.set(preset.project_id, list);
291
- }
292
- for (const [projectId, projectPresets] of presetsByProject.entries()) {
293
- const locked = computeLockedPresetIds(projectPresets, plan.entitlements.maxPresetsPerProject);
294
- if (locked.size > 0) {
295
- lockedPresetIdsByProject.set(projectId, locked);
296
- }
297
- }
298
- }
299
- }
300
- return { lockedProjectIds, lockedPresetIdsByProject };
301
- }
302
- export function ensureResourceNotLocked(resourceId, lockedIds, resourceType = 'preset') {
303
- if (lockedIds.has(resourceId)) {
304
- throw new PlanLimitError(`This ${resourceType} is locked because it exceeds your current plan limits. Upgrade or remove other ${resourceType}s to regain access.`, 403, 'resource_locked');
305
- }
306
- }
307
- function getCreditOverageRateCents(planId) {
308
- return CREDIT_OVERAGE_CENTS[planId];
309
- }
310
- function getCurrentOverageCredits(params) {
311
- return Math.max(0, params.usedCredits - params.quota);
312
- }
313
- function getCurrentOverageSpendCents(params) {
314
- return getCurrentOverageCredits({
315
- quota: params.quota,
316
- usedCredits: params.usedCredits,
317
- }) * getCreditOverageRateCents(params.planId);
318
- }
319
- function getProjectedOverageSpendCents(params) {
320
- return getCurrentOverageSpendCents({
321
- planId: params.planId,
322
- quota: params.quota,
323
- usedCredits: params.usedCreditsBefore + params.requestedCredits,
324
- });
325
- }
326
- function getRemainingOverageBudgetCents(params) {
327
- if (params.overageLimitCents === null) {
328
- return null;
329
- }
330
- return Math.max(0, params.overageLimitCents - getCurrentOverageSpendCents({
331
- planId: params.planId,
332
- quota: params.quota,
333
- usedCredits: params.usedCredits,
334
- }));
335
- }
336
- export function getRemainingOverageCredits(params) {
337
- if (params.overageLimitCents === null) {
338
- return null;
339
- }
340
- const rateCents = getCreditOverageRateCents(params.planId);
341
- if (rateCents <= 0) {
342
- return 0;
343
- }
344
- const remainingBudgetCents = getRemainingOverageBudgetCents(params) ?? 0;
345
- return Math.floor(remainingBudgetCents / rateCents);
346
- }
347
- export function getCaptureOverageState(params) {
348
- const spendCents = getCurrentOverageSpendCents({
349
- planId: params.planId,
350
- quota: params.quota,
351
- usedCredits: params.usedCredits,
352
- });
353
- const remainingBudgetCents = getRemainingOverageBudgetCents({
354
- planId: params.planId,
355
- quota: params.quota,
356
- usedCredits: params.usedCredits,
357
- overageLimitCents: params.overageLimitCents,
358
- });
359
- const remainingCredits = getRemainingOverageCredits({
360
- planId: params.planId,
361
- quota: params.quota,
362
- usedCredits: params.usedCredits,
363
- overageLimitCents: params.overageLimitCents,
364
- });
365
- const eligible = params.planId !== 'free'
366
- && params.allowOverages
367
- && params.hasActiveSubscription
368
- && !params.isYearlySubscription
369
- && params.hasOveragePrice;
370
- const limitReached = eligible
371
- && params.overageLimitCents !== null
372
- && (remainingBudgetCents ?? 0) <= 0;
373
- return {
374
- eligible,
375
- enabled: eligible && !limitReached,
376
- limitReached,
377
- spendCents,
378
- remainingBudgetCents,
379
- remainingCredits,
380
- };
381
- }
382
- export function getSignupBonusCredits(account, billingPeriodStart) {
383
- if (!account?.signup_bonus_credits || !account.created_at)
384
- return 0;
385
- const created = new Date(account.created_at);
386
- const periodStart = new Date(billingPeriodStart);
387
- return created >= periodStart ? account.signup_bonus_credits : 0;
388
- }
389
- export function ensureMonthlyCreditsQuota(plan, usedCredits, requestedCredits, allowOverage = false, overageLimitCents = null, bonusCredits = 0) {
390
- const effectiveQuota = plan.entitlements.creditsPerMonth + bonusCredits;
391
- if (allowOverage) {
392
- if (requestedCredits > 2500) {
393
- throw new PlanLimitError(`Batch too large: ${requestedCredits} credits requested. Maximum 2500 per request.`, 400, 'batch_too_large');
394
- }
395
- if (overageLimitCents !== null) {
396
- const projectedOverageSpendCents = getProjectedOverageSpendCents({
397
- planId: plan.id,
398
- quota: effectiveQuota,
399
- usedCreditsBefore: usedCredits,
400
- requestedCredits,
401
- });
402
- if (projectedOverageSpendCents > overageLimitCents) {
403
- throw new PlanLimitError(`Overage limit exceeded: projected overage spend is €${(projectedOverageSpendCents / 100).toFixed(2)} for a €${(overageLimitCents / 100).toFixed(2)} cap.`, 403, 'credit_overage_limit_exceeded');
404
- }
405
- }
406
- return;
407
- }
408
- if (usedCredits + requestedCredits > effectiveQuota) {
409
- throw new PlanLimitError(`Monthly credit quota exceeded: ${usedCredits} of ${effectiveQuota} used, ${requestedCredits} requested. Upgrade or wait until next month.`, 403, 'credit_quota_exceeded');
410
- }
411
- }
412
- async function resolveStripeBillingPeriodStart(supabase, userId) {
413
- const { data: account } = await supabase
414
- .from('billing_accounts')
415
- .select('stripe_current_period_start, stripe_current_period_end')
416
- .eq('user_id', userId)
417
- .maybeSingle();
418
- if (!account?.stripe_current_period_start) {
419
- return new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1)).toISOString();
420
- }
421
- const periodStart = new Date(account.stripe_current_period_start);
422
- const periodEnd = account.stripe_current_period_end
423
- ? new Date(account.stripe_current_period_end)
424
- : null;
425
- const isYearly = periodEnd
426
- && (periodEnd.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24) > 60;
427
- if (!isYearly) {
428
- return periodStart.toISOString();
429
- }
430
- const now = new Date();
431
- const dayOfMonth = periodStart.getUTCDate();
432
- let resetDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), dayOfMonth));
433
- if (resetDate > now) {
434
- resetDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, dayOfMonth));
435
- }
436
- if (resetDate < periodStart) {
437
- resetDate = periodStart;
438
- }
439
- return resetDate.toISOString();
440
- }
441
- export async function getOwnerBillingUsage(supabase, ownerUserId, billingPeriodStartOverride) {
442
- const [{ data: ownerProjects, error: ownerProjectsError }, billingPeriodStart] = await Promise.all([
443
- supabase.from('projects').select('id').eq('user_id', ownerUserId),
444
- billingPeriodStartOverride
445
- ? Promise.resolve(billingPeriodStartOverride)
446
- : resolveStripeBillingPeriodStart(supabase, ownerUserId),
447
- ]);
448
- if (ownerProjectsError) {
449
- throw new Error(ownerProjectsError.message);
450
- }
451
- const projectIds = (ownerProjects ?? []).map((project) => project.id);
452
- if (projectIds.length === 0) {
453
- return { billingPeriodStart, credits: 0 };
454
- }
455
- const { data: usageRows, error: usageError } = await supabase
456
- .from('credit_usage')
457
- .select('type, credits')
458
- .in('project_id', projectIds)
459
- .gte('created_at', billingPeriodStart);
460
- if (usageError) {
461
- throw new Error(usageError.message);
462
- }
463
- let credits = 0;
464
- for (const row of usageRows ?? []) {
465
- credits += Number(row.credits ?? 0);
466
- }
467
- return { billingPeriodStart, credits };
468
- }
469
- export function getIncrementalOverageCount(params) {
470
- const overageBefore = Math.max(0, params.usedBefore - params.quota);
471
- const overageAfter = Math.max(0, params.usedBefore + params.completedCount - params.quota);
472
- return Math.max(0, overageAfter - overageBefore);
473
- }
474
- function getRetentionCutoffIso(retentionDays, now = new Date()) {
475
- return new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
476
- }
477
- function extractStoragePathFromPublicUrl(url, bucket = 'screenshots') {
478
- if (!url)
479
- return null;
480
- const marker = `/storage/v1/object/public/${bucket}/`;
481
- const index = url.indexOf(marker);
482
- if (index === -1)
483
- return null;
484
- return decodeURIComponent(url.slice(index + marker.length));
485
- }
486
- async function removeStorageObjects(supabase, bucket, paths) {
487
- if (paths.size === 0)
488
- return;
489
- await Promise.all(Array.from(paths).map(async (path) => {
490
- try {
491
- await supabase.storage.from(bucket).remove([path]);
492
- }
493
- catch {
494
- // Best effort.
495
- }
496
- }));
497
- }
498
- async function removeCaptureAssets(supabase, path) {
499
- if (path.startsWith('cli/')) {
500
- await supabase.storage.from('screenshots').remove([path]);
501
- return;
502
- }
503
- const parts = path.split('/');
504
- const folder = parts.slice(0, -1).join('/');
505
- if (!folder) {
506
- await supabase.storage.from('screenshots').remove([path]);
507
- return;
508
- }
509
- const listPrefix = parts.slice(0, -2).join('/');
510
- const folderName = parts.at(-2);
511
- if (!folderName) {
512
- await supabase.storage.from('screenshots').remove([path]);
513
- return;
514
- }
515
- const { data } = await supabase.storage.from('screenshots').list(listPrefix, { limit: 100 });
516
- const targetFolder = data?.find((entry) => entry.name === folderName);
517
- if (!targetFolder) {
518
- await supabase.storage.from('screenshots').remove([path]);
519
- return;
520
- }
521
- const { data: files } = await supabase.storage.from('screenshots').list(folder, { limit: 100 });
522
- const paths = (files ?? []).map((file) => `${folder}/${file.name}`);
523
- if (paths.length === 0) {
524
- await supabase.storage.from('screenshots').remove([path]);
525
- return;
526
- }
527
- await supabase.storage.from('screenshots').remove(paths);
528
- }
529
- async function getProjectIdsForOwner(supabase, ownerUserId) {
530
- const { data: projects, error } = await supabase
531
- .from('projects')
532
- .select('id')
533
- .eq('user_id', ownerUserId);
534
- if (error) {
535
- throw new Error(error.message);
536
- }
537
- return (projects ?? []).map((project) => project.id);
538
- }
539
- export async function cleanupExpiredCapturesForOwner(supabase, ownerUserId) {
540
- const plan = await getResolvedBillingPlanForUserId(supabase, ownerUserId);
541
- const retentionCutoff = getRetentionCutoffIso(plan.entitlements.retentionDays);
542
- const projectIds = await getProjectIdsForOwner(supabase, ownerUserId);
543
- if (projectIds.length === 0) {
544
- return { deletedCaptures: 0 };
545
- }
546
- const { data: captures, error: capturesError } = await supabase
547
- .from('captures')
548
- .select('id, screenshot_url')
549
- .in('project_id', projectIds)
550
- .lt('created_at', retentionCutoff);
551
- if (capturesError) {
552
- throw new Error(capturesError.message);
553
- }
554
- const rows = captures ?? [];
555
- if (rows.length === 0) {
556
- return { deletedCaptures: 0 };
557
- }
558
- const uniquePaths = new Set();
559
- for (const row of rows) {
560
- const path = extractStoragePathFromPublicUrl(row.screenshot_url);
561
- if (path) {
562
- uniquePaths.add(path);
563
- }
564
- }
565
- await Promise.all(Array.from(uniquePaths).map(async (path) => {
566
- try {
567
- await removeCaptureAssets(supabase, path);
568
- }
569
- catch {
570
- // Best effort.
571
- }
572
- }));
573
- const { error: deleteError } = await supabase
574
- .from('captures')
575
- .delete()
576
- .in('id', rows.map((row) => row.id));
577
- if (deleteError) {
578
- throw new Error(deleteError.message);
579
- }
580
- return { deletedCaptures: rows.length };
581
- }
582
- //# sourceMappingURL=server-capture-runtime.js.map