@carrierllc/mcp 0.2.17 → 0.2.19
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/README.md +21 -1
- package/dist/cli.js +893 -80
- package/dist/cli.js.map +1 -1
- package/dist/index.js +213 -36
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/plugin/.claude-plugin/marketplace.json +2 -2
- package/plugin/carrier/.claude-plugin/plugin.json +1 -1
- package/plugin/carrier/README.md +31 -4
- package/plugin/carrier/agents/carrier-billing-auditor.md +1 -1
- package/plugin/carrier/commands/billing.md +8 -1
- package/plugin/carrier/commands/provision.md +18 -8
- package/plugin/carrier/commands/wallet.md +31 -0
- package/plugin/carrier/skills/carrier-operations/SKILL.md +12 -5
- package/templates/storefront/package-lock.json +12722 -0
- package/templates/storefront/package.json +3 -3
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +125 -0
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +16 -12
- package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +71 -13
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +59 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +199 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +18 -3
- package/templates/storefront/src/lib/checkout-order-claim.ts +141 -0
- package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +70 -0
- package/templates/storefront/src/middleware.ts +6 -0
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
export const dynamic = "force-dynamic";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* /sign-up — Custom email + password sign-up flow for the storefront.
|
|
4
|
+
*
|
|
5
|
+
* MANGO-6/7: Phone number is never required here. It is collected optionally
|
|
6
|
+
* on /activate/[orderId] after purchase.
|
|
7
|
+
*/
|
|
8
|
+
import { Suspense } from "react";
|
|
9
|
+
import { StorefrontSignUpClient } from "./StorefrontSignUpClient";
|
|
4
10
|
|
|
5
11
|
export const metadata = { title: "Sign up" };
|
|
6
12
|
|
|
7
13
|
export default function SignUpPage() {
|
|
8
14
|
return (
|
|
9
15
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
|
|
10
|
-
<
|
|
16
|
+
<Suspense
|
|
17
|
+
fallback={
|
|
18
|
+
<div className="text-center space-y-3">
|
|
19
|
+
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
|
|
20
|
+
<p className="text-sm text-white/60">Loading…</p>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
>
|
|
24
|
+
<StorefrontSignUpClient />
|
|
25
|
+
</Suspense>
|
|
11
26
|
</div>
|
|
12
27
|
);
|
|
13
28
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { clerkClient } from "@clerk/nextjs/server";
|
|
2
|
+
import { verifyCheckoutSession } from "@/lib/verify-checkout-session";
|
|
3
|
+
|
|
4
|
+
type StripeSession = {
|
|
5
|
+
metadata?: Record<string, string>;
|
|
6
|
+
customer_email?: string | null;
|
|
7
|
+
customer_details?: { email?: string | null };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** See verify-checkout-session.ts — same charset gate for path interpolation. */
|
|
11
|
+
const STRIPE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,256}$/;
|
|
12
|
+
|
|
13
|
+
function safeStripeSessionId(sessionId: string): string | null {
|
|
14
|
+
if (typeof sessionId !== "string" || !STRIPE_SESSION_ID_RE.test(sessionId)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return encodeURIComponent(sessionId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function fetchStripeSession(sessionId: string): Promise<StripeSession | null> {
|
|
21
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
22
|
+
if (!stripeKey) return null;
|
|
23
|
+
const safeSessionId = safeStripeSessionId(sessionId);
|
|
24
|
+
if (!safeSessionId) return null;
|
|
25
|
+
|
|
26
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, {
|
|
27
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
28
|
+
});
|
|
29
|
+
if (!resp.ok) return null;
|
|
30
|
+
return (await resp.json()) as StripeSession;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function setStripeClaimedBy(sessionId: string, userId: string): Promise<boolean> {
|
|
34
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
35
|
+
if (!stripeKey) return false;
|
|
36
|
+
const safeSessionId = safeStripeSessionId(sessionId);
|
|
37
|
+
if (!safeSessionId) return false;
|
|
38
|
+
|
|
39
|
+
const formBody = new URLSearchParams({
|
|
40
|
+
"metadata[claimedByUserId]": userId,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${stripeKey}`,
|
|
47
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
48
|
+
},
|
|
49
|
+
body: formBody.toString(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return resp.ok;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function claimedOrderIds(metadata: Record<string, unknown> | undefined): string[] {
|
|
56
|
+
const value = metadata?.claimedOrderIds;
|
|
57
|
+
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sessionPurchaserEmail(session: StripeSession): string | null {
|
|
61
|
+
return session.customer_details?.email ?? session.customer_email ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function userMatchesSessionPurchaser(session: StripeSession, userId: string): Promise<boolean> {
|
|
65
|
+
const purchaserEmail = sessionPurchaserEmail(session);
|
|
66
|
+
if (!purchaserEmail) return true;
|
|
67
|
+
|
|
68
|
+
const client = await clerkClient();
|
|
69
|
+
const user = await client.users.getUser(userId);
|
|
70
|
+
const normalizedPurchaserEmail = purchaserEmail.toLowerCase();
|
|
71
|
+
return user.emailAddresses.some(
|
|
72
|
+
(address) => address.emailAddress.toLowerCase() === normalizedPurchaserEmail,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function addClaimedOrderToUser(userId: string, orderId: string): Promise<void> {
|
|
77
|
+
const client = await clerkClient();
|
|
78
|
+
const user = await client.users.getUser(userId);
|
|
79
|
+
const existing = claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined);
|
|
80
|
+
if (existing.includes(orderId)) return;
|
|
81
|
+
|
|
82
|
+
await client.users.updateUserMetadata(userId, {
|
|
83
|
+
privateMetadata: { claimedOrderIds: [...existing, orderId] },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function claimCheckoutOrder(
|
|
88
|
+
userId: string,
|
|
89
|
+
sessionId: string,
|
|
90
|
+
templateId: string,
|
|
91
|
+
): Promise<{ ok: true; orderId: string } | { ok: false; status: number; error: string }> {
|
|
92
|
+
const verification = await verifyCheckoutSession(sessionId, templateId);
|
|
93
|
+
if (!verification.ok) {
|
|
94
|
+
return { ok: false, status: 400, error: "Invalid or unpaid checkout session" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const orderId = verification.orderId;
|
|
98
|
+
|
|
99
|
+
if (sessionId !== "mock" && process.env.STRIPE_SECRET_KEY) {
|
|
100
|
+
const session = await fetchStripeSession(sessionId);
|
|
101
|
+
if (!session) {
|
|
102
|
+
return { ok: false, status: 400, error: "Invalid or unpaid checkout session" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!(await userMatchesSessionPurchaser(session, userId))) {
|
|
106
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const claimedBy = session.metadata?.claimedByUserId;
|
|
110
|
+
if (claimedBy && claimedBy !== userId) {
|
|
111
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
112
|
+
}
|
|
113
|
+
if (!claimedBy) {
|
|
114
|
+
const updated = await setStripeClaimedBy(sessionId, userId);
|
|
115
|
+
if (!updated) {
|
|
116
|
+
return { ok: false, status: 502, error: "Failed to claim order" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const afterClaim = await fetchStripeSession(sessionId);
|
|
120
|
+
if (afterClaim?.metadata?.claimedByUserId !== userId) {
|
|
121
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await addClaimedOrderToUser(userId, orderId);
|
|
127
|
+
return { ok: true, orderId };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function userOwnsOrder(userId: string, orderId: string): Promise<boolean> {
|
|
131
|
+
if (process.env.STRIPE_SECRET_KEY && orderId.startsWith("cs_")) {
|
|
132
|
+
const session = await fetchStripeSession(orderId);
|
|
133
|
+
return session?.metadata?.claimedByUserId === userId;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const client = await clerkClient();
|
|
137
|
+
const user = await client.users.getUser(userId);
|
|
138
|
+
return claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined).includes(
|
|
139
|
+
orderId,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type SignUpVerificationResult = {
|
|
2
|
+
status: string;
|
|
3
|
+
createdSessionId: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type SignUpForEmailVerify = {
|
|
7
|
+
missingFields?: string[];
|
|
8
|
+
unverifiedFields?: string[];
|
|
9
|
+
update: (params: Record<string, never>) => Promise<SignUpVerificationResult>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type EmailSignUpCompletion =
|
|
13
|
+
| { status: "complete"; sessionId: string }
|
|
14
|
+
| { status: "incomplete"; message: string };
|
|
15
|
+
|
|
16
|
+
export async function completeEmailSignUpAfterVerify(
|
|
17
|
+
signUp: SignUpForEmailVerify,
|
|
18
|
+
result: SignUpVerificationResult,
|
|
19
|
+
): Promise<EmailSignUpCompletion> {
|
|
20
|
+
if (result.status === "complete" && result.createdSessionId) {
|
|
21
|
+
return { status: "complete", sessionId: result.createdSessionId };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (result.status === "missing_requirements") {
|
|
25
|
+
const missingFields = signUp.missingFields ?? [];
|
|
26
|
+
const unverifiedFields = signUp.unverifiedFields ?? [];
|
|
27
|
+
const onlyPhoneMissing =
|
|
28
|
+
missingFields.length === 1 &&
|
|
29
|
+
missingFields[0] === "phone_number" &&
|
|
30
|
+
unverifiedFields.length === 0;
|
|
31
|
+
|
|
32
|
+
if (onlyPhoneMissing) {
|
|
33
|
+
try {
|
|
34
|
+
const updated = await signUp.update({});
|
|
35
|
+
if (updated.status === "complete" && updated.createdSessionId) {
|
|
36
|
+
return { status: "complete", sessionId: updated.createdSessionId };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Fall through to generic incomplete message.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
status: "incomplete",
|
|
45
|
+
message: "One more step required. Please complete your profile to continue.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { status: "incomplete", message: "Verification incomplete. Please try again." };
|
|
50
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
type StripeCheckoutSession = {
|
|
2
|
+
payment_status?: string;
|
|
3
|
+
customer_email?: string | null;
|
|
4
|
+
customer_details?: { email?: string | null };
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function sessionPurchaserEmail(session: StripeCheckoutSession): string | null {
|
|
8
|
+
return session.customer_details?.email ?? session.customer_email ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type VerifyResult =
|
|
12
|
+
| { ok: true; orderId: string; purchaserEmail: string | null }
|
|
13
|
+
| { ok: false };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stripe Checkout session ids are opaque tokens (`cs_test_…` / `cs_live_…`).
|
|
17
|
+
* They are interpolated into the Stripe API URL, so an unvalidated value can
|
|
18
|
+
* inject `?`/`#`/`../` and retarget the request *with the Stripe secret key
|
|
19
|
+
* attached* (CodeQL js/request-forgery). Restrict to an explicit charset and
|
|
20
|
+
* encode before interpolation.
|
|
21
|
+
*/
|
|
22
|
+
const STRIPE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,256}$/;
|
|
23
|
+
|
|
24
|
+
function safeStripeSessionId(sessionId: string): string | null {
|
|
25
|
+
if (typeof sessionId !== "string" || !STRIPE_SESSION_ID_RE.test(sessionId)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return encodeURIComponent(sessionId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Confirms a Stripe Checkout session is paid before allowing post-payment flows.
|
|
33
|
+
* Mock sessions are only accepted when Stripe is not configured (local/dev builds).
|
|
34
|
+
*/
|
|
35
|
+
export async function verifyCheckoutSession(
|
|
36
|
+
sessionId: string,
|
|
37
|
+
templateId: string,
|
|
38
|
+
): Promise<VerifyResult> {
|
|
39
|
+
if (sessionId === "mock") {
|
|
40
|
+
if (process.env.STRIPE_SECRET_KEY || !templateId) {
|
|
41
|
+
return { ok: false };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, orderId: templateId, purchaserEmail: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const safeSessionId = safeStripeSessionId(sessionId);
|
|
47
|
+
if (!safeSessionId) {
|
|
48
|
+
return { ok: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
52
|
+
if (!stripeKey) {
|
|
53
|
+
return { ok: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, {
|
|
57
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!resp.ok) {
|
|
61
|
+
return { ok: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const session = (await resp.json()) as StripeCheckoutSession;
|
|
65
|
+
if (session.payment_status !== "paid") {
|
|
66
|
+
return { ok: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { ok: true, orderId: sessionId, purchaserEmail: sessionPurchaserEmail(session) };
|
|
70
|
+
}
|
|
@@ -9,6 +9,12 @@ const isPublicRoute = createRouteMatcher([
|
|
|
9
9
|
"/sign-in(.*)",
|
|
10
10
|
"/sign-up(.*)",
|
|
11
11
|
"/api/health",
|
|
12
|
+
// MANGO-8/9: guest checkout API and post-payment success page are public
|
|
13
|
+
// (user has not yet created an account at this point in the purchase flow)
|
|
14
|
+
"/api/checkout/guest",
|
|
15
|
+
"/checkout/success(.*)",
|
|
16
|
+
// The checkout page itself is public — unauthenticated users can initiate payment
|
|
17
|
+
"/checkout/(.*)",
|
|
12
18
|
]);
|
|
13
19
|
|
|
14
20
|
export default clerkMiddleware(async (auth, req) => {
|