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.
- package/assets/skill/SKILL.md +6 -0
- package/dist/cli-contract.d.ts +1 -0
- package/dist/cli-contract.js +12 -1
- package/dist/cli-runner-local.d.ts +1 -0
- package/dist/cli-runner-local.js +4 -0
- package/dist/cli-runner.d.ts +2 -0
- package/dist/cli-runner.js +5 -0
- package/dist/cli.js +1 -0
- package/dist/opcode-runner.d.ts +2 -0
- package/dist/opcode-runner.js +4 -0
- package/dist/openrouter-tts.d.ts +6 -0
- package/dist/openrouter-tts.js +6 -2
- package/dist/types.d.ts +1 -1
- package/dist/web-playwright-local.js +9 -1
- package/package.json +1 -1
- package/dist/server-capture-runtime.d.ts +0 -124
- package/dist/server-capture-runtime.js +0 -582
package/assets/skill/SKILL.md
CHANGED
|
@@ -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
|
package/dist/cli-contract.d.ts
CHANGED
|
@@ -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[];
|
package/dist/cli-contract.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/dist/cli-runner-local.js
CHANGED
|
@@ -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}]`;
|
package/dist/cli-runner.d.ts
CHANGED
|
@@ -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) */
|
package/dist/cli-runner.js
CHANGED
|
@@ -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) {
|
package/dist/opcode-runner.d.ts
CHANGED
|
@@ -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';
|
package/dist/opcode-runner.js
CHANGED
|
@@ -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':
|
package/dist/openrouter-tts.d.ts
CHANGED
|
@@ -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;
|
package/dist/openrouter-tts.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,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
|