Skip to content
Merged
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
18 changes: 9 additions & 9 deletions components/dashboard/account/SubscriptionSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { ArrowRight, Check, Lock } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
import { cancelStripeSubscription, createStripeCheckout, verifyApplePurchase } from "@src/lib/utils/requests";
import { cancelStripeSubscription, createStripeCheckout, submitApplePurchase } from "@src/lib/utils/requests";
import { useUser } from "@src/lib/utils/hooks";

import styles from "./SubscriptionSettings.module.css";
Expand All @@ -27,12 +27,13 @@ const SubscriptionSettings = () => {
const isApple = user?.subscriptionProvider === "APPLE";
const expiryDate = user?.isProUntil ? new Date(user.isProUntil).toLocaleDateString() : "";

// Restore Apple purchases on mount to sync subscription state with the server
// Restore Apple purchases on mount to sync subscription state with the server.
useEffect(() => {
if (!isTauri() || !user?.id) return;

let cancelled = false;
(async () => {

async function syncAppleSubscription() {
try {
const { restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api");
const { purchases } = await restorePurchases("subs");
Expand All @@ -41,18 +42,17 @@ const SubscriptionSettings = () => {
&& p.purchaseState === PurchaseState.PURCHASED
&& p.jwsRepresentation,
);

if (cancelled) return;

if (active?.jwsRepresentation) {
await verifyApplePurchase(active.jwsRepresentation);
await submitApplePurchase(active.jwsRepresentation);
await mutate();
}
} catch {
// Restore can fail if not signed into App Store — silently ignore
// Restore can fail if the user is not signed into the App Store — silently ignore.
}
})();
}

syncAppleSubscription();
return () => { cancelled = true; };
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps

Expand All @@ -72,7 +72,7 @@ const SubscriptionSettings = () => {
return;
}

const ok = await verifyApplePurchase(result.jwsRepresentation);
const ok = await submitApplePurchase(result.jwsRepresentation);
if (ok) {
await mutate();
} else {
Expand Down
99 changes: 99 additions & 0 deletions src/app/api/apple/purchase/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { NextRequest } from "next/server";
import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler";
import { BodyFieldError, ForbiddenError, Success } from "@src/lib/utils/api-utils";
import { verifyAppleJws, APPLE_BUNDLE_IDS, APPLE_PRODUCT_ID } from "@src/lib/apple-jws";
import { logger } from "@src/lib/utils/logger";
import * as UserService from "@src/server/service/user-service";
import * as TransactionService from "@src/server/service/transaction-service";

interface AppleTransactionPayload {
transactionId: string;
originalTransactionId: string;
bundleId: string;
productId: string;
purchaseDate: number;
expiresDate: number;
type: string;
appAccountToken?: string;
}

async function handleApplePurchase(req: NextRequest, { user }: AuthApiContext) {
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const jwsTransaction = body.jwsTransaction;
if (typeof jwsTransaction !== "string") {
throw new BodyFieldError("Missing jwsTransaction");
}

logger.debug("[Apple purchase] Verifying JWS", { userId: user.id });

const payload = await verifyAppleJws<AppleTransactionPayload>(jwsTransaction);

logger.debug("[Apple purchase] JWS verified", {
userId: user.id,
originalTransactionId: payload.originalTransactionId,
bundleId: payload.bundleId,
productId: payload.productId,
});

if (!APPLE_BUNDLE_IDS.includes(payload.bundleId)) {
logger.warn("[Apple purchase] Invalid bundle ID", { bundleId: payload.bundleId, userId: user.id });
throw new ForbiddenError("Invalid bundle ID");
}
if (payload.productId !== APPLE_PRODUCT_ID) {
logger.warn("[Apple purchase] Invalid product ID", { productId: payload.productId, userId: user.id });
throw new ForbiddenError("Invalid product ID");
}

// appAccountToken is the UUID embedded at purchase time (StoreKit's
// Product.purchase(options: .init(appAccountToken:))). If present it must
// match the caller so a receipt from one account can't be redeemed on another.
if (payload.appAccountToken && payload.appAccountToken !== user.id) {
logger.warn("[Apple purchase] appAccountToken mismatch", {
appAccountToken: payload.appAccountToken,
userId: user.id,
});
throw new ForbiddenError("Transaction does not belong to this account");
}

const expiresDate = new Date(payload.expiresDate);
if (expiresDate <= new Date()) {
logger.warn("[Apple purchase] Transaction already expired", {
originalTransactionId: payload.originalTransactionId,
expiresDate,
userId: user.id,
});
throw new ForbiddenError("Transaction already expired");
}

// Reject if this subscription is already claimed by a different user.
// Uses originalTransactionId so every renewal of the same subscription
// is anchored to the same record. This check MUST precede updateUserFromId
// so we never grant Pro before verifying ownership.
const existing = await TransactionService.findUserByTransactionId(payload.originalTransactionId);
if (existing && existing.userId !== user.id) {
logger.warn("[Apple purchase] Subscription already claimed by another user", {
originalTransactionId: payload.originalTransactionId,
claimingUserId: user.id,
existingUserId: existing.userId,
});
throw new ForbiddenError("Subscription is already linked to a different account");
}

await TransactionService.createTransactionIfNotExists(user.id, "APPLE", payload.originalTransactionId);

await UserService.updateUserFromId(user.id, {
isProUntil: expiresDate,
subscriptionProvider: "APPLE",
isSubscriptionCancelled: false,
});

logger.debug("[Apple purchase] Pro granted", {
userId: user.id,
originalTransactionId: payload.originalTransactionId,
expiresDate,
});

return Success(null);
}

export const POST = apiHandler(handleApplePurchase);
54 changes: 0 additions & 54 deletions src/app/api/apple/verify/route.ts

This file was deleted.

6 changes: 6 additions & 0 deletions src/app/api/desktop/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { apiHandler } from "@src/lib/utils/api-handler";
import { ForbiddenError, Success, validate } from "@src/lib/utils/api-utils";
import { putBridgeToken } from "@src/lib/desktop-bridge";
import { encodeDesktopBearer } from "@src/lib/auth-tokens";
import { logger } from "@src/lib/utils/logger";

const BodySchema = z.object({
nonce: z.string().min(16),
Expand All @@ -23,11 +24,14 @@ async function desktopTokenRoute(req: NextRequest) {
const body = await req.json();
const { nonce } = validate(BodySchema, body);

logger.debug("[Desktop token] Minting token for nonce", { nonce: nonce.slice(0, 8) + "…" });

const session = await auth();
const user = session?.user as
| { id?: string; email?: string; createdAt?: Date | string; role?: string }
| undefined;
if (!user?.id) {
logger.warn("[Desktop token] No active session for token request");
throw new ForbiddenError("Not authenticated");
}

Expand All @@ -42,6 +46,8 @@ async function desktopTokenRoute(req: NextRequest) {
});

putBridgeToken(nonce, token);
logger.debug("[Desktop token] Token stowed in bridge", { userId: user.id });

return Success(null);
}

Expand Down
60 changes: 46 additions & 14 deletions src/app/api/webhooks/apple/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { decodeJwt } from "jose";
import { verifyAppleJws } from "@src/lib/apple-jws";
import { logger } from "@src/lib/utils/logger";
import * as UserService from "@src/server/service/user-service";
import * as TransactionService from "@src/server/service/transaction-service";

Expand All @@ -14,6 +15,7 @@ interface AppleNotificationPayload {

interface AppleTransactionInfo {
transactionId: string;
originalTransactionId: string;
bundleId: string;
productId: string;
purchaseDate: number;
Expand All @@ -27,12 +29,23 @@ interface AppleRenewalInfo {
}

async function findUser(transaction: AppleTransactionInfo): Promise<{ id: string } | null> {
// appAccountToken is the user ID set at purchase time via StoreKit's appAccountToken option.
if (transaction.appAccountToken) {
const user = await UserService.getUserFromId(transaction.appAccountToken);
if (user) return { id: user.id };
if (user) {
logger.debug("[Apple webhook] Resolved user via appAccountToken", { userId: user.id });
return { id: user.id };
}
}
// Fallback: look up via originalTransactionId, which is the stable anchor stored
// in the Transaction table during the initial /api/apple/purchase call.
const tx = await TransactionService.findUserByTransactionId(transaction.originalTransactionId);
if (tx) {
logger.debug("[Apple webhook] Resolved user via originalTransactionId", {
userId: tx.userId,
originalTransactionId: transaction.originalTransactionId,
});
}
// Fallback: look up via the transaction stored during initial purchase verification
const tx = await TransactionService.findUserByTransactionId(transaction.transactionId);
return tx ? { id: tx.userId } : null;
}

Expand All @@ -42,15 +55,32 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Missing signedPayload" }, { status: 400 });
}

const notification = decodeJwt(body.signedPayload) as unknown as AppleNotificationPayload;
const transaction = decodeJwt(notification.data.signedTransactionInfo) as unknown as AppleTransactionInfo;
const renewal = notification.data.signedRenewalInfo
? (decodeJwt(notification.data.signedRenewalInfo) as unknown as AppleRenewalInfo)
: null;
let notification: AppleNotificationPayload;
let transaction: AppleTransactionInfo;
let renewal: AppleRenewalInfo | null = null;

try {
notification = await verifyAppleJws<AppleNotificationPayload>(body.signedPayload);
transaction = await verifyAppleJws<AppleTransactionInfo>(notification.data.signedTransactionInfo);
if (notification.data.signedRenewalInfo) {
renewal = await verifyAppleJws<AppleRenewalInfo>(notification.data.signedRenewalInfo);
}
} catch (err) {
logger.error("[Apple webhook] JWS verification failed", err);
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}

logger.debug("[Apple webhook] Received notification", {
type: notification.notificationType,
subtype: notification.subtype,
originalTransactionId: transaction.originalTransactionId,
});

const user = await findUser(transaction);
if (!user) {
console.warn("[Apple webhook] No user found for transaction:", transaction.transactionId);
logger.warn("[Apple webhook] No user found for transaction", {
originalTransactionId: transaction.originalTransactionId,
});
return NextResponse.json({ received: true });
}

Expand All @@ -63,7 +93,8 @@ export async function POST(req: NextRequest) {
subscriptionProvider: "APPLE",
isSubscriptionCancelled: false,
});
await TransactionService.createTransactionIfNotExists(user.id, "APPLE", transaction.transactionId);
await TransactionService.createTransactionIfNotExists(user.id, "APPLE", transaction.originalTransactionId);
logger.debug("[Apple webhook] Pro granted", { userId: user.id, expiresDate });
break;
}

Expand All @@ -74,14 +105,14 @@ export async function POST(req: NextRequest) {
subscriptionProvider: null,
isSubscriptionCancelled: false,
});
logger.debug("[Apple webhook] Pro revoked", { userId: user.id, reason: notification.notificationType });
break;
}

case "DID_CHANGE_RENEWAL_STATUS": {
const autoRenewOff = renewal?.autoRenewStatus === 0;
await UserService.updateUserFromId(user.id, {
isSubscriptionCancelled: autoRenewOff,
});
await UserService.updateUserFromId(user.id, { isSubscriptionCancelled: autoRenewOff });
logger.debug("[Apple webhook] Renewal status changed", { userId: user.id, autoRenewOff });
break;
}

Expand All @@ -91,6 +122,7 @@ export async function POST(req: NextRequest) {
subscriptionProvider: null,
isSubscriptionCancelled: false,
});
logger.debug("[Apple webhook] Pro refunded", { userId: user.id });
break;
}
}
Expand Down
Loading
Loading