@vibemonetize/core
Shared contracts for the whole platform. Zero dependencies beyond zod and jose.
Zod schemas here are the single source of truth for shapes — API and SDKs import them,
never redeclare.
Public API
Domain schemas (schemas.ts)
ulidSchema, slugSchema, and zod schemas + inferred types for every domain object:
Developer, App, Feature, Meter (period: month | day | lifetime | credit),
ObservedOrigin ({ origin, firstSeenAt, lastSeenAt } — passive Origin/Referer
capture from browser-facing authenticated traffic, so a CLI-created app can still
surface where it's deployed) and AppDetail (appSchema.extend({ observedOrigins, activationState }), GET /v1/apps/:appId only — list/create/update stay on plain
App; observation only, never auto-promoted into App.domains; activationState is
RFC 026's read projection — see activation.ts below),
Plan (with grants: FeatureGrant[], limits: UsageLimit[]; appId XOR bundleId;
RFC 036 billing-shape invariant — a period:"credit" limit and the "credits" interval
imply each other, so planSchema/planCreateSchema reject a credit pack declared as a
recurring/one-time plan and vice-versa via planBillingShapeRefinement/planBillingShapeMessage),
Bundle, EndUser, Membership (status/source enums; trials are memberships with
expiresAt), Purchase, UsageBalance, PaywallConfig (versioned per app; config
is a freeform v1 passthrough object owned by the dashboard editor + react <Paywall>).
The agreed keys inside that freeform config live here too: paywallContentV1Schema
(headline/body/accentColor — hex-validated — /gatedFeatureSlugs/showPoweredBy) +
readPaywallContentV1 (lenient read with defaults, never throws) — one shared
definition for the dashboard editor and the SDK renderer, while the api contract stays
passthrough. showPoweredBy (RFC 021) toggles the brand-anchored "Powered by
VibeMonetize" paywall footer; it is .default(true) (badge on by default), so configs
persisted before the key existed keep validating and resolve to true — paid tenants
with remove_branding set it false.
Mode (RFC 012): MODES = ["test", "live"] as const, modeSchema (zod enum),
DEFAULT_MODE = "live". Developer test/live split, Stripe-style — partitions
runtime data (api keys, end-user identities, purchases, usage, ledger, webhooks) per
developer; app/plan/feature/meter registry rows stay mode-less. resolveEntitlements
stays mode-agnostic and pure (CLAUDE.md ground truth): mode is a data-layer filter
applied by the api and a JWT passthrough claim, never resolver logic.
Entitlement resolution (entitlements.ts)
resolveEntitlements({ memberships, plans, usage, creditGrants?, now }): EntitlementSet
// EntitlementSet = { features: Set<`${appId}:${featureSlug}`>,
// meters: Map<`${appId}:${meterSlug}`, { cap, used, remaining, resetsAt }> }
Pure, deterministic, zero I/O. Rules: feature grants union across active memberships;
meter caps take the MAX across non-credit sources plus the SUM of credit sources;
expired/canceled/archived memberships and stale usage balances contribute nothing.
Helpers: isMembershipActive, entitlementKey, serializeEntitlementSet,
deserializeEntitlementSet.
creditGrants?: CreditGrantInput[] (#212 — { appId, meterSlug, delta }) is an OPTIONAL
additive input, independent of any membership/plan: a direct comp/goodwill credit grant
(POST /v1/credits/grant, secret-key only) stacks into the same credit-cap SUM a plan's
credit-period UsageLimit contributes, so it's visible on a meter even with zero
memberships/plans. Omitting it (or passing []) is identical to every pre-#212 call site.
This is the only place entitlement logic lives. (CLAUDE.md ground truth.)
Entitlement JWT (jwt.ts)
ES256, 5-minute TTL, JWKS-friendly. issueEntitlementToken, verifyEntitlementToken
(throws VibeMonetizeError("token_expired" | "unauthorized")), claimsForApp (projects
an EntitlementSet to one app's claims: { sub, appId, plan, features[], meters{}, mode },
claimsForApp(...) takes an optional mode?: Mode, defaulting to "live"),
ENTITLEMENT_JWT_ALG, ENTITLEMENT_TOKEN_TTL_SECONDS, entitlementTokenClaimsSchema.
EntitlementTokenClaims.mode: Mode (RFC 012) is optional on the wire for back-compat:
entitlementTokenClaimsSchema defaults a missing mode to "live" on parse, so tokens
issued before this claim existed keep verifying unchanged. issueEntitlementToken always
signs the resolved mode into the JWT payload.
verifyEntitlementToken takes an optional expectMode?: Mode (RFC 022, Gap 2 — closing
the "free test-mode entitlement replayed against a mode-blind live backend" risk):
enforces the token's effective mode (claims.mode ?? "live") matches, throwing
VibeMonetizeError("mode_mismatch") (403) on any mismatch. Omit expectMode to skip the
check entirely (mode-blind verification, the pre-RFC-022 behavior). This is the ONE
enforcement point every verifier shares — @vibemonetize/server's createClient and the
RFC 007 edge-enforcement recipe both call through this, never re-deriving the check.
Errors (errors.ts)
ERROR_CATALOG (code → HTTP status + default message), VibeMonetizeError,
apiErrorSchema ({ code, message } — the wire shape of every API error), toApiError.
RFC 022 additions: mode_unavailable (409 — a deployment has no Stripe key configured
for a request's mode; apps/api-side only) and mode_mismatch (403 — an entitlement
token's mode claim doesn't match what a verifier requires; see jwt.ts above). RFC 032
(legible refusals, issue #200): permanent/config refusals are 4xx, never a 5xx the SDK's
own retry logic can swallow — mode_unavailable is 409, not 503, on purpose (022's own
open question, resolved by the field report); apps/api's per-mode Stripe wiring passes
a message that names the concrete fix rather than just restating what's missing. Issue
#273 additions (same "legible refusals" pattern, extended to the email provider):
invalid_email_domain (400 — a reserved RFC-2606 documentation domain like
example.com/.test/localhost can never receive real mail; see email.ts below) and
email_provider_error (502 — the sibling of stripe_error for apps/api's Resend
gateway, wrapping any provider-side send failure).
RFC 054 (error audience, #292/#295): every catalog entry now carries buyerSafe: boolean — the dual of RFC 032. 032 tuned each message to name the fix for the
DEVELOPER; 054 makes sure the BUYER (who can't apply that fix) never sees it on an
end-user surface. Buyer-safe codes describe the buyer's own situation and are exactly
payment_required, usage_limit_exceeded, and rate_limited; everything else —
including any unrecognized code — is developer-only (when in doubt, fail toward not
leaking). New pure exports: isBuyerSafeErrorCode(code), buyerFacingMessage(error, fallback?) (buyer-safe code + non-empty message → that message; anything else → the
fallback), and BUYER_FALLBACK_MESSAGE ("This purchase is temporarily unavailable.").
The wire shape is untouched — buyerSafe never leaves the process; { code, message }
still round-trips byte-identically. @vibemonetize/react's resolveBuyerErrorMessage
wraps buyerFacingMessage with the console.error that keeps the developer detail in
devtools.
Email domain preflight (email.ts)
reservedDocumentationEmailDomain(email) — returns the matched RFC-2606 reserved domain
(example.com/.net/.org, .example/.test/.invalid/.localhost, localhost) or
null. reservedDocumentationEmailDomainMessage(domain) builds the fix-naming message
(RFC 032 style). apps/api/src/emailGateway.ts's Resend implementation runs this before
every send — issue #273 (a stranger copy-pasting the quickstart's literal
you@example.com got an opaque 500).
API route contracts (routes.ts)
API_ROUTES — OpenAPI-ish table of every v1 endpoint (method, path, auth,
request/response zod schemas), plus the request/response schemas themselves
(entitlementTokenRequestSchema, usageEventRequestSchema,
checkoutSessionRequestSchema, CRUD create/update inputs, eventsIngestRequestSchema).
See CONTRACTS.md for the full table.
grantCreditsRequestSchema/grantCreditsResponseSchema (#212): POST /v1/credits/grant
— sk_-only (never pk_), idempotent additive server-side credit grant onto a
period:"credit" meter (support/goodwill comps, independent of Stripe checkout and of
/v1/usage-events's own idempotency namespace). idempotencyKey is REQUIRED (unlike
usageEventRequestSchema's optional one). Response: { creditGrantId, applied, remaining }
— applied: false on an idempotent replay of an already-recorded key; a key reused with a
different payload throws idempotency_conflict (409). See entitlements.ts above for how
the grant becomes visible in resolveEntitlements.
RFC 030 adds creditBalanceSchema and the secret-key-only reserve, settle, and release
request/response schemas. Balances are { cap, used, reserved, available }, with
available = max(cap - used - reserved, 0); terminal reservation states are immutable.
identifyEndUserRequestSchema/verifyEndUserRequestSchema (RFC 002: publishable-key,
platform-verified 6-digit email code) and mergeEndUserRequestSchema/
mergeEndUserResponseSchema (RFC 014: sk_-only — the developer attests email is
already verified by their own auth, e.g. Neon Auth/Clerk/Supabase) both fold an
anonId's memberships/usage balances/events into the (found-or-created) verified-email
end user and return { endUserId }; the merge route is strictly more privileged
(secret-key only, never pk_) since the trust for email verification shifts from the
platform to the developer.
checkoutSessionRequestSchema (RFC 006) names the plan by exactly one of planId
(ULID escape hatch) or planSlug (the pricing-editor slug — preferred, promptable at
codegen time). Slugs are unique per app, so a planSlug request also carries the
optional appId resolution scope (the react SDK sends <MonetizeProvider>'s
automatically; an app-scoped api key can supply it implicitly). Bundle plans have no
single app scope and remain planId-only.
billingPortalSessionRequestSchema/billingPortalSessionResponseSchema (RFC 009):
POST /v1/billing-portal-sessions — the publishable-key route <AccountButton>'s
"Manage billing" calls to mint a hosted Stripe billing-portal session with no app
backend. Unlike the other pk_ routes it does NOT take endUserId ⊕ anonId: the
request carries the raw entitlement JWT (token) plus a returnUrl, and the api
verifies the token's signature and scopes the portal strictly to its sub claim — a
portal session exposes payment methods/invoices/cancellation, so identity must be
proven, not named. Responds { url }, or a typed not_found (404) when the end user
has no Stripe customer yet in the request's mode.
purchaseHistoryItemSchema/purchasesResponseSchema + PURCHASES_PAGE_LIMIT
(RFC 049): GET /v1/purchases — the payment-mode buyer's "your purchases" read, for
exactly the population the billing portal 404s for (one-time/credit-pack checkout
mints no reusable Stripe customer). Same prove-don't-name identity contract as the
portal route, but as a GET the raw entitlement JWT travels in the
x-vibemonetize-token request header (never a query param). Responds
{ purchases: [...] } — a display-only projection (purchasedAt, appName (a
bundle plan reports the bundle's name), planName, kind, amountCents,
currency, creditsGranted, stripeReceiptUrl) with no ids, newest-first, capped
at PURCHASES_PAGE_LIMIT. stripeReceiptUrl links Stripe's own hosted receipt when
it can be resolved from the stored PaymentIntent, else null — never a generated
document.
Registry CRUD is API-complete (api-first paywall management): features/meters have
update/archive routes (featureUpdateSchema, meterUpdateSchema), and plans have
get/update/archive (planUpdateSchema — slug/currency/owner immutable;
grants/limits replace wholesale; pricing changes attach a new Stripe price with
existing subscribers grandfathered). bundleCreateSchema (bundleSchema.omit(stamped),
same idiom as appCreateSchema — DX loop cycle 2) is the create input for POST /v1/bundles: the missing half of bundle support, since planCreateSchema already
accepted bundleId but the Bundle row itself had no creation path before this.
appCreateSchema carries one create-only extra beyond the omitted-stamps idiom:
listInStore: z.boolean().default(true) (RFC 005 amendment). Store listing at app
creation is default-ON with an explicit off-switch — omit the field (older clients) to
list by default, pass false to create the app unlisted. It is an INPUT flag only, not
an app entity field: the entity's listedAt timestamp (managed by the listing routes)
stays the source of truth for listing state, and unlist-immediately semantics are
unchanged. Like other .default() keys it is required in the inferred AppCreate type.
RFC 025 refines what listInStore: true means: "list as soon as this app has a
destination", not "list now". With ≥1 domain at create the app lists immediately
(listedAt = now, exactly the prior behavior); with none, the server stamps the new
server-managed appSchema.listIntentAt instead (never a create/update input) and
promotes the app to live automatically when its first domain arrives. Pending =
listIntentAt set AND listedAt null; an explicit unlist clears both.
appUpdateSchema (= appCreateSchema.partial()) carries one update-only extra beyond
the create shape: archived?: boolean (RFC 034). false un-archives (restores) an app
and true archives it (same soft-delete as archiveApp/DELETE); it is not on
appCreateSchema (archive state is server-managed at creation). The API re-checks
un-archive against the free-plan app cap, which RFC 034 makes a live count of
non-archived apps rather than a lifetime meter — so archiving frees a slot and
setup-status.appCount (the same live count) equals what is enforced.
listPlansForApp (GET /v1/apps/:appId/plans) nests plans under the app the same way
features/meters already do — same response shape as GET /v1/plans?appId= (DX loop
cycle 2).
Membership management (subscription surface): membershipWithPlanSchema (+
MembershipWithPlan) — membership rows with the plan hydrated inline,
listMembershipsQuerySchema (filters: appId incl. bundle fan-out, endUserId,
email, status), membershipCreateSchema (+ MembershipCreate) — comp grants
(grantee is exactly one of endUserId, anonId, or email — the email form is
RFC 014-style attestation: found-or-created in the key's mode, no verification
challenge; it's what vibemonetize grant sends — plus optional expiresAt), and a
body-less cancel route (POST /v1/memberships/:membershipId/cancel; Stripe subs
cancel at period end).
Setup status (onboarding progress; powers vibemonetize doctor + the dashboard
"Getting started" checklist): setupStatusQuerySchema (?appId= optional — omitted,
the server targets the developer's only app or returns app: null),
setupStatusResponseSchema (+ SetupStatusResponse, SetupStatusApp,
SetupStatusMilestones) — the authenticated key's mode, per-mode key inventory,
appCount, registry counts for the target app (featureCount/meterCount/planCount/
paidPlanCount), the app's RFC 025 store-listing state (listedAt/listIntentAt/
domainCount — live, pending a destination, or not listed — so doctor never
re-derives it), and first-time funnel milestones (firstPaywallImpressionAt,
firstCheckoutStartedAt, firstCheckoutCompletedAt, firstUsageEventAt,
firstPaidMembershipAt — the event-derived three are cross-mode by design, the last
two are scoped to the key's mode per RFC 012).
Events (events.ts)
analyticsEventSchema (stored shape), analyticsEventInputSchema (SDK payload),
CANONICAL_EVENTS (funnel names: page_view … usage_limit_reached), eventSourceSchema,
installChannelSchema (RFC 020: assistant | cli | docs | unknown). Both event schemas
carry an optional installChannel alongside source (RFC 008) — additive, absent means
the channel is unknown (never inferred), not a new event type.
FUNNEL_NON_NESTED_EVENTS (#315) lists canonical events whose distinct-identity count over
a window isn't a subset of an earlier funnel stage's — today just subscription_churned
(its checkout_completed may sit outside the query window). isNestedFunnelEvent() in
analytics.ts is the pure check over it; consumers must not force a "% of previous stage"
ratio for a non-nested event.
Credit audit (creditAudit.ts)
Read-only, secret-key contracts for the RFC 031 credit operations view. creditAuditUsersQuerySchema
and creditAuditUsersResponseSchema define a bounded, cursor-paginated per-meter balance list.
creditAuditTimelineQuerySchema and creditAuditTimelineResponseSchema define one end user's
newest-first immutable activity timeline, including signed available, held, and spent changes,
plus activeReservations: CreditActiveReservation[] — currently-held (status "reserved", not yet
settled/released/expired) holds for that end user/app/meter/mode, soonest-expiring first and bounded
(the API caps this list; see apps/api/src/routes/creditAudit.ts). This answers "is a long-running
job still holding funds right now" without inferring it from the merged timeline. The contracts
intentionally exclude raw metadata, actors, idempotency keys, exports, mutations, and
financial-settlement data.
Analytics reporting (analytics.ts)
analyticsFunnelQuerySchema/analyticsFunnelResponseSchema (per-canonical-event counts
- distinct visitors for an app/date-range; optional
groupBy=attribution_refaddssignupsByRef—signupcounts keyed byproperties.attribution.ref, RFC 008; optionalgroupBy=builder_sourceaddsactivationsByBuilder—activationcounts keyed byproperties.builderSource, RFC 017). #315:counts[name]incanonicalEventCountsSchemais a DISTINCT-IDENTITY count (endUserIdwhere identified,anonIdotherwise — the same rulevisitorsalready used), never a raw event count; one identity repeat-firing a canonical event no longer inflates that stage's share ofvisitorspast 100% (368% was observed in prod before this fix). ThegroupBybreakdowns (signupsByRef/activationsByBuilder) deliberately stay raw event counts — they're attribution buckets, not funnel stages, so they're never compared as a ratio. Also seeFUNNEL_NON_NESTED_EVENTS/isNestedFunnelEvent()above (events.ts) for the one canonical event (subscription_churned) that still isn't a subset of an earlier stage's identities, by domain rather than by counting method.
appRankingEntrySchema/
appsRankingResponseSchema (per-app portfolio triage: visitors30d, signups30d,
conversion, mrrCents, recommendation). recommendAppAction() is the pure decision
function (push | bundle | kill) over the documented APP_RANKING_THRESHOLDS — the api
computes the metrics, this is the only place the push/bundle/kill decision is made.
Per RFC 043 each row's mrrCents is the ATTRIBUTED total
mrrDirectCents + mrrBundleCents: direct app-plan MRR plus an equal-split share of
bundle-plan MRR across each bundle plan's plan_grants reach, computed by the pure
splitBundleMrrEqually() (remainder-exact integer split, deterministic by sorted appId).
The split is display-only triage attribution — never ledger/billing math — and each
bundle plan's shares sum exactly to the rounded bundle MRR the revenue-movement digest
puts on that bundle's own row, so the two surfaces reconcile with no double counting.
Both split fields are .optional() solely for deploy-skew tolerance (a pre-RFC-043 api
omits them); the api always returns them.
#313: each row also carries recentTrend (appRankingRecentTrendSchema) — direct
app-plan MRR reconstructed at now vs APP_RANKING_RECENT_TREND_DAYS (7) days earlier,
using the same boundary rule and live-mode-only policy as the revenue-movement digest's
weekly app rows, plus a precomputed declining flag (hasSharpRecentDecline(): the app
had MRR at the window start and lost at least APP_RANKING_THRESHOLDS.sharpRecentDecline
of it). recommendAppAction() accepts the trend as an optional input: a sharp recent
decline tempers a would-be push to bundle — a 30d conversion aggregate must not mask
this week's collapse. recentTrend is .optional() for deploy-skew tolerance only; the
api always returns it, and omitting it reproduces pre-#313 behavior verbatim.
Revenue movement (RFC 042, consolidating draft 045):
revenueMovementQuerySchema/revenueMovementResponseSchema — the "what changed this
week" digest (GET /v1/analytics/revenue-movement?period=week|month): live-mode-only
portfolio MRR reconstructed at the period boundaries, the signed delta plus the identical
prior window's delta, raw new/churned subscription counts for both windows, per-app and
per-bundle rows (bundle-plan MRR attributes to the bundle, never double-counted into
member apps), and a topMover. The MRR-at-boundary reconstruction rule (and why
trialing/past_due/expired never count) is documented on the schema.
pickTopRevenueMover() (deterministic largest-absolute-delta selection) and
summarizeRevenueMovement() (the one-line plain-English summary the dashboard card
renders) are the pure helpers; the api owns the SQL projection over
memberships ⋈ plans — zero new schema, by design.
Members near cap (#314):
membersNearCapQuerySchema/membersNearCapResponseSchema — which end users are at or
above a fraction of a meter cap (GET /v1/analytics/members-near-cap?appId=&threshold=&limit=,
threshold default MEMBERS_NEAR_CAP_DEFAULT_THRESHOLD = 0.8, results bounded and
ordered by pct desc). Each memberNearCapEntrySchema row's cap/used are the
entitlement resolver's own MeterEntitlement figures (the api batch-loads the resolver's
inputs and runs core's resolveEntitlements per end user — no duplicated cap logic),
so the view can never disagree with the member's entitlement JWT. The response is
MODE-scoped to the authenticated key (usage/memberships are mode-partitioned, RFC 012)
and labels itself with mode; pct is used/cap from the same resolver output — never
a distinct-identity count over a raw count.
Activation nudge (activation.ts, RFC 026)
appActivationStateSchema/AppActivationState — four fixed states (NOT_LIVE | LIVE_NO_PLAN | LIVE_NO_CHECKOUT | EARNING), no configurability by design (see the RFC's
"Why not" — this is a fixed, opinionated safety rail for the wedge, not a
lifecycle-marketing/segment-builder surface). appActivationState(signals) is the pure
state-machine function: no observed origin ever → NOT_LIVE (never nudged); ≥1
checkout_completed ever → EARNING; a priced plan exists (non-$0, Stripe price
actually attached) but zero completed checkouts → LIVE_NO_CHECKOUT; observed origin +
recent events but zero priced plans → LIVE_NO_PLAN; anything else (observed once, gone
quiet) → NOT_LIVE. ACTIVATION_RECENT_EVENTS_WINDOW_DAYS (30d, mirrors
computeAppsRanking's window) and ACTIVATION_NUDGE_EMAIL_DELAY_MS (48h) are the two
tunable-but-fixed constants; the api (activationState.ts) owns the actual
app_observed_origins/plans/events reads and calls this pure function — same
"pure decision logic here, api owns the SQL" split as analytics.ts/store.ts.
Surfaced on AppDetail.activationState (GET /v1/apps/:appId) and consumed by exactly
one lifecycle email template (activation_nudge, apps/api's lifecycleEmail.ts), fired
at most once per app.
Integration-success visibility (integration.ts)
The mode-aware (RFC 012 test vs live) "did the integration actually succeed?" contract —
deliberately distinct from RFC 026's activation rail (cross-mode, lifecycle-marketing)
and from the cross-mode analytics events table (per-mode milestones live in the api's
integration_signals rollup instead).
INTEGRATION_SIGNALS/integrationSignalSchema— the rollup keys:cli_*(self-reported CLI runs —INTEGRATION_CLI_COMMANDS/integrationCliCommandSchema, mapped viaintegrationSignalForCliCommand;initis absent on purpose, it never touches the network),token_issued(entitlement-token mint — THE "SDK provably ran in this mode" signal), and the funnel trio (paywall_impression,checkout_started,checkout_completed) mirrored per mode at ingest/webhook-projection time.integrationModeState(signals)— pure projection (same zero-I/O contract asappActivationState) onto the strictly-ordered per-mode railnot_started → keys_issued → sdk_connected → checkout_completed(INTEGRATION_MODE_STATES/integrationModeStateSchema); never demotes.isIntegrationVerified(state)— true atsdk_connected+, the one boolean agents and dashboards key off.- Wire shapes:
cliRunReportRequestSchema/cliRunReportResponseSchema(POST /v1/integration/cli-runs, secret key; the run's mode is always the KEY's mode) andintegrationStatusResponseSchema/IntegrationStatusResponse(GET /v1/apps/:appId/integration-status) with per-modeintegrationModeStatusSchemablocks (verified,state, key inventory, first/last token timestamps, last doctor run + outcome,integrationSignalSnapshotSchema[]) plusobservedOriginsandstatusUrl— the endpoint's own URL, printed bydoctorand pollable from CI/agents.
Browser-key hygiene (keyHygiene.ts, RFC 052)
Three FIXED read-only rules over a developer's api_keys (+ passively observed browser
origins) that flag the configurations behind issue #282 (a live, developer-wide
publishable key in a public browser bundle minting real-money checkouts). Read-only by
hard boundary: the report recommends, it never revokes — the #284 bulk revoke stays a
needs-human action.
KEY_HYGIENE_RULES/keyHygieneRuleSchema—secret_in_browser(critical: browser origins recorded for an app a secret key covers, and NO unrevoked publishable key covers that app, so the browser must be authenticating with ansk_),live_unscoped_publishable(high:pk_+mode='live'+app_id NULL— the exact #282 shape),legacy_mode_blind(medium: unrevoked pre-2026-07-06MODE_ENFORCEMENT_CUTOFFrow idle forLEGACY_IDLE_DAYS+ days). Fixed severities inKEY_HYGIENE_RULE_SEVERITY.evaluateKeyHygiene(keys, context)— pure, zero-I/O, deterministic (severity-first ordering); a single key can carry multiple findings.worstKeyHygieneSeverity.- Wire shapes:
keyHygieneFindingSchema(each finding names the fix — RFC 032 legibility; carries the displaykeyPrefix, never key material) andkeyHygieneReportResponseSchema(GET /v1/developer/key-hygiene, secret key) — rendered byvibemonetize doctor'skey-hygienecheck and the dashboard's "Setup health" line.
Store (store.ts, RFC 005)
computeStoreScore — the ONE ranking formula (log-scaled 28d visitors/signups/
checkouts/revenue + clamped conversion; weights in STORE_SCORE_WEIGHTS).
storeConversionRate uses checkouts / max(impressions, checkouts) so it stays
monotone when checkouts arrive without recorded impressions. storePublicTiers/
storeTierFor bucket raw counts into public 0–4 order-of-magnitude tiers (the
privacy boundary — raw numbers never leave the owner's dashboard). Public
projections: storeAppSchema, storeCreatorSchema, storeCreatorDetailSchema,
storeRankingSchema. Listing inputs live in routes.ts
(appListingUpsertSchema, developerProfileUpdateSchema); schemas.ts gained
handleSchema, APP_CATEGORIES/appCategorySchema, listing fields on
appSchema, and profile fields on developerSchema.
Bundle landing page (store.ts, RFC 040)
bundleLandingSchema (+ BundleLanding) — the public projection behind
GET /v1/store/bundles/:handle/:slug: the bundle's slug/name, a derived
copy line (see defaultBundleLandingCopy below — no stored/editable copy
field in v1), a storeCreatorRefSchema developer, apps (BundleLandingApp[]
— {slug, name, tagline, iconUrl, category, url}, one per app the bundle
grants that is currently listed with a verified domain — never an app the
page would dead-end on), plans (BundleLandingPlan[] — {slug, name, priceCents, currency, interval}, never a Stripe id), and a nullable
sellLinkSlug naming the RFC 018 bundle sell link that backs the buy CTA
(null, not a 404, when the developer hasn't created one yet).
defaultBundleLandingCopy(appNames: string[]) is the pure v1 default-copy
generator (oxford-commas 3+ apps) — the RFC's "why not" section argues
against a copy editor in v1, so this is the only source of the page's one
line of marketing copy.
Named sell links (routes.ts, RFC 018)
sellLinkSchema (+ SellLink, schemas.ts) — the sell_links CRUD entity targets exactly
one of nullable appId/bundleId; slug
(unique per developer), publishableKeyId (the api_keys row backing the page —
changeable via PATCH independently of the link's own slug/URL), planIds (nullable
subset; null = every public plan on the app), disabledAt (this entity's only
lifecycle marker; no archivedAt, no delete route). sellLinkCreateSchema/
sellLinkUpdateSchema take the actual publishableKey VALUE (pk_...), never an id —
the api resolves + validates it into publishableKeyId server-side and persists the
plaintext alongside it, since api_keys itself only ever stores a key's hash (see
apps/api/src/apiKeys.ts); safe because publishable keys are browser-safe by design.
listSellLinksQuerySchema (?appId= optional). publicSellLinkSchema (+
PublicSellLink) is the public resolver's response (GET /v1/sell-links/:handle/:slug, auth none) — {appId, appName, publishableKey, planIds}, revealing only what the page would have shipped to a browser anyway; 404s
when disabled or the backing key is revoked/archived. Powers apps/store's indexable
/buy/[handle]/[slug] page and vibemonetize app create's named sell-link summary
line, both new in this RFC — /p/[appId]?pk= remains the zero-setup, key-in-URL
fallback for a developer with no public creator handle yet.
Outbound webhooks (webhooksOut.ts, task #6)
Contract for developer-registered webhook receivers (webhook_endpoints_out):
webhookOutEventTypeSchema (subscription.created | subscription.canceled | usage.limit_reached), webhookEndpointSchema (+ WebhookEndpoint) — the CRUD
response entity (secret is full only in the create response, redacted to the
last 4 chars everywhere else), webhookEndpointCreateSchema (appId: null =
all of the developer's apps) / webhookEndpointUpdateSchema (partial, ≥1
field), and the dispatch payload: webhookOutEnvelopeSchema ({id, type, createdAt, data}) with per-event data shapes
(subscriptionCreatedDataSchema, subscriptionCanceledDataSchema,
usageLimitReachedDataSchema). Route contracts are in API_ROUTES
(createWebhookEndpoint … archiveWebhookEndpoint, secret_key). Signing and
secret generation are api implementation details (Stripe-style
x-vibemonetize-signature: t=<ts>,v1=<hmac-sha256>), not part of this package.
Pricing experiments (experiments.ts, Phase 4)
experimentSchema/experimentVariantSchema — variants reference existing Plan rows
(2+ price points on one app; unique keys and planIds enforced by
experimentVariantsAreValid) rather than inline price overrides, reusing the
per-plan Stripe price machinery instead of duplicating it. status (draft | running | stopped) is derived from startedAt/endedAt by deriveExperimentStatus — never
stored redundantly. assignVariant({ experimentId, subjectId, variants }) is the pure,
deterministic weighted-bucket assignment function (djb2 hash of
${experimentId}:${subjectId}) — the only place variant-bucketing logic lives; the api
persists the result lazily on first lookup. experimentAssignmentSchema mirrors the
experiment_assignments table. Route contracts: experimentCreateSchema,
listExperimentsQuerySchema, experimentSummaryResponseSchema (checkout-starts +
conversions per variant), getExperimentAssignmentsQuerySchema/
experimentAssignmentResultSchema (publishable-key, lazy per-subject assignment).
Pricing templates (pricingTemplates.ts, RFC 046)
PRICING_TEMPLATES — the three canonical archetype pricing packs (saas,
ai-credits, daily-free) as plain data over MeterSpec/PlanCapSpec/PlanSpec
(also defined here; the CLI's --meter/--cap/--plan flag shapes). Shared by the
CLI's app create/plan create --template <name> and the dashboard's "Start from a
template" plan step, so both surfaces expand the identical features/meters/plans.
TEMPLATE_NAMES, isTemplateName(value) (untrusted-input guard), and
expandTemplate(name) (deep-copied { features, meters, plans }) round out the API.
Templates are pure expansion into the existing create routes — no schema of their own,
and RFC 036's billing-shape invariant applies to them like any manual plan. Note: the
published CLI mirrors these values at runtime (packages/cli/src/templates.ts) because
core is deliberately only a devDependency there; a drift is a CLI test failure.
Tests
pnpm test — vitest; resolveEntitlements is covered by fast-check property tests
(union semantics, max-cap meters, additive credits, trial expiry, determinism);
computeStoreScore/storeTierFor likewise (determinism, per-component
monotonicity, tier bounds).