stripe-zod-contracts — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited stripe-zod-contracts (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Zod schemas define both runtime validation AND TypeScript types. Never write a separate TypeScript interface for data that has a Zod schema — use z.infer<typeof schema> instead. Export the schemas, not the types.
Install and configure fastify-type-provider-zod so that Zod schemas on routes automatically infer request/response types:
import Fastify from 'fastify';
import {
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from 'fastify-type-provider-zod';
const app = Fastify();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// All routes registered on this instance get Zod type inference
const typedApp = app.withTypeProvider<ZodTypeProvider>();The webhook POST route does NOT use standard Zod body validation because:
Instead, validate the event AFTER signature verification:
import { z } from 'zod';
// Validated after constructEvent() succeeds
const stripeEventSchema = z.object({
id: z.string().startsWith('evt_'),
type: z.string(),
data: z.object({
object: z.record(z.unknown()),
}),
created: z.number(),
});
type StripeEventParsed = z.infer<typeof stripeEventSchema>;Use z.discriminatedUnion on the type field for type-safe event dispatch:
const subscriptionEventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('customer.subscription.created'),
data: z.object({ object: subscriptionObjectSchema }),
}),
z.object({
type: z.literal('customer.subscription.updated'),
data: z.object({ object: subscriptionObjectSchema }),
}),
z.object({
type: z.literal('customer.subscription.deleted'),
data: z.object({ object: subscriptionObjectSchema }),
}),
z.object({
type: z.literal('invoice.paid'),
data: z.object({ object: invoiceObjectSchema }),
}),
z.object({
type: z.literal('invoice.payment_failed'),
data: z.object({ object: invoiceObjectSchema }),
}),
]);
type SubscriptionEvent = z.infer<typeof subscriptionEventSchema>;This is more efficient than z.union — it checks the type discriminator first, then validates only the matching branch.
Validate all configuration at startup:
export const envSchema = z.object({
// Stripe
STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/, 'Invalid Stripe secret key format'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_', 'Invalid webhook secret format'),
// Database
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
// Auth
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
JWT_ISSUER: z.string().url().optional(),
JWT_AUDIENCE: z.string().optional(),
// Server
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
HOST: z.string().default('0.0.0.0'),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
// Rate limiting
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(1000),
RATE_LIMIT_WINDOW: z.string().default('1 minute'),
});
export type Env = z.infer<typeof envSchema>;For protected billing API routes (NOT webhooks), use full Zod validation:
// POST /api/v1/billing/checkout
export const createCheckoutSchema = {
body: z.object({
priceId: z.string().startsWith('price_'),
successUrl: z.string().url(),
cancelUrl: z.string().url(),
}),
response: {
200: z.object({
sessionUrl: z.string().url(),
}),
},
};
// GET /api/v1/billing/subscription
export const getSubscriptionSchema = {
response: {
200: z.object({
planName: z.string(),
status: z.enum([
'trialing', 'active', 'past_due', 'canceled',
'unpaid', 'incomplete', 'incomplete_expired', 'paused',
]),
currentPeriodEnd: z.number(),
cancelAtPeriodEnd: z.boolean(),
// stripe_customer_id NOT exposed — internal only
}),
},
};export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});export const updateSubscriptionSchema = {
body: z.object({
cancelAtPeriodEnd: z.boolean().optional(),
newPriceId: z.string().startsWith('price_').optional(),
}).refine(
(data) => Object.keys(data).length > 0,
{ message: 'At least one field must be provided' }
),
};Export schemas, derive types with z.infer:
// schemas.ts — export these
export const subscriptionSchema = z.object({ /* ... */ });
export const entitlementSchema = z.object({ /* ... */ });
export const checkoutRequestSchema = z.object({ /* ... */ });
// Types derived from schemas — export these too
export type Subscription = z.infer<typeof subscriptionSchema>;
export type Entitlement = z.infer<typeof entitlementSchema>;
export type CheckoutRequest = z.infer<typeof checkoutRequestSchema>;request.user.id from JWT)Use z.coerce for query parameters (which arrive as strings):
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
status: z.enum(['active', 'canceled', 'all']).default('all'),
});Use z.transform for data normalization:
const emailSchema = z.string().email().transform((s) => s.toLowerCase().trim());Use z.preprocess for complex input coercion:
const timestampSchema = z.preprocess(
(val) => typeof val === 'string' ? new Date(val).getTime() / 1000 : val,
z.number().int().positive()
);~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.