ADR 0015 — Production Hardening, Monetization & Marketplace
Status
Accepted (source-complete; verification loop constrained by a sandbox toolchain gap more severe than any prior module's — see §Verification constraints).
Context
Module 14 made the platform survive a node failure and scale horizontally. What it didn't do — and what every prior module's release notes flagged as deferred — is answer the questions a project needs answered before real users and real money touch it: how does it charge anyone, how does it stop one workspace from exhausting shared resources, how does it know a marketplace plugin hasn't been tampered with, how does a self-hosted operator get support and diagnose problems without SSH access, and how does it behave for a user who doesn't speak English or relies on a screen reader. Module 15 closes that list: billing/ monetization, usage quotas, marketplace security, API platform hardening, integrations, AI cost control, privacy/telemetry groundwork, i18n/ accessibility, onboarding/demo mode, operability (health diagnostics, structured error handling, support), release engineering, and feature flags — all under the same non-negotiable constraint every prior module has carried: no mandatory paid service, self-hosted deployment fully functional, every external/paid integration strictly opt-in.
Decisions
1. Billing is a provider abstraction with none as the default, not a
Stripe dependency. PaymentProvider is an interface; StripePaymentProvider
is one implementation, built on plain fetch() deliberately without the
stripe npm SDK (avoiding a large dependency and its own version-drift
surface for a v1 integration). BILLING_PROVIDER defaults to 'none',
and SubscriptionService still creates a real Subscription row per
organization even with no provider configured, pinned to PlanTier.FREE
whose Plan.limits are UNLIMITED by seed default — so billing
machinery existing in the schema never implies a deployment must pay for
anything.
2. Two independent Stripe webhook paths, not one shared secret reused
for two purposes. Module 14 already had a generic, shared-secret
billing webhook. Module 15's StripeWebhookController
(POST /billing/webhook/stripe, task #366) is deliberately separate: it
verifies Stripe's own Stripe-Signature: t=<ts>,v1=<hex_hmac> scheme
(HMAC-SHA256 over ${timestamp}.${rawBody}, timingSafeEqual comparison,
5-minute replay tolerance) against STRIPE_WEBHOOK_SECRET, independent of
the older mechanism, rather than overloading one verification path with
two different signature formats. See docs/architecture/stripe-webhook.md.
3. Usage metering and quota enforcement are additive and opt-in by
construction. UsageService meters consumption unconditionally (cheap,
useful for dashboards regardless of billing), but QuotaService.check()
only ever blocks an action if a QuotaPolicy row exists at some scope
(USER/WORKSPACE/ORGANIZATION) or the org's Plan.limits entry for that
resource is non-null — an empty QuotaPolicy table and the default
UNLIMITED FREE plan together mean a self-hosted deployment sees
allowed: true for everything, with no throttling and no extra write on
the hot path. Task #358's performance pass collapsed what had been up to
three sequential findFirst() calls (one per candidate scope) into a
single findMany() with an OR across all candidates, picking the most
specific match in memory — a real N+1 fix, not just a documentation pass.
4. Marketplace security is integrity tooling that applies uniformly,
not a paid gate. Publisher verification/registration, package
checksums, a scan-result/security-advisory pipeline, and enforced
per-plugin permission declarations (PluginPermissionGrant — a plugin
cannot silently gain a capability its manifest didn't declare) apply the
same way regardless of PlanTier or PENTESTHUB_EDITION. This is a
trust boundary around third-party code, not a monetization lever.
5. Feature flags resolve through one deterministic precedence order,
reusing EditionService rather than building a parallel edition check.
FeatureFlagsService.evaluateAll() resolves each flag as: USER override
WORKSPACE override > ORGANIZATION override >
editionRules(AND ofminEdition/selfHostedOnly/planTiers/betaOnly/developerOnly) >defaultEnabled.editionRules.minEditioncallsEditionService.isEnterpriseFor(organizationId?)— the same Module 14 serviceTenantController'sEditionGuardalready depends on — rather than re-implementing Community-vs-Enterprise detection a second time.editionRulesis nullable and unused by any flag this module ships, matching the "no deployment-wide platform-admin role" posture: the two management endpoints (POST /feature-flags,POST /feature-flags/overrides) are real and functional but reachable via Swagger/curlonly, same disclosed scope decision as the Support ticket queue (task #355) and the Module 14 Admin Console before it. Seedocs/architecture/feature-flags.md.
6. Operability additions are diagnostic surfaces over existing state,
not a new monitoring subsystem. GET /observability/health/diagnostics
is public, always-200, and does a real DB round-trip plus process
metadata — designed so a self-hosted operator (or a Support ticket
submitter) can get a paste-able health snapshot with zero extra
infrastructure. Every AppException threads a request-correlation ID
from AsyncLocalStorage through to the JSON error body and, on the web
app, through root and dashboard-segment React error boundaries — one ID
a user can quote back in a support ticket, not a new logging pipeline.
7. Support is a ticket queue without a triage UI, for the same
architectural reason Module 14's Tenant Management stayed Enterprise-only
and the Admin Console stayed read-oriented: no deployment-wide
platform-admin role exists yet. POST /support/tickets (public,
rate-limited, optional diagnostics-snapshot attachment) plus an
authenticated "my tickets" list are real and usable today; building a
staff-facing triage queue on top of a role that doesn't exist would mean
inventing that role's authorization model as a side effect of a support
feature, which this module deliberately declines to do. See
docs/architecture/support-system.md.
8. Release engineering starts the version-tracking discipline this
project didn't have before. Root and per-app package.json versions
are synchronized to the module-tracking scheme for the first time
(0.15.0 for Module 15), CHANGELOG.md now exists at the repo root
following Keep a Changelog format back to 0.1.0, and RELEASE_CHANNEL
(stable/beta/nightly, default stable) is surfaced on GET /observability/health's liveness response as informational metadata —
no code path branches on it yet, deliberately, since nothing in this
module needed channel-specific behavior.
9. Privacy and i18n/accessibility are groundwork, explicitly scoped as
such. Self-service data export/deletion and telemetry opt-out
(UserPreferences.telemetryOptOut) give a data subject real control
today; the opt-in, privacy-aware TelemetryModule collects nothing by
default. i18n architecture spans English, Uzbek, and Russian; the
accessibility audit (docs/reviews/0009-accessibility-audit.md) covers
web, mobile, and desktop. Neither claims to be a finished compliance
program — docs/compliance/gdpr-readiness.md and this module's new
PRIVACY.md say plainly what's implemented versus what a real deployment
operator still needs to configure (a monitored contact address, a
jurisdiction-specific retention policy, a DPA with any sub-processor they
choose to enable).
Consequences
- Two independent billing-webhook verification paths (Module 14's
generic shared-secret one and this module's Stripe-specific HMAC one)
must both be kept correct going forward; a future payment-provider
integration should get its own dedicated verification path rather than
being squeezed into either existing one, consistent with the
provider-abstraction pattern
PaymentProvideralready establishes. QuotaService.resolveLimit()'s single-findMany()shape is now the precedent for any future multi-scope lookup in this codebase — a sequentialfindFirst()-per-candidate loop introduced elsewhere should be treated as a regression of the same class this task fixed.FeatureFlagsServiceis now the second consumer ofEditionService.isEnterpriseFor()alongsideEditionGuard— any future Community-vs-Enterprise check should call this same method rather than re-deriving edition state fromLicenserows directly.- The "no deployment-wide platform-admin role" gap, now load-bearing for three separate features (Tenant Management, Support triage, Feature Flags management), is large enough that it should be treated as its own future module rather than continuing to be worked around feature-by-feature.
- The Billing UI and Feature Flags admin UI are the two largest end-user-facing gaps this module leaves open; both are already tracked as separate follow-up work rather than silently absorbed into "Module 15 is done."
Verification constraints (sandbox)
This module's sandbox constraint is a strictly worse variant of
Module 14's: not just "pnpm install/network/build tooling is slow or
absent," but, discovered during task #366's Stripe webhook work, the
root package.json files for @nestjs/common, @nestjs/swagger,
@nestjs/cqrs, @nestjs/config, and @prisma/client are physically
missing from this sandbox's pnpm content-addressable store, even though
nested files within each package are present. Confirmed two independent
ways: find/ls on the resolved store path showing total 0 with only
subdirectories and no package.json, and a plain node -e "require.resolve('@nestjs/common')" failing with MODULE_NOT_FOUND —
ruling out a TypeScript-specific resolution quirk. This means:
apps/api'stsc --noEmitcannot complete in this sandbox at all, not merely slowly — every scoping/incremental/caching workaround attempted (see task #366's disclosure) still hits the same missing-file wall.jestis unrunnable here (same standing gap disclosed since earlier modules) — three new spec files (feature-flags.service.spec.ts,stripe-webhook.controller.spec.ts,quota.service.spec.ts, task #362) were written and manually traced against their targets' actual control flow but have never executed.npx prisma generate/ a real migration have not run here — theprismaCLI package is absent from the local pnpm store, same category of gap. Module 15's schema changes are captured in one consolidated migration file that must be verified withprisma migrate diffagainst a real database.- Background/detached processes (nohup, disown, setsid) were empirically confirmed not to persist across separate sandbox tool invocations in this environment, which rules out a "launch a long build in the background and poll later" workaround for any of the above.
What was done instead, disclosed per-task throughout this module:
direct manual review of every new/changed source file (reading
.d.ts signatures where available, cross-checking type names against
packages/shared's actual exports, tracing control flow by hand against
each new spec file's assertions). This is real verification effort, but
it is not a substitute for an actual compiler pass, lint run, or test
execution. A normal development machine must run pnpm install && pnpm --filter api prisma generate && pnpm --filter api prisma migrate dev && pnpm turbo run build lint check-types test before this module is
considered production-ready — see the module's final completion report
for the exact outstanding checklist.