diff --git a/.github/ISSUE_TEMPLATE/policies-doc.md b/.github/ISSUE_TEMPLATE/policies-doc.md index df994872..dace6984 100644 --- a/.github/ISSUE_TEMPLATE/policies-doc.md +++ b/.github/ISSUE_TEMPLATE/policies-doc.md @@ -1,10 +1,9 @@ --- name: Policies Doc about: File a bug or suggestion for a policy -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- diff --git a/cspell.json b/cspell.json index e0414a4d..6261e569 100644 --- a/cspell.json +++ b/cspell.json @@ -4,17 +4,9 @@ // language - current active spelling language "language": "en", // words - list of words to be always considered correct - "words": [ - "Kubernetes", - "Linkerd", - "Quickstart", - "quickstarts", - "Zuplo" - ], + "words": ["Kubernetes", "Linkerd", "Quickstart", "quickstarts", "Zuplo"], // flagWords - list of words to be always considered incorrect // This is useful for offensive words and common spelling errors. // For example "hte" should be "the" - "flagWords": [ - "hte" - ] + "flagWords": ["hte"] } diff --git a/docs/articles/background-dispatcher.md b/docs/articles/background-dispatcher.md index d9b725ff..489c37f3 100644 --- a/docs/articles/background-dispatcher.md +++ b/docs/articles/background-dispatcher.md @@ -87,7 +87,7 @@ The `options.msDelay` parameter is required and must be a valid non-zero number. ```ts const backgroundDispatcher = new BackgroundDispatcher( dispatchFunction, - { msDelay: 100 } + { msDelay: 100 }, ); ``` diff --git a/docs/articles/hooks.md b/docs/articles/hooks.md index 93e2958e..14ec82e3 100644 --- a/docs/articles/hooks.md +++ b/docs/articles/hooks.md @@ -29,7 +29,7 @@ import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export async function tracingPlugin( request: ZuploRequest, context: ZuploContext, - policyName: string + policyName: string, ) { // Get the trace header let traceparent = request.headers.get("traceparent"); @@ -71,7 +71,7 @@ import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export async function pluginWithHook( request: ZuploRequest, context: ZuploContext, - policyName: string + policyName: string, ) { const cloned = request.clone(); context.addResponseSendingFinalHook( @@ -81,7 +81,7 @@ export async function pluginWithHook( method: "GET", body, }); - } + }, ); return request; diff --git a/docs/articles/monetization-glossary.md b/docs/articles/monetization-glossary.md index bbebc097..008c6c2c 100644 --- a/docs/articles/monetization-glossary.md +++ b/docs/articles/monetization-glossary.md @@ -1,3 +1,4 @@ --- -title: Monetization Terms Glossary +title: Monetization Glossary +sidebar_label: Glossary --- diff --git a/docs/articles/monetization-programmatic-quotas.md b/docs/articles/monetization-programmatic-quotas.md index b9fc734b..dcecf239 100644 --- a/docs/articles/monetization-programmatic-quotas.md +++ b/docs/articles/monetization-programmatic-quotas.md @@ -1,3 +1,91 @@ --- title: Monetization Programmatic Quotas +sidebar_label: Programmatic Quotas --- + +Typically, when adding monetization to your API, you set the number of meters a +request will consume in the settings of the +[Monetization Policy](https://zuplo.com/docs/policies/monetization-inbound). For +example, the policy below specifies that the request will consume 1 `requests` +meter and 5 `computeUnits` meters. + +```json +{ + "export": "MonetizationInboundPolicy", + "module": "$import(@zuplo/runtime)", + "options": { + "allowRequestsOverQuota": false, + "allowedSubscriptionStatuses": ["active", "incomplete"], + "meterOnStatusCodes": "200-399", + "meters": { + "requests": 1, + "computeUnits": 5 + } + } +} +``` + +However, in some cases, you may not know up front how many units of a particular +meter will be consumed until after the response is sent. For example, maybe your +backend is responsible for computing the `computeUnits` on a request and send +the result in the response in the `compute-units` header. + +In Zuplo, you can support these dynamic meters by writing a little code. To make +the `computeUnits` meter dynamic, first update the policy by setting the +`computeUnits` meter to `0` as shown below. + +```json +{ + "export": "MonetizationInboundPolicy", + "module": "$import(@zuplo/runtime)", + "options": { + "allowRequestsOverQuota": false, + "allowedSubscriptionStatuses": ["active", "incomplete"], + "meterOnStatusCodes": "200-399", + "meters": { + "requests": 1, + "computeUnits": 0 + } + } +} +``` + +Next you can create a +[custom code outbound policy](/docs/policies/custom-code-outbound) that reads +data from the Response (in this case the `compute-units` header) and sets the +meter programmatically. + +```ts title="/modules/set-compute-units-outbound.ts" +import { + MonetizationInboundPolicy, + ZuploRequest, + ZuploContext, +} from "@zuplo/runtime"; + +export default async function ( + response: Response, + request: ZuploRequest, + context: ZuploContext, + options: any, + policyName: string, +) { + const headerValue = response.headers.get("compute-units"); + let computeUnits; + if (headerValue && typeof headerValue === "string") { + computeUnits = parseInt(headerValue); + } + + // Throw an error if the server doesn't send compute units + // Alternatively, you could have a default value + if (!computeUnits) { + throw new Error("Invalid response, no compute units sent."); + } + + // Set the compute units for the request + MonetizationInboundPolicy.setMeters(context, { + computeUnits, + }); + + return response; +} +``` diff --git a/docs/articles/stripe-monetization-plugin.md b/docs/articles/stripe-monetization-plugin.md new file mode 100644 index 00000000..7b1c087e --- /dev/null +++ b/docs/articles/stripe-monetization-plugin.md @@ -0,0 +1,54 @@ +--- +title: Stripe Monetization Plugin +sidebar_label: Stripe Plugin +--- + +The Stripe Monetization Plugin makes makes it easy to register a Stripe Webhook +in your Zuplo API that will handle Stripe subscription events. + +When you register the Stripe Plugin a new route is configured on your API at the +path `/__plugins/stripe/webhook`. This route is used to receive webhooks sent by +stripe for Stripe subscription events. + +The Plugin is registered in the `zuplo.runtime.ts` extension. It requires +setting the `webhooks.signingSecret` value and the `stripeSecretKey` in order to +function. + +There is additional configuration if you wan to customize the path, etc, but for +most cases no additional configuration is required. + +```ts +import { + RuntimeExtensions, + StripeMonetizationPlugin, + environment, +} from "@zuplo/runtime"; + +export function runtimeInit(runtime: RuntimeExtensions) { + // Create the Stripe Plugin + const stripe = new StripeMonetizationPlugin({ + webhooks: { + signingSecret: environment.STRIPE_WEBHOOK_SIGNING_SECRET, + }, + stripeSecretKey: environment.STRIPE_SECRET_KEY, + }); + // Register the plugin + runtime.addPlugin(stripe); +} +``` + +## Debugging & Troubleshooting + +The Runtime Plugin emits logs that show what the Webhook is doing. For example, +when a new subscription is created, the plugin will log information about the +Stripe subscription, user, etc. + +If are having trouble with the Webhooks, reviewing the logs for the Plugin is +the place to start. + +Additionally, you can use the Stripe +[Webhook logs](https://dashboard.stripe.com/test/webhooks) in the Stripe +Dashboard to view the webhooks that were send and their status. You can also +resend a webhook event. + +![Stripe Webhooks](../../public/media/stripe-monetization-plugin/image.png) diff --git a/docs/articles/support.md b/docs/articles/support.md index b1fe8c6d..c97de566 100644 --- a/docs/articles/support.md +++ b/docs/articles/support.md @@ -48,8 +48,8 @@ offers access to the following channels: ### Premium support Customers on a Zuplo enterprise plan can choose from premium support offerings -that can optionally include specific SLAs for response time as well as additional means of -contact such as a private Slack channel. +that can optionally include specific SLAs for response time as well as +additional means of contact such as a private Slack channel. As part of premium support, Zuplo can also offer: @@ -58,7 +58,7 @@ As part of premium support, Zuplo can also offer: - Advice on best-practices designing your Zuplo API - Troubleshooting -Contact sales to explore adding these options to your agreement. +Contact sales to explore adding these options to your agreement. ## Contact Methods @@ -90,9 +90,10 @@ your support offering. ### Private Discord/Slack Channel -Enterprise support contracts can optionally chat directly with the Zuplo team in a private -Discord or Slack channel. These channels are useful for posting feature -requests, asking questions, or general troubleshooting. Contact sales for more info. +Enterprise support contracts can optionally chat directly with the Zuplo team in +a private Discord or Slack channel. These channels are useful for posting +feature requests, asking questions, or general troubleshooting. Contact sales +for more info. :::caution diff --git a/docs/articles/zuplo-json.md b/docs/articles/zuplo-json.md index b5a20b68..b551b20c 100644 --- a/docs/articles/zuplo-json.md +++ b/docs/articles/zuplo-json.md @@ -13,7 +13,7 @@ of the file is `1`. { "version": 1, "project": "my-project", - "compatibilityDate": "2023-03-14" + "compatibilityDate": "2023-03-14", } ``` diff --git a/policies/custom-code-outbound/doc.md b/policies/custom-code-outbound/doc.md index b7692684..130b0d89 100644 --- a/policies/custom-code-outbound/doc.md +++ b/policies/custom-code-outbound/doc.md @@ -1,7 +1,6 @@ -:::tip -The outbound policy will only execute if the response status code is 'ok' -(e.g. `response.ok === true` or the status code is 200-299) - see -[response.ok on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok). +:::tip The outbound policy will only execute if the response status code is 'ok' +(e.g. `response.ok === true` or the status code is 200-299) - see +[response.ok on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok). ::: ### Writing A Policy diff --git a/policies/rbac-policy-inbound/policy.ts b/policies/rbac-policy-inbound/policy.ts index b9540e81..ff896a44 100644 --- a/policies/rbac-policy-inbound/policy.ts +++ b/policies/rbac-policy-inbound/policy.ts @@ -27,8 +27,8 @@ export default async function ( // Check that the user has one of the allowed roles if ( - !options.allowedRoles.some( - (allowedRole) => request.user?.data.roles.includes(allowedRole), + !options.allowedRoles.some((allowedRole) => + request.user?.data.roles.includes(allowedRole), ) ) { context.log.error( diff --git a/postcss.config.js b/postcss.config.js index 33ad091d..12a703d9 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/public/media/stripe-monetization-plugin/image.png b/public/media/stripe-monetization-plugin/image.png new file mode 100644 index 00000000..904c942e Binary files /dev/null and b/public/media/stripe-monetization-plugin/image.png differ diff --git a/sidebar.json b/sidebar.json index 2f45b305..dcfad776 100644 --- a/sidebar.json +++ b/sidebar.json @@ -55,7 +55,8 @@ "label": "Reference", "items": [ "articles/monetization-glossary", - "articles/monetization-programmatic-quotas" + "articles/monetization-programmatic-quotas", + "articles/stripe-monetization-plugin" ] } ] diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index f2edd03e..07622c39 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link' +import Link from "next/link"; export default function NotFound() { return ( @@ -21,5 +21,5 @@ export default function NotFound() { - ) + ); } diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 744d9479..bb696471 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,30 +1,30 @@ -import Link from 'next/link' -import clsx from 'clsx' +import Link from "next/link"; +import clsx from "clsx"; const variantStyles = { primary: - 'rounded-full bg-pink py-2 px-4 text-sm font-semibold text-gray-900 hover:bg-pink-HOVER focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink/50 active:bg-pink-HOVER', + "rounded-full bg-pink py-2 px-4 text-sm font-semibold text-gray-900 hover:bg-pink-HOVER focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink/50 active:bg-pink-HOVER", secondary: - 'rounded-full bg-gray-800 py-2 px-4 text-sm font-medium text-white hover:bg-gray-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/50 active:text-gray-400', -} + "rounded-full bg-gray-800 py-2 px-4 text-sm font-medium text-white hover:bg-gray-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/50 active:text-gray-400", +}; type ButtonProps = { - variant?: keyof typeof variantStyles + variant?: keyof typeof variantStyles; } & ( | React.ComponentPropsWithoutRef - | (React.ComponentPropsWithoutRef<'button'> & { href?: undefined }) -) + | (React.ComponentPropsWithoutRef<"button"> & { href?: undefined }) +); export function Button({ - variant = 'primary', + variant = "primary", className, ...props }: ButtonProps) { - className = clsx(variantStyles[variant], className) + className = clsx(variantStyles[variant], className); - return typeof props.href === 'undefined' ? ( + return typeof props.href === "undefined" ? (