Architecture deep-dive
How SaaSCoreX works under the hood
This page shows the actual enforcement layers that execute before state changes occur. All snippets are production code — not pseudocode, not “simplified examples.”
10 subsystems. All server-enforced. All wired together.
RBAC
Typed permission enforcement
13 action types. Three role tiers. Every mutation guarded server-side. No UI-only protection.
packages/core/src/teams/permissions.ts
export type OrgAction =
| "org.update"
| "org.delete"
| "member.invite"
| "member.remove"
| "member.update"
| "member.list"
| "project.create"
| "project.update"
| "project.delete"
| "billing.manage"
| "apiKey.create"
| "apiKey.delete"
| "audit.read";
const ROLE_PERMISSIONS: Record<MemberRole, Set<OrgAction>> = {
OWNER: new Set([ /* all 13 actions */ ]),
ADMIN: new Set([ /* 9 actions */ ]),
MEMBER: new Set([ /* 4 actions */ ]),
};
export function requirePermission(
role: MemberRole, action: OrgAction
): void {
if (!can(role, action)) {
throw new PermissionDeniedError(role, action);
}
}- OrgAction union type catches typos at compile time
- requirePermission() throws before any write executes
- Role-permission matrix is a data structure, not scattered if-checks
tenant-isolation
Multi-tenant isolation
Every database query scoped to the active org. Cross-tenant access is structurally impossible.
packages/core/src/auth/session.ts
export async function requireOrg() {
const session = await requireAuth();
if (!session.activeOrgId) {
throw new Error(
"Session has no activeOrgId."
);
}
return session;
}
// Every query is scoped to the active org
const projects = await db.project.findMany({
where: { orgId: session.activeOrgId },
});- requireOrg() guarantees an activeOrgId before any data access
- Session is cached per request — safe to call multiple times
- No org context = error, not silent fallthrough
Billing
Entitlement-driven billing
Usage limits enforced at the function level. Plan switching with automatic Stripe proration. No stale permission caches.
packages/core/src/billing/entitlements.ts
export async function checkLimit(
orgId: string,
limitKey: keyof PlanEntitlements,
currentUsage: number,
): Promise<{ allowed: boolean; limit: number; usage: number }> {
const entitlements = await getEntitlements(orgId);
const limit = entitlements[limitKey] as number;
return {
allowed: currentUsage < limit,
limit,
usage: currentUsage,
};
}
export async function requireEntitlement(
orgId: string,
check: (e: PlanEntitlements) => boolean,
message = "This feature requires an upgrade.",
): Promise<PlanEntitlements> {
const entitlements = await getEntitlements(orgId);
if (!check(entitlements)) {
throw new EntitlementRequiredError(message);
}
return entitlements;
}- checkLimit() compares live usage against plan entitlements
- requireEntitlement() throws before the operation runs
- switchSubscriptionPlan() handles upgrade/downgrade with proration
- No subscription defaults to free-tier entitlements
Audit
Structured audit logging
24-action typed taxonomy. Query API with date range and action filters. CSV export. Automatic retention policies.
packages/core/src/audit/service.ts
export type AuditAction =
| "user.login" | "user.logout" | "user.delete"
| "user.update"
| "org.create" | "org.update" | "org.delete"
| "member.invite" | "member.accept_invite"
| "member.revoke_invite" | "member.change_role"
| "member.remove" | "member.leave"
| "billing.checkout"
| "billing.subscription_created"
| "billing.subscription_updated"
| "billing.subscription_cancelled"
| "billing.payment_failed"
| "admin.impersonate" | "admin.user_update"
| "resource.create" | "resource.update" | "resource.delete"
| "resource.read" | "user.sessions_invalidated";
export async function audit(input: AuditLogInput): Promise<void> {
if (!isFeatureEnabled("auditLog")) return;
try {
await db.auditLog.create({
data: {
actorId: input.actorId ?? null,
orgId: input.orgId ?? null,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
meta: input.meta ?? undefined,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
},
});
} catch {
// Never throws — audit failures don't disrupt the primary flow
}
}- audit() is a fire-and-forget call — never breaks the primary flow
- Feature-gated: no-op when auditLog is disabled
- Paginated query API with actor, action, and date range filters
- purgeOldLogs() called by weekly background job (90-day default)
Features
Three-level feature gating
Disable a subsystem and it vanishes: actions throw, routes 404, UI hides. No dead code, no phantom routes.
packages/features-runtime/src/
// 1. Server actions — throw if disabled
export function assertFeatureEnabled(flag: FeatureFlag): void {
if (!isFeatureEnabled(flag)) {
throw new FeatureDisabledError(flag);
}
}
// 2. Route handlers — return 404 if disabled
export function withFeatureGuard(flag: FeatureFlag, handler) {
return async (...args) => {
if (!isFeatureEnabled(flag)) {
return new NextResponse(null, { status: 404 });
}
return handler(...args);
};
}
// 3. React components — hide UI if disabled
export function FeatureGate({ flag, children, fallback = null }) {
if (!isFeatureEnabled(flag)) return <>{fallback}</>;
return <>{children}</>;
}- assertFeatureEnabled() — server actions and mutations
- withFeatureGuard() — route handlers return 404
- FeatureGate — React component hides nav and UI blocks
- Config is a plain TypeScript object — no external service needed
API Keys
API key model
SHA-256 hashed storage. Plaintext shown once at creation. Prefix for identification. Expiry and revocation built in.
packages/core/src/api-keys/service.ts
function generateKey() {
const plaintext = `sk_${randomBytes(32).toString("hex")}`;
const prefix = plaintext.slice(0, 11); // "sk_" + first 8 hex
const keyHash = createHash("sha256")
.update(plaintext).digest("hex");
return { plaintext, prefix, keyHash };
}
export async function createApiKey(orgId, userId, name) {
const { plaintext, prefix, keyHash } = generateKey();
const record = await db.apiKey.create({
data: { orgId, userId, name, keyHash, prefix },
});
// plaintext shown once — never stored
return { key: plaintext, record };
}
export async function validateApiKey(plaintext) {
const keyHash = hashKey(plaintext);
const key = await db.apiKey.findUnique({
where: { keyHash },
});
if (!key || key.revokedAt) return null;
if (key.expiresAt && key.expiresAt < new Date()) return null;
return { userId: key.userId, orgId: key.orgId };
}- sk_ prefix for easy identification in logs
- Only the hash is stored — plaintext is never persisted
- validateApiKey() checks revocation and expiry before authorizing
- lastUsedAt updated asynchronously to avoid blocking requests
Jobs
Background jobs
Billing reconciliation, audit purge, webhook retry, data export. Scheduled and event-driven via Inngest with automatic retries.
packages/jobs/src/functions/
// Nightly billing reconciliation — 3 AM daily
export const billingReconciliation = inngest.createFunction(
{ id: "billing-reconciliation" },
{ cron: "0 3 * * *" },
async ({ step }) => {
if (!isFeatureEnabled("billing")) {
return { skipped: true, reason: "billing disabled" };
}
return step.run("reconcile-subscriptions", () =>
reconcileSubscriptions()
);
},
);
// Audit log purge — Sunday 4 AM, 90-day retention
// Webhook retry — every 15 minutes
// Data export — event-driven, emailed as JSON- Billing reconciliation heals Stripe↔DB drift from missed webhooks
- Audit purge enforces 90-day retention on a weekly schedule
- Data export gathers org data and emails as JSON attachment
- Feature-gated: jobs skip gracefully when their subsystem is off
Webhooks
Webhook idempotency
State machine prevents double-processing. RECEIVED → PROCESSING → PROCESSED/FAILED. Race-condition safe.
packages/core/src/billing/webhook-handler.ts
export async function processWebhookEvent(
event: Stripe.Event
): Promise<void> {
// Idempotency: deduplicate by stripeEventId
const existing = await db.webhookEvent.findUnique({
where: { stripeEventId: event.id },
});
if (existing?.status === "PROCESSED") return;
if (existing?.status === "PROCESSING") return;
// Claim the event: RECEIVED → PROCESSING
const webhookEvent = await db.webhookEvent.upsert({
where: { stripeEventId: event.id },
create: { stripeEventId: event.id, status: "PROCESSING", ... },
update: { status: "PROCESSING", attempts: { increment: 1 } },
});
try {
await handleEvent(event);
// PROCESSING → PROCESSED
await db.webhookEvent.update({
where: { id: webhookEvent.id },
data: { status: "PROCESSED", processedAt: new Date() },
});
} catch (error) {
// PROCESSING → FAILED (retryable)
await db.webhookEvent.update({
where: { id: webhookEvent.id },
data: { status: "FAILED", error: error.message },
});
throw error; // Return 500 for Stripe retry
}
}- Deduplication by stripeEventId — events processed exactly once
- PROCESSING state prevents concurrent instances from racing
- FAILED events are retried by background job, not lost
- Error details stored for debugging — not silently swallowed
Rate Limiting
Default-on rate limiting
Memory adapter for single-instance deploys. Redis adapter activates automatically when REDIS_URL is set. No code changes needed.
packages/core/src/observability/rate-limit.ts
export const rateLimits = {
auth: { maxRequests: 10, windowSec: 60 },
admin: { maxRequests: 30, windowSec: 60 },
api: { maxRequests: 100, windowSec: 60 },
action: { maxRequests: 20, windowSec: 60 },
} as const;
export async function requireRateLimit(
key: string, config: RateLimitConfig
): Promise<void> {
const limiter = await getRateLimiter();
const result = await limiter.check(key, config);
if (!result.allowed) {
throw new RateLimitExceededError(result.resetAt);
}
}
// Auto-detects REDIS_URL → Redis adapter
// No REDIS_URL → in-memory (zero deps)- Preset configurations for auth, admin, API, and action routes
- requireRateLimit() throws before the operation executes
- Auto-detects Redis for multi-instance deployments
- Memory store has automatic cleanup and size limits
REST API
21-endpoint REST API
Full CRUD API with OpenAPI 3.1 spec, consistent response envelopes, CORS, pagination, and per-endpoint rate limiting. Typed errors map to HTTP status codes through a centralized handler.
apps/web/src/app/api/v1/
// Consistent response envelope
type ApiResponse<T> =
| { data: T }
| { error: { code: ErrorCode; message: string; requestId: string } };
// Every route follows the same pattern:
export async function GET(request: Request) {
const auth = await authenticateRequest(request); // 1. Authenticate
requirePermission(auth.role, "member.list"); // 2. Authorize
await requireRateLimit(`api:${auth.orgId}`, api); // 3. Rate limit
const members = await listMembers(auth.orgId, { // 4. Org-scoped query
page, pageSize
});
return json({ // 5. Envelope response
data: { items: members, meta: { page, pageSize, total, totalPages } }
}, { headers: { ...corsHeaders(request), "X-Request-Id": getRequestId() } });
}
// Centralized typed error → HTTP response mapping
export function mapErrorToResponse(request, error) {
if (error instanceof NotFoundError) return fail(request, "NOT_FOUND", 404);
if (error instanceof ValidationError) return fail(request, "VALIDATION_FAILED", 422);
if (error instanceof ConflictError) return fail(request, "CONFLICT", 409);
if (error instanceof BillingStateError) return fail(request, "BAD_REQUEST", 400);
if (error instanceof LimitExceededError) return fail(request, "LIMIT_EXCEEDED", 403);
return null; // Unknown error — caller re-throws
}- OpenAPI 3.1 spec served at /api/v1/openapi.json
- Shared envelope: { data } for success, { error } with code, message, and requestId for failures
- CORS support with configurable allowed origins
- Pagination with page, pageSize, total, and totalPages metadata
- Per-endpoint rate limiting with Retry-After and X-RateLimit headers
- Typed errors (NotFoundError, ValidationError, ConflictError, etc.) map to HTTP codes via centralized handler
What you’d have to build yourself
Typical starter vs. SaaSCoreX
Most starters give you a login page and a Stripe button. Everything below the line is what you’d wire yourself.
| Capability | Typical Starter | SaaSCoreX |
|---|---|---|
| Auth (login, OAuth) | ||
| Stripe checkout button | ||
| Role enum | ||
| Two-factor auth (TOTP + backup codes) | — | |
| Typed RBAC (13 server-enforced actions) | — | |
| Multi-tenant org isolation | — | |
| Entitlement-driven billing enforcement | — | |
| Plan switching with Stripe proration | — | |
| Webhook idempotency state machine | — | |
| Stripe↔DB nightly reconciliation | — | |
| Structured audit logging (24 actions) | — | |
| Notification preferences (per-user) | — | |
| GDPR-style data export | — | |
| Three-level feature gating | — | |
| API key management (SHA-256 hashed) | — | |
| Background jobs (Inngest) | — | |
| 21-endpoint REST API with OpenAPI 3.1 | — | |
| Rate limiting (memory + Redis) | — | |
| E2E billing tests (Playwright) | — |
SaaSCoreX is designed to be the foundation you ship real revenue on.
Every system on this page is production code. One-time purchase. Full source access. No subscription.