Skip to content
Merged
176 changes: 176 additions & 0 deletions apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* /api/app-hosting/apps/[appId]/dedicated — buy or cancel the flat monthly
* always-on SKU for one published app.
*
* POST starts a dedicated subscription and returns a PaymentElement client
* secret. The app does NOT become dedicated here — entitlement begins when
* Stripe reports the subscription active, via the webhook.
* DELETE cancels at period end. The app stays dedicated until the period Stripe
* has already been paid for actually ends, and the webhook moves the tier.
*
* ONLY THE DRIVE OWNER MAY BUY. Hosting is billed to the drive owner
* (`resolveEnvPayerId` semantics — see `app-billing.ts`), so anybody else buying
* would be committing a recurring charge to somebody else's card. That makes
* ownership the authorization question here rather than the drive's usual
* edit-permission question, and it is checked against `drives.ownerId` directly
* rather than through a role: a role can be granted, and "may spend this person's
* money" is not something a role should be able to grant.
*
* Dark behind `APP_HOSTING_ENABLED`, and inert where `isBillingEnabled()` is false
* (tenant, onprem) — both checked inside `isDedicatedTierPurchasable()`, before any
* Stripe call. A disabled deployment answers 404, not 403: the feature does not
* exist there, and saying "forbidden" would advertise one that does.
*/

import { NextRequest, NextResponse } from 'next/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { db } from '@pagespace/db/db';
import { eq } from '@pagespace/db/operators';
import { users } from '@pagespace/db/schema/auth';
import { auditRequest } from '@pagespace/lib/audit/audit-log';
import { loggers } from '@pagespace/lib/logging/logger-config';
import { lookupDriveOwnerId } from '@pagespace/lib/billing/sandbox-payer';
import { getPublishedApp } from '@pagespace/lib/services/app-hosting/provisioner';
import { isDedicatedTierPurchasable } from '@pagespace/lib/services/app-hosting/dedicated-tier-service';
import {
cancelDedicatedSubscription,
startDedicatedSubscription,
} from '@/lib/app-hosting/dedicated-subscription';

const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true };

/** Refusals that are the caller's fault, mapped to the status that says so. */
const REFUSAL_STATUS: Record<string, number> = {
guest_preset_not_allowed: 400,
already_subscribed: 409,
not_subscribed: 404,
// Everything price-shaped is a DEPLOYMENT misconfiguration, not a bad request:
// the customer asked for a legitimate size and this deployment cannot sell it.
// 503 rather than 500 because it is a configuration state that will change
// without a code fix, and rather than 400 because there is nothing the caller
// could have sent that would have worked.
price_not_configured: 503,
price_not_found: 503,
price_not_monthly_usd: 503,
price_below_floor: 503,
unknown_preset: 400,
};

/**
* Resolve the app and confirm the caller owns the drive that pays for it.
*
* Returns the app plus the OWNER's user row — not the caller's — because the
* subscription is created against the owner's Stripe customer, and they are the
* same person by the time this returns. Reading it explicitly keeps that fact in
* the code rather than in a reader's head.
*/
async function authorize(request: NextRequest, appId: string) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) return { error: auth.error } as const;

// The kill switch first, before any read — while hosting is dark this endpoint
// must be inert rather than merely fruitless.
if (!isDedicatedTierPurchasable()) {
return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const;
}

const app = await getPublishedApp(appId);
if (!app) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const;

const ownerId = await lookupDriveOwnerId(app.driveId);
if (!ownerId || ownerId !== auth.userId) {
// 404, not 403: a non-owner must not be able to confirm that an app id exists
// by the shape of the refusal.
return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const;
}

const [owner] = await db.select().from(users).where(eq(users.id, ownerId)).limit(1);
if (!owner) {
return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const;
}

return { app, owner, userId: auth.userId } as const;
}

export async function POST(request: NextRequest, context: { params: Promise<{ appId: string }> }) {
const { appId } = await context.params;
const authorized = await authorize(request, appId);
if ('error' in authorized) return authorized.error;
const { app, owner, userId } = authorized;

// The size being bought is the size the app ALREADY RUNS, read from the row
// rather than taken from the request body. A body-supplied preset would let a
// caller buy the price of a small guest for an app running a large one — the
// two columns that must agree (`published_apps.guestPreset` and the
// subscription's `guestPreset`) would be set from different sources, which is
// precisely the drift the mirror table's docblock warns about. Resizing is a
// separate action, and it happens before the purchase.
try {
const result = await startDedicatedSubscription({
publishedAppId: app.id,
user: owner,
guestPreset: app.guestPreset,
});

if (!result.ok) {
const status = REFUSAL_STATUS[result.reason] ?? 400;
return NextResponse.json({ error: result.reason }, { status });
}

auditRequest(request, {
eventType: 'data.write',
userId,
resourceType: 'published_app_subscription',
resourceId: result.stripeSubscriptionId,
details: { action: 'create', publishedAppId: app.id, guestPreset: app.guestPreset },
});

return NextResponse.json({
subscriptionId: result.stripeSubscriptionId,
clientSecret: result.clientSecret,
status: result.status,
});
} catch (error) {
loggers.api.error(
'Dedicated hosting subscription could not be started',
error instanceof Error ? error : undefined,
{ publishedAppId: app.id },
);
return NextResponse.json({ error: 'Failed to start dedicated hosting' }, { status: 500 });
}
}

export async function DELETE(request: NextRequest, context: { params: Promise<{ appId: string }> }) {
const { appId } = await context.params;
const authorized = await authorize(request, appId);
if ('error' in authorized) return authorized.error;
const { app, userId } = authorized;

try {
const result = await cancelDedicatedSubscription(app.id);
if (!result.ok) {
const status = REFUSAL_STATUS[result.reason] ?? 400;
return NextResponse.json({ error: result.reason }, { status });
}

auditRequest(request, {
eventType: 'data.write',
userId,
resourceType: 'published_app_subscription',
resourceId: app.id,
details: { action: 'cancel_at_period_end', publishedAppId: app.id },
});

return NextResponse.json({
cancelAtPeriodEnd: result.cancelAtPeriodEnd,
currentPeriodEnd: result.currentPeriodEnd.toISOString(),
});
} catch (error) {
loggers.api.error(
'Dedicated hosting subscription could not be cancelled',
error instanceof Error ? error : undefined,
{ publishedAppId: app.id },
);
return NextResponse.json({ error: 'Failed to cancel dedicated hosting' }, { status: 500 });
}
}
61 changes: 60 additions & 1 deletion apps/web/src/app/api/cron/meter-published-apps/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import {
defaultAwakeMeterDeps,
meterAwakePublishedAppsSerialized,
} from '@pagespace/lib/services/app-hosting/awake-meter';
import {
DEDICATED_DUNNING_VISIBILITY_DAYS,
surveyDedicatedDunning,
} from '@pagespace/lib/services/app-hosting/dedicated-tier-service';
import * as Sentry from '@sentry/nextjs';
import { audit } from '@pagespace/lib/audit/audit-log';
import { loggers } from '@pagespace/lib/logging/logger-config';
Expand Down Expand Up @@ -32,6 +36,16 @@ import { validateSignedCronRequest } from '@/lib/auth/cron-auth';
* self-correcting: the window stays open and the next tick bills it in full, so
* they are counted and audited rather than alerted on.
*
* IT ALSO COUNTS THE OTHER TIER'S ONE INVISIBLE COST. A dedicated app keeps
* serving while its subscription is `past_due`, because taking a customer's
* production app offline over a card that will retry successfully is an outage
* they did not cause. That trade is bounded by Stripe's dunning ending the
* subscription — a STRIPE ACCOUNT SETTING, not code — so an account configured to
* leave failures `past_due` forever would serve an always-on machine free forever
* with nothing in this repo able to notice. `surveyDedicatedDunning` is what
* notices. It never fails the tick: it is a visibility signal about a decision we
* made deliberately, not money going wrong, so it warns and counts.
*
* As with the storage reconcile, the Sentry capture is what actually reaches a
* human — the docker cron invokes this through `curl -sS` without `-f`, so an
* HTTP 500 exits 0 and its body just lands in a log. The status code stays the
Expand All @@ -54,6 +68,41 @@ export async function GET(request: Request) {
try {
const run = await meterAwakePublishedAppsSerialized(defaultAwakeMeterDeps);

// Independent of the meter's outcome, and deliberately never allowed to break
// it: this reports on the DEDICATED tier, which the meter above does not touch
// at all. A failure to count is not a reason to fail a tick that billed
// correctly.
const dunning = await surveyDedicatedDunning().catch((error) => {
loggers.system.error('[Cron] Dedicated-tier dunning survey failed', error as Error);
return null;
});

if (dunning && dunning.pastDueStale > 0) {
console.log(
`[Cron] Dedicated hosting: ${dunning.pastDueStale} of ${dunning.pastDue} past_due app(s) overdue more than ${DEDICATED_DUNNING_VISIBILITY_DAYS} days — apps: ${dunning.staleAppIds.join(', ')}`,
);
Sentry.captureMessage(
`Dedicated hosting: ${dunning.pastDueStale} always-on app(s) served on an unpaid subscription for more than ${DEDICATED_DUNNING_VISIBILITY_DAYS} days`,
{
// WARNING, not error. Nothing is broken and nothing is being lost to a
// bug — this is the cost of a deliberate product choice becoming
// unbounded, which is an operator decision (chase the customer, or fix
// the Stripe dunning settings), not an incident.
level: 'warning',
// Fingerprinted on the cause so a persistent situation stays ONE issue
// rather than opening a fresh one on every tick as the count moves.
fingerprint: ['dedicated-hosting-dunning-stale'],
tags: { check: 'published_app_dedicated_dunning' },
extra: {
pastDue: dunning.pastDue,
pastDueStale: dunning.pastDueStale,
staleAppIds: dunning.staleAppIds,
thresholdDays: DEDICATED_DUNNING_VISIBILITY_DAYS,
},
},
);
}

if (run.outcome === 'lock_busy') {
console.log('[Cron] Published-app awake meter: skipped — advisory lock held by another run');
return NextResponse.json({ success: true, outcome: 'lock_busy', timestamp: new Date().toISOString() });
Expand Down Expand Up @@ -101,6 +150,10 @@ export async function GET(request: Request) {
settledButUnadvanced: run.settledButUnadvanced,
totalAwakeSeconds: run.totalAwakeSeconds,
sourceFailed: run.sourceFailed,
// The dedicated tier's own figures. Zero on every deployment that has not
// sold one, which is all of them while the feature is dark.
dedicatedPastDue: dunning?.pastDue ?? 0,
dedicatedPastDueStale: dunning?.pastDueStale ?? 0,
},
});

Expand Down Expand Up @@ -152,7 +205,13 @@ export async function GET(request: Request) {
);
}

return NextResponse.json({ success: true, ...run, timestamp: new Date().toISOString() });
return NextResponse.json({
success: true,
...run,
dedicatedPastDue: dunning?.pastDue ?? 0,
dedicatedPastDueStale: dunning?.pastDueStale ?? 0,
timestamp: new Date().toISOString(),
});
} catch (error) {
loggers.system.error('[Cron] Error metering published-app awake seconds', error as Error);
return NextResponse.json(
Expand Down
Loading
Loading