Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Supabase Postgres (Drizzle)
DATABASE_URL=

# Turso (libSQL)
TURSO_DB_URL=libsql://peregrine-prod-org.turso.io
TURSO_AUTH_TOKEN=
Expand All @@ -15,8 +18,12 @@ STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

# WebContainers Commercial License
WEB_CONTAINER_API_KEY=
# WebContainers commercial API key; required for production builds
NEXT_PUBLIC_WEB_CONTAINER_API_KEY=

# Optional, disabled-by-default pilot telemetry
COST_PILOT_ENABLED=false
NEXT_PUBLIC_COST_PILOT_ENABLED=false

# Optional fallback AI (Codeium)
CODEIUM_API_KEY=
104 changes: 104 additions & 0 deletions app/api/cost-pilot/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { createHmac } from "node:crypto";
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { requireAuth } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/drizzle/schema";

export const runtime = "nodejs";

const MAX_DURATION_MS = 60 * 60 * 1000;
const EVENT_ID_PATTERN = /^[a-zA-Z0-9-]{16,64}$/;

function isDuration(value: unknown): value is number | null {
return value === null || (
typeof value === "number" &&
Number.isInteger(value) &&
value >= 0 &&
value <= MAX_DURATION_MS
);
}

export async function POST(request: Request) {
if (process.env.COST_PILOT_ENABLED !== "true") {
return new NextResponse(null, { status: 404 });
}

let userId: string;
try {
userId = await requireAuth();
} catch {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}

if (!body || typeof body !== "object") {
return NextResponse.json({ error: "invalid_event" }, { status: 400 });
}

const event = body as Record<string, unknown>;
const installExitCode = event.npmInstallExitCode;
if (
typeof event.eventId !== "string" ||
!EVENT_ID_PATTERN.test(event.eventId) ||
event.eventType !== "editor_session" ||
!isDuration(event.elapsedMs) ||
event.elapsedMs === null ||
!isDuration(event.npmInstallMs) ||
!(installExitCode === null || (
typeof installExitCode === "number" &&
Number.isInteger(installExitCode) &&
installExitCode >= 0 &&
installExitCode <= 255
)) ||
(event.outcome !== "success" && event.outcome !== "failure")
) {
return NextResponse.json({ error: "invalid_event" }, { status: 400 });
}

const clerkSecret = process.env.CLERK_SECRET_KEY;
if (!clerkSecret) {
return NextResponse.json({ error: "pilot_unavailable" }, { status: 503 });
}

try {
const [account] = await db
.select({ tier: users.tier })
.from(users)
.where(eq(users.id, userId))
.limit(1);

if (!account || (account.tier ?? "free") !== "free") {
return new NextResponse(null, { status: 204 });
}

const occurredAt = new Date();
const month = occurredAt.toISOString().slice(0, 7);
const actorMonthKey = createHmac("sha256", clerkSecret)
.update("peregrine-cost-pilot-v1:" + month + ":" + userId)
.digest("hex");

console.info("PEREGRINE_COST_PILOT_V1", JSON.stringify({
eventId: event.eventId,
eventType: "editor_session",
actorMonthKey,
month,
occurredAt: occurredAt.toISOString(),
elapsedMs: event.elapsedMs,
npmInstallMs: event.npmInstallMs,
npmInstallExitCode: installExitCode,
outcome: event.outcome,
appRevision: process.env.VERCEL_GIT_COMMIT_SHA ?? null,
}));

return NextResponse.json({ accepted: true }, { status: 202 });
} catch {
return NextResponse.json({ error: "pilot_unavailable" }, { status: 503 });
}
}
87 changes: 87 additions & 0 deletions docs/operations/PEREGRINE_FREE_TIER_COST_PILOT_v0.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Peregrine Free-Tier Cost Pilot v0.1.0

**Status:** Draft protocol; not measured and not approved for production use
**Target:** less than USD 0.020 fully loaded cost per monthly active free IDE user
**Repository:** Sovereign-Development
**As of:** 2026-09-25

## Decision boundary

The gate is the fully loaded monthly cost per free monthly active user (MAU). Also report marginal cost per free MAU so the scale curve is visible; marginal cost does not replace the gate.

A free MAU is a distinct authenticated account whose current account tier is free and that starts at least one editor session during the UTC calendar month. The event endpoint derives a month-scoped HMAC of the Clerk user ID on the server. Raw user IDs, emails, project IDs, source files, paths, and error text are not emitted.

## Calculation

Fully loaded free-tier cost per MAU:

(direct free-cohort variable spend
+ allocated shared product operating cost
+ WebContainer API license and usage fees)
/ distinct free MAU

Report the marginal variable-cost-per-MAU separately. Exclude internal labor from product COGS, but show it separately if useful. Include Vercel, Supabase, Cloudflare, AI provider usage, storage, network egress, email, monitoring, Stripe fees caused by free-tier activity, and every WebContainer charge. Use actual invoices or provider usage exports for the measurement period.

Allocate shared costs using a documented usage driver. If no defensible driver exists, report a conservative upper bound that assigns the full shared product cost to the free cohort; do not silently omit it. Split one-time setup fees from recurring run-rate.

## Pilot protocol

- Run one 30-day UTC measurement window in an isolated, invite-only pilot environment.
- Aim for at least 30 distinct free MAU and 100 valid editor-session events. Below either minimum, label results preliminary; do not claim the target is verified.
- Include low, typical, and heavy usage patterns within the published free-tier limits. Record those limits with the result.
- Require complete cost inputs for every provider in scope and reconcile each reported amount to an invoice, usage export, or signed license quote.
- Report the mean fully loaded cost per free MAU, marginal cost per free MAU, p50/p90 editor-session duration, npm install success rate, and percentage of sessions with accepted telemetry.
- The gate passes only if the complete fully loaded average is below USD 0.020, no provider or license cost is missing, and the telemetry capture rate is at least 95%. Report tail usage and any outliers; the average does not replace limits against abuse or exceptional consumption.

This pilot is an initial operating signal, not proof that costs remain below target at scale. Repeat at a materially larger cohort or validate provider pricing at the intended scale before widening the free tier.

## Instrumentation

The implementation in this branch emits one server log event per editor initialization when both pilot flags are enabled. It records elapsed time, npm install duration and exit code, outcome, app revision, UTC month, and a month-scoped HMAC actor key. The browser submits no code, file path, project identifier, email, or raw user ID. Only accounts currently marked free are logged. Event IDs support duplicate removal in analysis.

The event sink is Vercel runtime logs. The event route is disabled unless both COST_PILOT_ENABLED and NEXT_PUBLIC_COST_PILOT_ENABLED are set to true. The code change does not enable either flag or deploy anything. The client currently sends a single fire-and-forget request with no retry and no independent attempted-session denominator. Accepted server log events alone cannot establish the 95% telemetry capture threshold. Before the cohort starts, select and verify an independent denominator source, inject a dropped-request case, and demonstrate that the capture-rate calculation detects it. Export only the minimum fields for analysis and delete the exported row-level pilot data after 30 days, subject to the provider's available retention controls.

This instrumentation does not estimate provider bills. Reconcile actual Vercel, Supabase, Cloudflare, AI, storage, egress, email, monitoring, Stripe, and WebContainer costs from provider-side billing evidence. Include the telemetry route and log retention in the measured cost.

## License gate

StackBlitz's current Commercial Usage page says a license is required for production use of the WebContainer API in a commercial, for-profit setting; prototypes and POCs are exempt. Its FAQ also mentions a nominal charge beyond 10,000 API requests per month, but does not provide a public price or make that threshold an explicit license exemption. Treat the commercial page as controlling until StackBlitz confirms otherwise in writing.

Production and customer-facing pilot use remains blocked until written terms answer: the license fee and billing unit; definition of an API request and the 10,000/month threshold; whether this use case and free-tier users are covered; trial/POC boundaries; rate limits; key issuance and rotation; and support/SLA. Configure the licensed API key before WebContainer.boot in production. Do not treat browser-side execution as eliminating the vendor license cost.

Official references:

- https://webcontainers.io/enterprise
- https://developer.stackblitz.com/guides/user-guide/general-faqs
- https://developer.stackblitz.com/platform/api/webcontainer-api

## Current evidence and blockers

- No cost result is recorded. The pilot has not run.
- The connected Vercel account returned no teams, so the project, deployment environment, and runtime logs cannot be inspected or configured here.
- The Supabase project is now ACTIVE_HEALTHY. Live inspection verified migration `20260513032045` (`create_tables`) and the `public.users`, `public.projects`, and `public.files` tables; each currently reports 0 rows.
- The Supabase security advisor reports RLS disabled on all three public tables at ERROR / EXTERNAL severity. The enabled key inventory includes both a legacy `anon` key and a modern publishable key; key values are not recorded. Do not enable RLS without defining the access policy, because doing so without policies blocks data access. Review the Clerk-authenticated server path and database role before selecting policies.
- Legacy `anon` key rotation remains unverified; the connector exposes key metadata but no key-rotation operation.
- The Cloudflare connector is available, but read-only Workers, Pages, and billing inventory calls fail with API authentication error 10000; no Cloudflare usage or billing evidence was obtained.
- The production migration remains open: key rotation, Vercel repointing, and auth, Stripe webhook, database, and collaboration smoke tests remain unverified.
- The one-shot telemetry path has no independent editor-session attempt denominator; the 95% capture criterion is not currently measurable.
- Netlify returned no matching Peregrine site, and Linear returned no dedicated Peregrine project or gate item. The Sentry API could not be queried because its required auth token, organization, and project settings are absent; no deployment, incident, or monitoring-cost evidence was obtained from those sources.
- No WebContainer API price or signed license terms are publicly available in the sources above. The commercial-use requirement is clear; the price and negotiated scope remain unresolved pending written vendor terms.

## Evidence record

Fill this table from provider receipts after the pilot. Do not estimate missing invoice amounts as zero.

| Provider / cost bucket | Period | Amount (USD) | Evidence URL or receipt | Allocation method | Verified |
|---|---|---:|---|---|---|
| WebContainer license and usage | | | | Direct | |
| Vercel | | | | | |
| Supabase | | | | | |
| Cloudflare | | | | | |
| AI providers | | | | | |
| Storage, egress, email, monitoring | | | | | |
| Other direct product costs | | | | | |

## Release decision

Record the final sample size, event count, capture rate, free MAU, total cost, fully loaded cost per free MAU, marginal cost per free MAU, p50/p90 use, license state, missing evidence, reviewer, and decision. The status stays NOT VERIFIED until the cost and licensing gates pass with receipts.
43 changes: 43 additions & 0 deletions lib/costPilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export type CostPilotSession = {
elapsedMs: number;
npmInstallMs: number | null;
npmInstallExitCode: number | null;
outcome: "success" | "failure";
};

function createEventId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}

/**
* Optional, fire-and-forget pilot telemetry. It sends durations and an outcome
* only; no source code, project identifiers, file paths, or error text.
*/
export function recordCostPilotSession(sample: CostPilotSession): void {
if (
typeof window === "undefined" ||
process.env.NEXT_PUBLIC_COST_PILOT_ENABLED !== "true"
) {
return;
}

const payload = {
eventId: createEventId(),
eventType: "editor_session",
elapsedMs: sample.elapsedMs,
npmInstallMs: sample.npmInstallMs,
npmInstallExitCode: sample.npmInstallExitCode,
outcome: sample.outcome,
};

void fetch("/api/cost-pilot/events", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => undefined);
}
64 changes: 51 additions & 13 deletions lib/webcontainer.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,65 @@
import { WebContainer } from '@webcontainer/api';
import { configureAPIKey, WebContainer } from "@webcontainer/api";
import { recordCostPilotSession } from "./costPilot";

let containerPromise: Promise<WebContainer> | null = null;
let apiKeyConfigured = false;

function configureCommercialLicense(): void {
if (apiKeyConfigured) return;

const apiKey = process.env.NEXT_PUBLIC_WEB_CONTAINER_API_KEY;
if (apiKey) {
configureAPIKey(apiKey);
} else if (process.env.NODE_ENV === "production") {
throw new Error(
"A licensed WebContainer API key is required for production. Configure NEXT_PUBLIC_WEB_CONTAINER_API_KEY."
);
}

apiKeyConfigured = true;
}

export async function getWebContainer(): Promise<WebContainer> {
if (!containerPromise) {
configureCommercialLicense();
containerPromise = WebContainer.boot({
coep: 'credentialless',
workdirName: 'project',
coep: "credentialless",
workdirName: "project",
});
}
return containerPromise;
}

export async function bootProject(files: Record<string, string>): Promise<WebContainer> {
const container = await getWebContainer();
await container.mount(
Object.fromEntries(
Object.entries(files).map(([path, content]) => [path, { file: { contents: content } }])
)
);
if (files['package.json']) {
const process = await container.spawn('npm', ['install']);
await process.exit;
const startedAt = performance.now();
let npmInstallMs: number | null = null;
let npmInstallExitCode: number | null = null;
let outcome: "success" | "failure" = "failure";

try {
const container = await getWebContainer();
await container.mount(
Object.fromEntries(
Object.entries(files).map(([path, content]) => [path, { file: { contents: content } }])
)
);
if (files["package.json"]) {
const installStartedAt = performance.now();
try {
const process = await container.spawn("npm", ["install"]);
npmInstallExitCode = await process.exit;
} finally {
npmInstallMs = Math.round(performance.now() - installStartedAt);
}
}
outcome = npmInstallExitCode === null || npmInstallExitCode === 0 ? "success" : "failure";
return container;
} finally {
recordCostPilotSession({
elapsedMs: Math.round(performance.now() - startedAt),
npmInstallMs,
npmInstallExitCode,
outcome,
});
}
return container;
}
Loading