@rune-kit/rune 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +357 -0
- package/agents/.gitkeep +0 -0
- package/agents/architect.md +29 -0
- package/agents/asset-creator.md +11 -0
- package/agents/audit.md +11 -0
- package/agents/autopsy.md +11 -0
- package/agents/brainstorm.md +11 -0
- package/agents/browser-pilot.md +11 -0
- package/agents/coder.md +29 -0
- package/agents/completion-gate.md +11 -0
- package/agents/constraint-check.md +11 -0
- package/agents/context-engine.md +11 -0
- package/agents/cook.md +11 -0
- package/agents/db.md +11 -0
- package/agents/debug.md +11 -0
- package/agents/dependency-doctor.md +11 -0
- package/agents/deploy.md +11 -0
- package/agents/design.md +11 -0
- package/agents/docs-seeker.md +11 -0
- package/agents/fix.md +11 -0
- package/agents/hallucination-guard.md +11 -0
- package/agents/incident.md +11 -0
- package/agents/integrity-check.md +11 -0
- package/agents/journal.md +11 -0
- package/agents/launch.md +11 -0
- package/agents/logic-guardian.md +11 -0
- package/agents/marketing.md +11 -0
- package/agents/onboard.md +11 -0
- package/agents/perf.md +11 -0
- package/agents/plan.md +11 -0
- package/agents/preflight.md +11 -0
- package/agents/problem-solver.md +11 -0
- package/agents/rescue.md +11 -0
- package/agents/research.md +11 -0
- package/agents/researcher.md +29 -0
- package/agents/review-intake.md +11 -0
- package/agents/review.md +11 -0
- package/agents/reviewer.md +28 -0
- package/agents/safeguard.md +11 -0
- package/agents/sast.md +11 -0
- package/agents/scanner.md +28 -0
- package/agents/scope-guard.md +11 -0
- package/agents/scout.md +11 -0
- package/agents/sentinel.md +11 -0
- package/agents/sequential-thinking.md +11 -0
- package/agents/session-bridge.md +11 -0
- package/agents/skill-forge.md +11 -0
- package/agents/skill-router.md +11 -0
- package/agents/surgeon.md +11 -0
- package/agents/team.md +11 -0
- package/agents/test.md +11 -0
- package/agents/trend-scout.md +11 -0
- package/agents/verification.md +11 -0
- package/agents/video-creator.md +11 -0
- package/agents/watchdog.md +11 -0
- package/agents/worktree.md +11 -0
- package/commands/.gitkeep +0 -0
- package/commands/rune.md +168 -0
- package/compiler/__tests__/openclaw-adapter.test.js +140 -0
- package/compiler/__tests__/parser.test.js +55 -0
- package/compiler/adapters/antigravity.js +59 -0
- package/compiler/adapters/claude.js +37 -0
- package/compiler/adapters/cursor.js +67 -0
- package/compiler/adapters/generic.js +60 -0
- package/compiler/adapters/index.js +45 -0
- package/compiler/adapters/openclaw.js +150 -0
- package/compiler/adapters/windsurf.js +60 -0
- package/compiler/bin/rune.js +288 -0
- package/compiler/doctor.js +153 -0
- package/compiler/emitter.js +240 -0
- package/compiler/parser.js +208 -0
- package/compiler/transformer.js +69 -0
- package/compiler/transforms/branding.js +27 -0
- package/compiler/transforms/cross-references.js +29 -0
- package/compiler/transforms/frontmatter.js +38 -0
- package/compiler/transforms/hooks.js +68 -0
- package/compiler/transforms/subagents.js +36 -0
- package/compiler/transforms/tool-names.js +60 -0
- package/contexts/dev.md +34 -0
- package/contexts/research.md +43 -0
- package/contexts/review.md +55 -0
- package/extensions/ai-ml/PACK.md +517 -0
- package/extensions/analytics/PACK.md +557 -0
- package/extensions/backend/PACK.md +678 -0
- package/extensions/chrome-ext/PACK.md +995 -0
- package/extensions/content/PACK.md +381 -0
- package/extensions/devops/PACK.md +520 -0
- package/extensions/ecommerce/PACK.md +280 -0
- package/extensions/gamedev/PACK.md +393 -0
- package/extensions/mobile/PACK.md +273 -0
- package/extensions/saas/PACK.md +805 -0
- package/extensions/security/PACK.md +536 -0
- package/extensions/trading/PACK.md +597 -0
- package/extensions/ui/PACK.md +947 -0
- package/package.json +47 -0
- package/skills/.gitkeep +0 -0
- package/skills/adversary/SKILL.md +271 -0
- package/skills/asset-creator/SKILL.md +157 -0
- package/skills/audit/SKILL.md +466 -0
- package/skills/autopsy/SKILL.md +200 -0
- package/skills/ba/SKILL.md +279 -0
- package/skills/brainstorm/SKILL.md +266 -0
- package/skills/browser-pilot/SKILL.md +168 -0
- package/skills/completion-gate/SKILL.md +151 -0
- package/skills/constraint-check/SKILL.md +165 -0
- package/skills/context-engine/SKILL.md +176 -0
- package/skills/cook/SKILL.md +636 -0
- package/skills/db/SKILL.md +256 -0
- package/skills/debug/SKILL.md +240 -0
- package/skills/dependency-doctor/SKILL.md +235 -0
- package/skills/deploy/SKILL.md +174 -0
- package/skills/design/DESIGN-REFERENCE.md +365 -0
- package/skills/design/SKILL.md +462 -0
- package/skills/doc-processor/SKILL.md +254 -0
- package/skills/docs/SKILL.md +336 -0
- package/skills/docs-seeker/SKILL.md +166 -0
- package/skills/fix/SKILL.md +192 -0
- package/skills/git/SKILL.md +285 -0
- package/skills/hallucination-guard/SKILL.md +204 -0
- package/skills/incident/SKILL.md +241 -0
- package/skills/integrity-check/SKILL.md +169 -0
- package/skills/journal/SKILL.md +190 -0
- package/skills/launch/SKILL.md +330 -0
- package/skills/logic-guardian/SKILL.md +240 -0
- package/skills/marketing/SKILL.md +229 -0
- package/skills/mcp-builder/SKILL.md +311 -0
- package/skills/onboard/SKILL.md +298 -0
- package/skills/perf/SKILL.md +297 -0
- package/skills/plan/SKILL.md +520 -0
- package/skills/preflight/SKILL.md +231 -0
- package/skills/problem-solver/SKILL.md +284 -0
- package/skills/rescue/SKILL.md +434 -0
- package/skills/research/SKILL.md +122 -0
- package/skills/review/SKILL.md +354 -0
- package/skills/review-intake/SKILL.md +222 -0
- package/skills/safeguard/SKILL.md +188 -0
- package/skills/sast/SKILL.md +190 -0
- package/skills/scaffold/SKILL.md +276 -0
- package/skills/scope-guard/SKILL.md +150 -0
- package/skills/scout/SKILL.md +232 -0
- package/skills/sentinel/SKILL.md +320 -0
- package/skills/sentinel-env/SKILL.md +226 -0
- package/skills/sequential-thinking/SKILL.md +234 -0
- package/skills/session-bridge/SKILL.md +287 -0
- package/skills/skill-forge/SKILL.md +317 -0
- package/skills/skill-router/SKILL.md +267 -0
- package/skills/surgeon/SKILL.md +203 -0
- package/skills/team/SKILL.md +397 -0
- package/skills/test/SKILL.md +271 -0
- package/skills/trend-scout/SKILL.md +145 -0
- package/skills/verification/SKILL.md +201 -0
- package/skills/video-creator/SKILL.md +201 -0
- package/skills/watchdog/SKILL.md +166 -0
- package/skills/worktree/SKILL.md +140 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "@rune/saas"
|
|
3
|
+
description: SaaS patterns — multi-tenancy, billing integration, subscription management, feature flags, team permissions, and user onboarding flows.
|
|
4
|
+
metadata:
|
|
5
|
+
author: runedev
|
|
6
|
+
version: "0.2.0"
|
|
7
|
+
layer: L4
|
|
8
|
+
price: "$12"
|
|
9
|
+
target: SaaS builders
|
|
10
|
+
tools:
|
|
11
|
+
- Read
|
|
12
|
+
- Grep
|
|
13
|
+
- Edit
|
|
14
|
+
- Bash
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# @rune/saas
|
|
18
|
+
|
|
19
|
+
## Purpose
|
|
20
|
+
|
|
21
|
+
SaaS applications share a common set of hard problems that most teams solve from scratch: tenant isolation that leaks data, billing webhooks that silently fail, subscription state that drifts from the payment provider, feature flags with no cleanup discipline, permission systems that escalate silently, and onboarding funnels that drop users before activation. This pack codifies production-tested patterns for each — detect the current architecture, audit for common SaaS pitfalls, and emit the correct implementation. These six skills are interdependent: tenant isolation shapes the billing model, billing drives feature gating, feature flags control gradual rollout, team permissions determine what each role can access, and gating plus permissions together determine the onboarding flow.
|
|
22
|
+
|
|
23
|
+
## Triggers
|
|
24
|
+
|
|
25
|
+
- Auto-trigger: when `tenant`, `subscription`, `billing`, `stripe`, `paddle`, `lemonsqueezy`, `plan`, `pricing`, `featureFlag`, `rbac`, `permission`, `onboarding` patterns detected in codebase
|
|
26
|
+
- `/rune multi-tenant` — audit or implement tenant isolation
|
|
27
|
+
- `/rune billing-integration` — set up or audit billing provider integration
|
|
28
|
+
- `/rune subscription-flow` — build subscription management UI
|
|
29
|
+
- `/rune feature-flags` — implement feature flag system
|
|
30
|
+
- `/rune team-management` — build org/team RBAC and invite flows
|
|
31
|
+
- `/rune onboarding-flow` — build or audit user onboarding
|
|
32
|
+
- Called by `cook` (L1) when SaaS project patterns detected
|
|
33
|
+
|
|
34
|
+
## Skills Included
|
|
35
|
+
|
|
36
|
+
### multi-tenant
|
|
37
|
+
|
|
38
|
+
Multi-tenancy patterns — database isolation strategies, tenant context middleware, data partitioning, cross-tenant query prevention, tenant-aware background jobs, and GDPR data export.
|
|
39
|
+
|
|
40
|
+
#### Isolation Strategy Comparison
|
|
41
|
+
|
|
42
|
+
| Strategy | Cost | Isolation | Migration Difficulty | When to Use |
|
|
43
|
+
|---|---|---|---|---|
|
|
44
|
+
| Shared DB, tenant column | Low | Weak (app-enforced) | Easy | Early-stage, <1000 tenants |
|
|
45
|
+
| Shared DB + PostgreSQL RLS | Low | Strong (DB-enforced) | Easy | Best default for most SaaS |
|
|
46
|
+
| Schema-per-tenant | Medium | Strong | Medium | When tenants need schema customization |
|
|
47
|
+
| DB-per-tenant | High | Perfect | Hard | Enterprise, compliance (HIPAA, SOC2) |
|
|
48
|
+
|
|
49
|
+
#### Workflow
|
|
50
|
+
|
|
51
|
+
**Step 1 — Detect current isolation strategy**
|
|
52
|
+
Use Grep to find tenant-related code: `tenantId`, `organizationId`, `workspaceId`, `x-tenant-id` header, RLS policies, schema-per-tenant patterns, database switching logic. Read the database schema and middleware to classify the isolation strategy in use.
|
|
53
|
+
|
|
54
|
+
**Step 2 — Audit isolation boundaries**
|
|
55
|
+
Check for: queries without tenant filter (data leak risk), missing tenant context in middleware, no RLS policies on shared tables, admin endpoints that bypass tenant isolation, background jobs processing cross-tenant data without scoping. Flag each with severity.
|
|
56
|
+
|
|
57
|
+
**Step 3 — Emit tenant-safe patterns**
|
|
58
|
+
Based on detected strategy, emit: tenant middleware (extract from JWT/header, set on request context), RLS policies for shared-schema approach, scoped repository pattern that injects tenant filter on every query, and tenant-aware test fixtures.
|
|
59
|
+
|
|
60
|
+
**Step 4 — Tenant-aware background jobs**
|
|
61
|
+
Every background job MUST carry `tenantId`. Use BullMQ job data to pass tenant context, then initialize a scoped repository inside the job processor. Never process tenant data in a job without an explicit `tenantId` guard.
|
|
62
|
+
|
|
63
|
+
**Step 5 — Tenant data export (GDPR portability)**
|
|
64
|
+
Implement `/api/tenants/:id/export` that collects all data rows belonging to a tenant across all tables, serializes to JSON or CSV, and streams the result as a download. Log the export event in the audit trail with timestamp and requesting user.
|
|
65
|
+
|
|
66
|
+
#### Example
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// Tenant middleware — extract from JWT, inject into request context
|
|
70
|
+
const tenantMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
|
71
|
+
const tenantId = req.user?.tenantId ?? req.headers['x-tenant-id'] as string;
|
|
72
|
+
if (!tenantId) return res.status(403).json({ error: { code: 'TENANT_REQUIRED', message: 'Tenant context missing' } });
|
|
73
|
+
req.tenantId = tenantId;
|
|
74
|
+
next();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Scoped repository — every query automatically filtered by tenant
|
|
78
|
+
class ScopedRepository<T extends { tenantId: string }> {
|
|
79
|
+
constructor(private model: PrismaModel<T>, private tenantId: string) {}
|
|
80
|
+
|
|
81
|
+
async findMany(where: Partial<Omit<T, 'tenantId'>> = {}) {
|
|
82
|
+
return this.model.findMany({ where: { ...where, tenantId: this.tenantId } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async create(data: Omit<T, 'tenantId' | 'id' | 'createdAt' | 'updatedAt'>) {
|
|
86
|
+
return this.model.create({ data: { ...data, tenantId: this.tenantId } as any });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// PostgreSQL RLS — DB-enforced isolation, safest approach
|
|
91
|
+
-- Enable RLS on every shared table
|
|
92
|
+
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
|
93
|
+
|
|
94
|
+
-- Set tenant context before query (from app middleware)
|
|
95
|
+
SET LOCAL app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
|
|
96
|
+
|
|
97
|
+
-- Policy reads from session variable — automatic for all queries
|
|
98
|
+
CREATE POLICY tenant_isolation ON projects
|
|
99
|
+
USING (tenant_id = current_setting('app.tenant_id')::uuid);
|
|
100
|
+
|
|
101
|
+
-- Set in Prisma $executeRaw before each query block:
|
|
102
|
+
-- await prisma.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
|
|
103
|
+
|
|
104
|
+
// BullMQ — tenant-aware background job
|
|
105
|
+
const emailQueue = new Queue('emails');
|
|
106
|
+
|
|
107
|
+
// Producer: always pass tenantId in job data
|
|
108
|
+
await emailQueue.add('send-invoice', { tenantId, invoiceId, recipientEmail });
|
|
109
|
+
|
|
110
|
+
// Consumer: initialize scoped context from job data
|
|
111
|
+
const worker = new Worker('emails', async (job) => {
|
|
112
|
+
const { tenantId, invoiceId } = job.data;
|
|
113
|
+
const invoices = new ScopedRepository(prisma.invoice, tenantId);
|
|
114
|
+
const invoice = await invoices.findMany({ id: invoiceId });
|
|
115
|
+
// process...
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// GDPR export — stream all tenant data
|
|
119
|
+
app.get('/api/tenants/:id/export', requireOwner, async (req, res) => {
|
|
120
|
+
const { id: tenantId } = req.params;
|
|
121
|
+
const [projects, members, invoices] = await Promise.all([
|
|
122
|
+
prisma.project.findMany({ where: { tenantId } }),
|
|
123
|
+
prisma.member.findMany({ where: { tenantId } }),
|
|
124
|
+
prisma.invoice.findMany({ where: { tenantId } }),
|
|
125
|
+
]);
|
|
126
|
+
await prisma.auditLog.create({ data: { tenantId, action: 'DATA_EXPORT', actorId: req.user.id } });
|
|
127
|
+
res.setHeader('Content-Disposition', `attachment; filename="export-${tenantId}.json"`);
|
|
128
|
+
res.json({ exportedAt: new Date(), projects, members, invoices });
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### billing-integration
|
|
135
|
+
|
|
136
|
+
Billing integration — Stripe and LemonSqueezy (Stripe alternative for Vietnam/non-US sellers). Subscription lifecycle, webhook handling, usage-based billing, dunning management, and tax handling.
|
|
137
|
+
|
|
138
|
+
> **Vietnam note**: Stripe requires a US/EU entity and is unavailable for direct signup from Vietnam. LemonSqueezy acts as Merchant of Record — handles VAT, tax compliance, and payouts globally. Prefer LemonSqueezy for solo founders and small teams in Vietnam/Southeast Asia.
|
|
139
|
+
|
|
140
|
+
#### Workflow
|
|
141
|
+
|
|
142
|
+
**Step 1 — Detect billing provider**
|
|
143
|
+
Use Grep to find billing code: `stripe`, `lemonsqueezy`, `@stripe/stripe-js`, webhook endpoints (`/webhook`, `/billing/webhook`), subscription models. Read payment configuration and webhook handlers.
|
|
144
|
+
|
|
145
|
+
**Step 2 — Audit webhook reliability**
|
|
146
|
+
Check for: missing webhook signature verification, no idempotency handling, missing event types (subscription deleted, payment failed, invoice paid), no dead-letter queue for failed webhook processing, subscription state stored only in payment provider (no local sync).
|
|
147
|
+
|
|
148
|
+
**Step 3 — Emit robust billing integration**
|
|
149
|
+
Emit: webhook handler with signature verification, idempotent event processing (store processed event IDs), subscription state sync (local DB mirrors provider state).
|
|
150
|
+
|
|
151
|
+
**Step 4 — Usage-based billing (metered)**
|
|
152
|
+
For products where billing scales with usage (API calls, seats, storage): create a Stripe Meter, report usage records incrementally using `stripe.billing.meterEvents.create`, and handle overage pricing in the subscription's price tiers. Display current-period usage in the billing portal. For LemonSqueezy, use quantity-based subscriptions with a per-unit price and update quantity on usage checkpoints.
|
|
153
|
+
|
|
154
|
+
**Step 5 — Dunning management flow**
|
|
155
|
+
When `invoice.payment_failed` fires: Day 0 — notify customer, retry in 3 days. Day 3 — retry + second email. Day 7 — retry + urgent email + in-app warning banner. Day 14 — suspend account (read-only mode), email with payment link. Day 21 — cancel subscription, archive data with 30-day recovery window. Never hard-delete on cancellation.
|
|
156
|
+
|
|
157
|
+
#### Example
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// Stripe webhook — verified, idempotent, full lifecycle
|
|
161
|
+
import Stripe from 'stripe';
|
|
162
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
163
|
+
|
|
164
|
+
app.post('/billing/webhook/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
165
|
+
const sig = req.headers['stripe-signature']!;
|
|
166
|
+
let event: Stripe.Event;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
|
|
170
|
+
} catch {
|
|
171
|
+
return res.status(400).json({ error: 'Invalid signature' });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const processed = await db.webhookEvent.findUnique({ where: { eventId: event.id } });
|
|
175
|
+
if (processed) return res.json({ received: true, skipped: true });
|
|
176
|
+
|
|
177
|
+
switch (event.type) {
|
|
178
|
+
case 'customer.subscription.created':
|
|
179
|
+
case 'customer.subscription.updated':
|
|
180
|
+
await syncSubscription(event.data.object as Stripe.Subscription); break;
|
|
181
|
+
case 'customer.subscription.deleted':
|
|
182
|
+
await cancelSubscription(event.data.object as Stripe.Subscription); break;
|
|
183
|
+
case 'invoice.payment_failed':
|
|
184
|
+
await startDunningFlow(event.data.object as Stripe.Invoice); break;
|
|
185
|
+
case 'invoice.payment_succeeded':
|
|
186
|
+
await clearDunningState((event.data.object as Stripe.Invoice).customer as string); break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await db.webhookEvent.create({ data: { eventId: event.id, type: event.type, processedAt: new Date() } });
|
|
190
|
+
res.json({ received: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// LemonSqueezy webhook — alternative for Vietnam-based sellers
|
|
194
|
+
import crypto from 'crypto';
|
|
195
|
+
|
|
196
|
+
app.post('/billing/webhook/lemonsqueezy', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
197
|
+
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET!;
|
|
198
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
199
|
+
const digest = Buffer.from(hmac.update(req.body).digest('hex'), 'utf8');
|
|
200
|
+
const signature = Buffer.from(req.headers['x-signature'] as string ?? '', 'utf8');
|
|
201
|
+
|
|
202
|
+
if (!crypto.timingSafeEqual(digest, signature)) {
|
|
203
|
+
return res.status(400).json({ error: 'Invalid signature' });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const payload = JSON.parse(req.body.toString());
|
|
207
|
+
const eventName: string = payload.meta.event_name;
|
|
208
|
+
|
|
209
|
+
switch (eventName) {
|
|
210
|
+
case 'subscription_created':
|
|
211
|
+
case 'subscription_updated':
|
|
212
|
+
await syncLSSubscription(payload.data); break;
|
|
213
|
+
case 'subscription_cancelled':
|
|
214
|
+
await cancelLSSubscription(payload.data); break;
|
|
215
|
+
case 'subscription_payment_failed':
|
|
216
|
+
await startDunningFlow({ customerId: payload.data.attributes.customer_id }); break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
res.json({ received: true });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Usage-based billing — report metered usage to Stripe
|
|
223
|
+
const reportUsage = async (tenantId: string, quantity: number) => {
|
|
224
|
+
const subscription = await db.subscription.findUnique({ where: { tenantId } });
|
|
225
|
+
await stripe.billing.meterEvents.create({
|
|
226
|
+
event_name: 'api_call',
|
|
227
|
+
payload: { stripe_customer_id: subscription!.stripeCustomerId, value: String(quantity) },
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Dunning state machine
|
|
232
|
+
const startDunningFlow = async ({ customer }: { customer?: string | null; customerId?: string }) => {
|
|
233
|
+
const tenantId = await getTenantByCustomer(customer ?? '');
|
|
234
|
+
await db.tenant.update({ where: { id: tenantId }, data: { dunningStartedAt: new Date(), status: 'PAYMENT_FAILED' } });
|
|
235
|
+
await emailQueue.add('dunning-day0', { tenantId }, { delay: 0 });
|
|
236
|
+
await emailQueue.add('dunning-day3', { tenantId }, { delay: 3 * 24 * 60 * 60 * 1000 });
|
|
237
|
+
await emailQueue.add('dunning-day7', { tenantId }, { delay: 7 * 24 * 60 * 60 * 1000 });
|
|
238
|
+
await emailQueue.add('dunning-suspend', { tenantId }, { delay: 14 * 24 * 60 * 60 * 1000 });
|
|
239
|
+
await emailQueue.add('dunning-cancel', { tenantId }, { delay: 21 * 24 * 60 * 60 * 1000 });
|
|
240
|
+
};
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Tax handling:**
|
|
244
|
+
- **Stripe Tax** — enable in Stripe dashboard, set `automatic_tax: { enabled: true }` on checkout sessions. Handles US state tax, EU VAT automatically.
|
|
245
|
+
- **Paddle** — acts as Merchant of Record (same as LemonSqueezy), handles all tax obligations. Good alternative if LemonSqueezy doesn't support your use case.
|
|
246
|
+
- **EU VAT** — if selling direct (not through MoR): collect VAT registration number, validate via VIES API, apply reverse charge for B2B EU transactions.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
### subscription-flow
|
|
251
|
+
|
|
252
|
+
Subscription UI flows — pricing page, checkout, plan upgrades/downgrades, plan migration, annual/monthly toggle with proration preview, coupon codes, lifetime deal support, and cancellation with retention.
|
|
253
|
+
|
|
254
|
+
#### Workflow
|
|
255
|
+
|
|
256
|
+
**Step 1 — Detect subscription model**
|
|
257
|
+
Use Grep to find plan/tier definitions, feature flags, trial logic, checkout components. Read pricing config to understand: plan tiers, billing intervals, trial duration, feature gates, and upgrade/downgrade rules.
|
|
258
|
+
|
|
259
|
+
**Step 2 — Audit subscription UX**
|
|
260
|
+
Check for: pricing page without annual toggle, checkout without error recovery, no trial-to-paid conversion flow, plan change without proration explanation, cancellation without retention offer, missing feature gates on protected API routes.
|
|
261
|
+
|
|
262
|
+
**Step 3 — Emit subscription patterns**
|
|
263
|
+
Emit: type-safe plan configuration, feature gate middleware/hook, checkout flow with error handling, plan change with proration preview, cancellation flow with feedback collection, and trial expiry handling.
|
|
264
|
+
|
|
265
|
+
**Step 4 — Plan migration on downgrade**
|
|
266
|
+
When a user downgrades to a lower plan that has stricter limits (e.g., Pro 50 projects → Free 3 projects): DO NOT hard-delete over-limit data. Three options: (a) **Read-only grace period** — over-limit items become read-only for 30 days, user prompted to delete or upgrade; (b) **Hard limit** — block new item creation when at limit, existing items preserved; (c) **Grace period + export** — email user with export link, mark items for deletion after 60 days. Default recommendation: option (a) for good UX.
|
|
267
|
+
|
|
268
|
+
**Step 5 — Annual/monthly toggle + proration + coupons + lifetime deals**
|
|
269
|
+
Show annual price with savings badge ("Save 20%"). On plan change, call Stripe's proration preview endpoint and display "You'll be charged $X today" before confirming. For coupon codes: validate via `stripe.promotionCodes.list`, display discount amount/percentage and expiry. For lifetime deals (AppSumo, LemonSqueezy): create a one-time payment product, on `order_created` webhook set `subscription.plan = 'lifetime'` with `expiresAt = null` — lifetime access never expires.
|
|
270
|
+
|
|
271
|
+
#### Example
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
// Type-safe plan configuration + feature gating
|
|
275
|
+
const PLANS = {
|
|
276
|
+
free: { price: 0, limits: { projects: 3, members: 1, storage: '100MB' }, features: ['basic_analytics'] },
|
|
277
|
+
pro: { price: 29, limits: { projects: 50, members: 10, storage: '10GB' }, features: ['basic_analytics', 'advanced_analytics', 'api_access', 'priority_support'] },
|
|
278
|
+
team: { price: 79, limits: { projects: -1, members: -1, storage: '100GB' }, features: ['basic_analytics', 'advanced_analytics', 'api_access', 'priority_support', 'sso', 'audit_log'] },
|
|
279
|
+
lifetime: { price: 199, limits: { projects: -1, members: 25, storage: '50GB' }, features: ['basic_analytics', 'advanced_analytics', 'api_access', 'priority_support'] },
|
|
280
|
+
} as const;
|
|
281
|
+
|
|
282
|
+
type PlanId = keyof typeof PLANS;
|
|
283
|
+
type Feature = typeof PLANS[PlanId]['features'][number];
|
|
284
|
+
|
|
285
|
+
function useFeatureGate(feature: Feature): { allowed: boolean; upgradeRequired: PlanId | null } {
|
|
286
|
+
const { plan } = useSubscription();
|
|
287
|
+
const allowed = (PLANS[plan].features as readonly string[]).includes(feature);
|
|
288
|
+
if (allowed) return { allowed: true, upgradeRequired: null };
|
|
289
|
+
const requiredPlan = (Object.entries(PLANS) as [PlanId, typeof PLANS[PlanId]][])
|
|
290
|
+
.find(([_, p]) => (p.features as readonly string[]).includes(feature));
|
|
291
|
+
return { allowed: false, upgradeRequired: requiredPlan?.[0] ?? null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Proration preview before plan change
|
|
295
|
+
const getProrationPreview = async (tenantId: string, newPriceId: string): Promise<number> => {
|
|
296
|
+
const sub = await db.subscription.findUnique({ where: { tenantId } });
|
|
297
|
+
const preview = await stripe.invoices.retrieveUpcoming({
|
|
298
|
+
customer: sub!.stripeCustomerId,
|
|
299
|
+
subscription: sub!.stripeSubscriptionId,
|
|
300
|
+
subscription_items: [{ id: sub!.stripeItemId, price: newPriceId }],
|
|
301
|
+
subscription_proration_behavior: 'create_prorations',
|
|
302
|
+
});
|
|
303
|
+
return preview.amount_due / 100; // dollars
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Coupon validation
|
|
307
|
+
const validateCoupon = async (code: string) => {
|
|
308
|
+
const promos = await stripe.promotionCodes.list({ code, active: true, limit: 1 });
|
|
309
|
+
if (!promos.data.length) throw new Error('Invalid or expired coupon');
|
|
310
|
+
const promo = promos.data[0];
|
|
311
|
+
const coupon = promo.coupon;
|
|
312
|
+
return {
|
|
313
|
+
id: promo.id,
|
|
314
|
+
discount: coupon.percent_off ? `${coupon.percent_off}% off` : `$${(coupon.amount_off! / 100).toFixed(2)} off`,
|
|
315
|
+
duration: coupon.duration,
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Lifetime deal — LemonSqueezy one-time payment webhook
|
|
320
|
+
app.post('/billing/webhook/lemonsqueezy', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
321
|
+
// ...signature check...
|
|
322
|
+
const payload = JSON.parse(req.body.toString());
|
|
323
|
+
if (payload.meta.event_name === 'order_created') {
|
|
324
|
+
const email = payload.data.attributes.user_email;
|
|
325
|
+
const user = await db.user.findUnique({ where: { email } });
|
|
326
|
+
if (user) {
|
|
327
|
+
await db.subscription.upsert({
|
|
328
|
+
where: { userId: user.id },
|
|
329
|
+
update: { plan: 'lifetime', expiresAt: null },
|
|
330
|
+
create: { userId: user.id, plan: 'lifetime', expiresAt: null },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
res.json({ received: true });
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
### feature-flags
|
|
341
|
+
|
|
342
|
+
Feature flag management — gradual rollouts, kill switches, A/B testing, user-segment targeting, and stale flag cleanup. Supports self-hosted (Unleash, custom Redis) and managed (LaunchDarkly, Statsig, Flagsmith).
|
|
343
|
+
|
|
344
|
+
#### Flag Types
|
|
345
|
+
|
|
346
|
+
| Type | Use Case | Example |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| Boolean | Simple on/off for a feature | `new_dashboard_ui` |
|
|
349
|
+
| Percentage rollout | Gradual release 1% → 100% | `redesigned_editor: 25%` |
|
|
350
|
+
| User segment | Specific users/orgs first | `beta_users`, `enterprise_plan` |
|
|
351
|
+
| A/B test | Compare variants with metrics | `checkout_flow: variant_a / variant_b` |
|
|
352
|
+
| Kill switch | Instant disable on failure | `payment_processor_v2` |
|
|
353
|
+
| Environment | Dev/staging/prod separation | Auto by `NODE_ENV` |
|
|
354
|
+
|
|
355
|
+
#### Rollout Pattern: Canary → Gradual → GA
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
1% (internal + beta users) → 10% → 25% → 50% → 100% → cleanup flag after 30 days at 100%
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
#### Workflow
|
|
362
|
+
|
|
363
|
+
**Step 1 — Identify feature boundary**
|
|
364
|
+
Before writing code, define the flag: name (kebab-case, descriptive), default value (false = safe default), targeting rules (who sees it first), and planned cleanup date. Document in your flag provider dashboard.
|
|
365
|
+
|
|
366
|
+
**Step 2 — Create flag with targeting rules**
|
|
367
|
+
In Unleash/LaunchDarkly/Flagsmith: create flag with gradual rollout strategy. Start at 0%. Add a "beta users" segment for internal testing before any percentage rollout. Set environment-specific defaults: always-on in dev, gradual in staging, starts at 0% in prod.
|
|
368
|
+
|
|
369
|
+
**Step 3 — Implement client/server evaluation**
|
|
370
|
+
Client: evaluate flag in a React hook, never inline. Server: evaluate in middleware or at request start, attach result to request context. Never evaluate flags inside hot loops — cache the result for the request lifetime.
|
|
371
|
+
|
|
372
|
+
**Step 4 — Add analytics event tracking**
|
|
373
|
+
Every flag evaluation on a user-facing feature should fire an analytics event: `feature_flag_evaluated` with `{ flag, variant, userId, tenantId }`. This enables funnel analysis by variant and measures the rollout's impact on key metrics.
|
|
374
|
+
|
|
375
|
+
**Step 5 — Schedule flag cleanup**
|
|
376
|
+
Flags that have been at 100% for >30 days are stale. Run a weekly lint job: grep all flag keys used in code, compare against provider's flag list, flag mismatches (code uses a flag that was deleted → runtime error, or flag exists but never referenced → cleanup candidate). Remove stale flags from both code and provider in the same PR.
|
|
377
|
+
|
|
378
|
+
#### Example
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
// Custom Redis-based flag evaluation (self-hosted, zero SaaS dependency)
|
|
382
|
+
import { Redis } from 'ioredis';
|
|
383
|
+
const redis = new Redis(process.env.REDIS_URL!);
|
|
384
|
+
|
|
385
|
+
interface FlagConfig {
|
|
386
|
+
enabled: boolean;
|
|
387
|
+
percentage?: number; // 0-100 for gradual rollout
|
|
388
|
+
allowedUsers?: string[]; // canary user IDs
|
|
389
|
+
allowedPlans?: string[]; // plan-based targeting
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const evaluateFlag = async (
|
|
393
|
+
flagKey: string,
|
|
394
|
+
ctx: { userId: string; tenantId: string; plan: string }
|
|
395
|
+
): Promise<boolean> => {
|
|
396
|
+
const raw = await redis.get(`flag:${flagKey}`);
|
|
397
|
+
if (!raw) return false; // default off = safe
|
|
398
|
+
const config: FlagConfig = JSON.parse(raw);
|
|
399
|
+
if (!config.enabled) return false;
|
|
400
|
+
if (config.allowedUsers?.includes(ctx.userId)) return true;
|
|
401
|
+
if (config.allowedPlans?.includes(ctx.plan)) return true;
|
|
402
|
+
if (config.percentage !== undefined) {
|
|
403
|
+
// Deterministic: same user always gets same bucket
|
|
404
|
+
const hash = parseInt(ctx.userId.slice(-8), 16) % 100;
|
|
405
|
+
return hash < config.percentage;
|
|
406
|
+
}
|
|
407
|
+
return config.enabled;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// React hook — evaluate once per render cycle, never in loops
|
|
411
|
+
function useFlag(flagKey: string): boolean {
|
|
412
|
+
const { user } = useAuth();
|
|
413
|
+
const { data: enabled = false } = useQuery({
|
|
414
|
+
queryKey: ['flag', flagKey, user?.id],
|
|
415
|
+
queryFn: () => fetchFlag(flagKey),
|
|
416
|
+
staleTime: 30_000, // cache 30s — flags don't change every millisecond
|
|
417
|
+
});
|
|
418
|
+
return enabled;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Server middleware — evaluate at request boundary, attach to context
|
|
422
|
+
const flagMiddleware = (flagKey: string) => async (req: Request, res: Response, next: NextFunction) => {
|
|
423
|
+
req.flags = req.flags ?? {};
|
|
424
|
+
req.flags[flagKey] = await evaluateFlag(flagKey, {
|
|
425
|
+
userId: req.user!.id,
|
|
426
|
+
tenantId: req.tenantId!,
|
|
427
|
+
plan: req.user!.plan,
|
|
428
|
+
});
|
|
429
|
+
next();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// Usage in route — flag already evaluated, no async needed
|
|
433
|
+
app.get('/api/checkout', flagMiddleware('new_checkout_v2'), (req, res) => {
|
|
434
|
+
if (req.flags['new_checkout_v2']) {
|
|
435
|
+
return checkoutV2Handler(req, res);
|
|
436
|
+
}
|
|
437
|
+
return checkoutV1Handler(req, res);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Stale flag detection — run weekly in CI
|
|
441
|
+
import { execSync } from 'child_process';
|
|
442
|
+
|
|
443
|
+
const findStaleFlags = async () => {
|
|
444
|
+
const flagsInCode = execSync('grep -r "useFlag\\|evaluateFlag" src/ --include="*.ts" -h')
|
|
445
|
+
.toString()
|
|
446
|
+
.match(/(?:useFlag|evaluateFlag)\(['"]([^'"]+)['"]/g)
|
|
447
|
+
?.map(m => m.match(/['"]([^'"]+)['"]/)?.[1])
|
|
448
|
+
.filter(Boolean) ?? [];
|
|
449
|
+
|
|
450
|
+
const flagsInProvider = await redis.keys('flag:*').then(keys => keys.map(k => k.replace('flag:', '')));
|
|
451
|
+
const stale = flagsInProvider.filter(f => !flagsInCode.includes(f));
|
|
452
|
+
const missing = flagsInCode.filter(f => !flagsInProvider.includes(f));
|
|
453
|
+
return { stale, missing };
|
|
454
|
+
};
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Sharp edges for flags:**
|
|
458
|
+
- Never evaluate flags on hot paths (e.g., inside `Array.map` over 1000 items) — cache the flag state at the top of the function.
|
|
459
|
+
- In tests: mock flag evaluation at the provider level, not by conditionally skipping flag checks. Every code path should be testable with flags on and off.
|
|
460
|
+
- Flag dependency chains (flag A enables flag B) — avoid. If you need compound logic, evaluate both flags independently and combine in application code. Provider-level dependencies are invisible in code review.
|
|
461
|
+
- Percentage rollout is not the same as A/B test — percentage rollout has no control group. For A/B tests, always keep a 50/50 split or a defined control group.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
### team-management
|
|
466
|
+
|
|
467
|
+
Organization, team, and member permissions — RBAC hierarchy, invite flow with expiry, permission checking at API and UI layers, and audit trail for permission changes.
|
|
468
|
+
|
|
469
|
+
#### Role Hierarchy
|
|
470
|
+
|
|
471
|
+
```
|
|
472
|
+
Owner (1 per org)
|
|
473
|
+
└── Admin (multiple)
|
|
474
|
+
└── Member (default role)
|
|
475
|
+
└── Viewer (read-only)
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Org-level roles apply across all teams. Team-level roles can be more restrictive (e.g., org Member can be team Admin for a specific team).
|
|
479
|
+
|
|
480
|
+
#### Permission Matrix
|
|
481
|
+
|
|
482
|
+
| Action | Owner | Admin | Member | Viewer |
|
|
483
|
+
|---|---|---|---|---|
|
|
484
|
+
| Delete organization | ✅ | ❌ | ❌ | ❌ |
|
|
485
|
+
| Manage billing | ✅ | ✅ | ❌ | ❌ |
|
|
486
|
+
| Invite members | ✅ | ✅ | ❌ | ❌ |
|
|
487
|
+
| Create teams | ✅ | ✅ | ❌ | ❌ |
|
|
488
|
+
| Create projects | ✅ | ✅ | ✅ | ❌ |
|
|
489
|
+
| View projects | ✅ | ✅ | ✅ | ✅ |
|
|
490
|
+
| Manage team members | ✅ | ✅ (own teams) | ❌ | ❌ |
|
|
491
|
+
|
|
492
|
+
#### Workflow
|
|
493
|
+
|
|
494
|
+
**Step 1 — Design org/team schema**
|
|
495
|
+
Model: `Organization → Team → Membership (userId, orgId, teamId?, role)`. Org-level membership has `teamId = null`. Team-level membership scopes the role to a specific team. Use a single `Membership` table with nullable `teamId` rather than separate `OrgMember` and `TeamMember` tables.
|
|
496
|
+
|
|
497
|
+
**Step 2 — Implement RBAC middleware**
|
|
498
|
+
Create a `requirePermission(action)` middleware that reads `req.user.id` + `req.tenantId`, loads the user's role for that org, and checks against a permission map. Fail fast: return 403 immediately if permission not found. Never trust client-provided role claims.
|
|
499
|
+
|
|
500
|
+
**Step 3 — Build invite flow**
|
|
501
|
+
Invite: generate a signed token (`crypto.randomBytes(32).hex`), store with `{ email, orgId, role, invitedBy, expiresAt: +7d }`, send email with link. Accept: verify token not expired, not already accepted, create Membership record, mark invite as accepted. Resend: invalidate old token, create new one with fresh expiry. Pending invites visible to admins in settings.
|
|
502
|
+
|
|
503
|
+
**Step 4 — Add permission UI gates**
|
|
504
|
+
In React: `<CanAccess action="invite_members"><InviteButton /></CanAccess>` — hides UI elements the user can't use. Also disable + tooltip pattern: show the button but disable it with "Upgrade to invite members" tooltip (better UX than hiding, helps users understand what's possible). Enforce the same check in the API — UI gates are cosmetic only.
|
|
505
|
+
|
|
506
|
+
**Step 5 — Emit audit trail**
|
|
507
|
+
Every permission change, role assignment, invite, and removal MUST log to an `AuditLog` table: `{ orgId, actorId, targetId, action, before, after, ip, userAgent, timestamp }`. Surface the last 100 entries in the org settings Security tab. Retain for 90 days minimum (compliance requirement for SOC2).
|
|
508
|
+
|
|
509
|
+
#### Example
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
// Prisma schema — org, team, membership
|
|
513
|
+
model Organization {
|
|
514
|
+
id String @id @default(cuid())
|
|
515
|
+
name String
|
|
516
|
+
slug String @unique
|
|
517
|
+
members Membership[]
|
|
518
|
+
teams Team[]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
model Team {
|
|
522
|
+
id String @id @default(cuid())
|
|
523
|
+
orgId String
|
|
524
|
+
name String
|
|
525
|
+
org Organization @relation(fields: [orgId], references: [id])
|
|
526
|
+
members Membership[]
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
model Membership {
|
|
530
|
+
id String @id @default(cuid())
|
|
531
|
+
userId String
|
|
532
|
+
orgId String
|
|
533
|
+
teamId String? // null = org-level role
|
|
534
|
+
role Role
|
|
535
|
+
user User @relation(fields: [userId], references: [id])
|
|
536
|
+
org Organization @relation(fields: [orgId], references: [id])
|
|
537
|
+
team Team? @relation(fields: [teamId], references: [id])
|
|
538
|
+
|
|
539
|
+
@@unique([userId, orgId, teamId]) // one role per user per scope
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
enum Role { OWNER ADMIN MEMBER VIEWER }
|
|
543
|
+
|
|
544
|
+
// Permission map
|
|
545
|
+
const PERMISSIONS = {
|
|
546
|
+
delete_org: ['OWNER'],
|
|
547
|
+
manage_billing: ['OWNER', 'ADMIN'],
|
|
548
|
+
invite_members: ['OWNER', 'ADMIN'],
|
|
549
|
+
create_projects: ['OWNER', 'ADMIN', 'MEMBER'],
|
|
550
|
+
view_projects: ['OWNER', 'ADMIN', 'MEMBER', 'VIEWER'],
|
|
551
|
+
} as const;
|
|
552
|
+
type Action = keyof typeof PERMISSIONS;
|
|
553
|
+
|
|
554
|
+
// RBAC middleware — never trust client-provided role
|
|
555
|
+
const requirePermission = (action: Action) => async (req: Request, res: Response, next: NextFunction) => {
|
|
556
|
+
const membership = await prisma.membership.findFirst({
|
|
557
|
+
where: { userId: req.user!.id, orgId: req.tenantId!, teamId: null },
|
|
558
|
+
});
|
|
559
|
+
if (!membership || !(PERMISSIONS[action] as readonly string[]).includes(membership.role)) {
|
|
560
|
+
return res.status(403).json({ error: { code: 'FORBIDDEN', action } });
|
|
561
|
+
}
|
|
562
|
+
req.userRole = membership.role;
|
|
563
|
+
next();
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// React permission hook
|
|
567
|
+
function usePermission(action: Action): boolean {
|
|
568
|
+
const { membership } = useOrg();
|
|
569
|
+
if (!membership) return false;
|
|
570
|
+
return (PERMISSIONS[action] as readonly string[]).includes(membership.role);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Invite flow
|
|
574
|
+
const createInvite = async (orgId: string, email: string, role: Role, invitedBy: string) => {
|
|
575
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
576
|
+
await prisma.invite.create({
|
|
577
|
+
data: { orgId, email, role, invitedBy, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
|
|
578
|
+
});
|
|
579
|
+
await emailQueue.add('invite', { email, token, orgId });
|
|
580
|
+
return token;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const acceptInvite = async (token: string, userId: string) => {
|
|
584
|
+
const invite = await prisma.invite.findUnique({ where: { token } });
|
|
585
|
+
if (!invite || invite.acceptedAt || invite.expiresAt < new Date()) {
|
|
586
|
+
throw new Error('Invalid or expired invite');
|
|
587
|
+
}
|
|
588
|
+
await prisma.$transaction([
|
|
589
|
+
prisma.membership.create({ data: { userId, orgId: invite.orgId, role: invite.role } }),
|
|
590
|
+
prisma.invite.update({ where: { token }, data: { acceptedAt: new Date() } }),
|
|
591
|
+
prisma.auditLog.create({ data: { orgId: invite.orgId, actorId: userId, action: 'MEMBER_JOINED', targetId: userId } }),
|
|
592
|
+
]);
|
|
593
|
+
};
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Sharp edges for team-management:**
|
|
597
|
+
- **Permission escalation**: an Admin inviting another Admin is fine, but an Admin promoting themselves to Owner must be blocked. Rule: you can only assign roles lower than your own.
|
|
598
|
+
- **Cross-org data leak**: when loading team resources, always filter by `orgId`. A user who belongs to two orgs must never see org B's data when acting in org A's context.
|
|
599
|
+
- **Invite token reuse**: after an invite is accepted, mark it accepted immediately in the same transaction as membership creation. Race condition: two tabs accepting the same invite → use `@@unique` on membership + catch unique constraint error.
|
|
600
|
+
- **Owner removal**: prevent the last Owner from being removed or downgraded. Always require at least one Owner per org. Check before processing the role change.
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
### onboarding-flow
|
|
605
|
+
|
|
606
|
+
User onboarding patterns — progressive disclosure, setup wizards, product tours, activation metrics (AARRR), empty states, re-engagement, and invite flows.
|
|
607
|
+
|
|
608
|
+
#### Workflow
|
|
609
|
+
|
|
610
|
+
**Step 1 — Detect onboarding state**
|
|
611
|
+
Use Grep to find onboarding code: `onboarding`, `setup`, `wizard`, `tour`, `welcome`, `getting-started`, `empty-state`, `invite`. Read the signup/post-registration flow to understand what happens after account creation.
|
|
612
|
+
|
|
613
|
+
**Step 2 — Audit activation funnel**
|
|
614
|
+
Check for: signup → empty dashboard (no guidance), missing setup wizard for critical config, no progress indicator during multi-step setup, empty states without action prompts, invite flow that doesn't pre-populate team context, no activation metric tracking.
|
|
615
|
+
|
|
616
|
+
**Step 3 — Emit onboarding patterns**
|
|
617
|
+
Emit: multi-step setup wizard with progress persistence (resume on reload), context-aware empty states with primary action, team invite flow with role selection, activation checklist component, and analytics event tracking for funnel steps.
|
|
618
|
+
|
|
619
|
+
**Step 4 — Activation metric framework (AARRR)**
|
|
620
|
+
Define your "Aha moment" — the single action that correlates with long-term retention. Common patterns: "created first project + invited one teammate" (Slack), "connected data source" (analytics tools), "ran first workflow" (automation tools). Instrument this event explicitly: `analytics.track('activation_achieved', { userId, tenantId, daysFromSignup })`. Track activation rate weekly. If <40% of signups activate in 7 days, the onboarding is broken.
|
|
621
|
+
|
|
622
|
+
**Step 5 — Re-engagement for dormant users**
|
|
623
|
+
Detect dormant: user signed up but never achieved activation, OR activated user with no activity in 14 days. Trigger: Day 3 after signup with no activation → in-app banner + email tip. Day 7 → personalized email with "here's what you haven't tried yet". Day 14 → offer a guided setup call or live demo. Track re-engagement conversion rate separately from organic activation.
|
|
624
|
+
|
|
625
|
+
#### Example
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
// Onboarding wizard with progress persistence + analytics
|
|
629
|
+
const ONBOARDING_STEPS = ['profile', 'workspace', 'invite_team', 'first_project'] as const;
|
|
630
|
+
type Step = typeof ONBOARDING_STEPS[number];
|
|
631
|
+
|
|
632
|
+
function useOnboarding() {
|
|
633
|
+
const [progress, setProgress] = useLocalStorage<Record<Step, boolean>>('onboarding', {
|
|
634
|
+
profile: false, workspace: false, invite_team: false, first_project: false,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const currentStep = ONBOARDING_STEPS.find(step => !progress[step]) ?? null;
|
|
638
|
+
const complete = (step: Step) => {
|
|
639
|
+
setProgress(prev => ({ ...prev, [step]: true }));
|
|
640
|
+
analytics.track('onboarding_step_complete', { step, totalSteps: ONBOARDING_STEPS.length });
|
|
641
|
+
};
|
|
642
|
+
const isComplete = currentStep === null;
|
|
643
|
+
const percentComplete = (Object.values(progress).filter(Boolean).length / ONBOARDING_STEPS.length) * 100;
|
|
644
|
+
return { currentStep, complete, isComplete, percentComplete, progress };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Empty state library — 5 common SaaS empty states
|
|
648
|
+
const EMPTY_STATES = {
|
|
649
|
+
no_projects: {
|
|
650
|
+
icon: 'FolderIcon',
|
|
651
|
+
title: 'No projects yet',
|
|
652
|
+
description: 'Create your first project to get started.',
|
|
653
|
+
cta: { label: 'Create Project', href: '/projects/new' },
|
|
654
|
+
},
|
|
655
|
+
no_team_members: {
|
|
656
|
+
icon: 'UsersIcon',
|
|
657
|
+
title: 'You\'re working alone',
|
|
658
|
+
description: 'Invite your team to collaborate.',
|
|
659
|
+
cta: { label: 'Invite Teammates', href: '/settings/members' },
|
|
660
|
+
},
|
|
661
|
+
no_data: {
|
|
662
|
+
icon: 'ChartIcon',
|
|
663
|
+
title: 'No data yet',
|
|
664
|
+
description: 'Connect your first data source to see analytics.',
|
|
665
|
+
cta: { label: 'Connect Source', href: '/integrations' },
|
|
666
|
+
},
|
|
667
|
+
no_integrations: {
|
|
668
|
+
icon: 'PlugIcon',
|
|
669
|
+
title: 'No integrations connected',
|
|
670
|
+
description: 'Connect your tools to unlock automation.',
|
|
671
|
+
cta: { label: 'Browse Integrations', href: '/integrations' },
|
|
672
|
+
},
|
|
673
|
+
no_billing: {
|
|
674
|
+
icon: 'CreditCardIcon',
|
|
675
|
+
title: 'No payment method',
|
|
676
|
+
description: 'Add a payment method to unlock Pro features.',
|
|
677
|
+
cta: { label: 'Add Payment Method', href: '/settings/billing' },
|
|
678
|
+
},
|
|
679
|
+
} as const;
|
|
680
|
+
|
|
681
|
+
// Product tour — step-by-step spotlight with dismiss/snooze
|
|
682
|
+
interface TourStep { target: string; title: string; description: string; position: 'top' | 'bottom' | 'left' | 'right'; }
|
|
683
|
+
|
|
684
|
+
function useProductTour(tourId: string, steps: TourStep[]) {
|
|
685
|
+
const [state, setState] = useLocalStorage<{ completed: boolean; dismissed: boolean; step: number }>(`tour:${tourId}`, {
|
|
686
|
+
completed: false, dismissed: false, step: 0,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const advance = () => {
|
|
690
|
+
if (state.step + 1 >= steps.length) {
|
|
691
|
+
setState(s => ({ ...s, completed: true }));
|
|
692
|
+
analytics.track('product_tour_completed', { tourId });
|
|
693
|
+
} else {
|
|
694
|
+
setState(s => ({ ...s, step: s.step + 1 }));
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const dismiss = (snoozeMinutes?: number) => {
|
|
699
|
+
if (snoozeMinutes) {
|
|
700
|
+
const snoozeUntil = Date.now() + snoozeMinutes * 60_000;
|
|
701
|
+
setState(s => ({ ...s, dismissed: true }));
|
|
702
|
+
localStorage.setItem(`tour:${tourId}:snooze`, String(snoozeUntil));
|
|
703
|
+
} else {
|
|
704
|
+
setState(s => ({ ...s, dismissed: true }));
|
|
705
|
+
analytics.track('product_tour_dismissed', { tourId, atStep: state.step });
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const isSnoozed = () => {
|
|
710
|
+
const snoozeUntil = Number(localStorage.getItem(`tour:${tourId}:snooze`) ?? 0);
|
|
711
|
+
return Date.now() < snoozeUntil;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const active = !state.completed && !state.dismissed && !isSnoozed();
|
|
715
|
+
return { active, currentStep: steps[state.step], stepIndex: state.step, advance, dismiss };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Re-engagement detection — server-side cron
|
|
719
|
+
const detectDormantUsers = async () => {
|
|
720
|
+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
721
|
+
const dormant = await prisma.user.findMany({
|
|
722
|
+
where: {
|
|
723
|
+
createdAt: { lt: sevenDaysAgo },
|
|
724
|
+
activatedAt: null, // never completed activation
|
|
725
|
+
lastReEngagementEmailAt: null,
|
|
726
|
+
},
|
|
727
|
+
take: 500,
|
|
728
|
+
});
|
|
729
|
+
for (const user of dormant) {
|
|
730
|
+
await emailQueue.add('re-engagement', { userId: user.id });
|
|
731
|
+
await prisma.user.update({ where: { id: user.id }, data: { lastReEngagementEmailAt: new Date() } });
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
---
|
|
737
|
+
|
|
738
|
+
## Connections
|
|
739
|
+
|
|
740
|
+
```
|
|
741
|
+
Calls → sentinel (L2): security audit on billing, tenant isolation, and RBAC
|
|
742
|
+
Calls → docs-seeker (L3): lookup billing provider API documentation
|
|
743
|
+
Calls → git (L3): emit semantic commits for schema migrations and billing changes
|
|
744
|
+
Called By ← cook (L1): when SaaS project patterns detected
|
|
745
|
+
Called By ← review (L2): when subscription/billing/RBAC code under review
|
|
746
|
+
Called By ← audit (L2): SaaS architecture health dimension
|
|
747
|
+
Called By ← ba (L2): translating business requirements into SaaS implementation patterns
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
## Tech Stack Support
|
|
751
|
+
|
|
752
|
+
| Billing Provider | SDK | Webhook Verification | Vietnam/Global |
|
|
753
|
+
|---|---|---|---|
|
|
754
|
+
| Stripe | stripe-node v17+ | Built-in `constructEvent` | Requires US/EU entity |
|
|
755
|
+
| LemonSqueezy | @lemonsqueezy/lemonsqueezy.js | HMAC SHA256 header | ✅ Works globally, Merchant of Record |
|
|
756
|
+
| Paddle | @paddle/paddle-node-sdk | Paddle webhook SDK | ✅ Works globally, Merchant of Record |
|
|
757
|
+
|
|
758
|
+
| Feature Flag Provider | Self-hosted | Managed | Best For |
|
|
759
|
+
|---|---|---|---|
|
|
760
|
+
| Custom Redis | ✅ Free | — | Simple boolean + percentage flags |
|
|
761
|
+
| Unleash | ✅ Open source | ✅ Cloud | Full-featured, self-hosted option |
|
|
762
|
+
| Flagsmith | ✅ Open source | ✅ Cloud | Open source with good React SDK |
|
|
763
|
+
| LaunchDarkly | ❌ | ✅ Paid | Enterprise, advanced targeting |
|
|
764
|
+
| Statsig | ❌ | ✅ Freemium | A/B testing + analytics |
|
|
765
|
+
|
|
766
|
+
## Constraints
|
|
767
|
+
|
|
768
|
+
1. MUST verify webhook signatures — never process unverified billing events.
|
|
769
|
+
2. MUST store subscription state locally — never rely solely on the payment provider API for access control decisions.
|
|
770
|
+
3. MUST scope every database query to tenant context — unscoped queries are data leak vulnerabilities.
|
|
771
|
+
4. MUST handle billing edge cases: failed payments, disputed charges, plan downgrades with active usage over new limits.
|
|
772
|
+
5. MUST NOT expose billing provider customer IDs or internal subscription IDs to end users.
|
|
773
|
+
6. MUST enforce RBAC at the API layer — client-side permission gates are cosmetic only.
|
|
774
|
+
7. MUST default feature flags to `false` — opt-in is safer than opt-out.
|
|
775
|
+
8. MUST clean up stale feature flags within 30 days of full rollout.
|
|
776
|
+
|
|
777
|
+
## Sharp Edges
|
|
778
|
+
|
|
779
|
+
| Failure Mode | Severity | Mitigation |
|
|
780
|
+
|---|---|---|
|
|
781
|
+
| Webhook processes same event twice causing duplicate charges or state corruption | CRITICAL | Idempotency check: store processed event IDs, skip duplicates |
|
|
782
|
+
| Tenant isolation bypassed in admin or reporting queries | CRITICAL | Audit ALL query paths including admin, cron jobs, and reporting; use RLS as safety net |
|
|
783
|
+
| Admin promotes themselves to Owner (permission escalation) | CRITICAL | Rule: you can only assign roles ≤ your own; enforce server-side |
|
|
784
|
+
| Feature flag evaluated on every iteration inside a hot loop | HIGH | Evaluate flag once before the loop, pass as parameter; cache with 30s stale time |
|
|
785
|
+
| Plan downgrade hard-deletes data created under higher plan | HIGH | Implement read-only grace period (30 days) — never delete on downgrade |
|
|
786
|
+
| Trial expiry races with checkout completion | HIGH | Use billing provider's trial management; sync state from webhook, not from timer |
|
|
787
|
+
| Invite token reused by two concurrent requests → duplicate memberships | HIGH | Unique constraint on `(userId, orgId, teamId)`; catch constraint error gracefully |
|
|
788
|
+
| Onboarding wizard loses progress on page refresh | MEDIUM | Persist wizard state to localStorage or backend; resume from last incomplete step |
|
|
789
|
+
| Feature gate checked client-side only (bypassed via API) | HIGH | Enforce feature gates in API middleware, not just UI components |
|
|
790
|
+
| Last org Owner removed (org locked out) | HIGH | Block role change that would leave org with zero Owners |
|
|
791
|
+
| Stale feature flags accumulate (>50 flags, no cleanup) | MEDIUM | Weekly CI job: detect flags in code not in provider and vice versa |
|
|
792
|
+
|
|
793
|
+
## Done When
|
|
794
|
+
|
|
795
|
+
- Tenant isolation audited: every query scoped, RLS or middleware enforced, background jobs carry tenantId, GDPR export endpoint implemented
|
|
796
|
+
- Billing webhooks verified, idempotent, and handling all lifecycle events including dunning flow
|
|
797
|
+
- Subscription flow has pricing page, checkout, upgrade, downgrade, proration preview, coupon codes, cancellation, and lifetime deal support
|
|
798
|
+
- Feature flags implemented with evaluation caching, stale flag detection, and test mocking
|
|
799
|
+
- Team RBAC implemented with invite flow, permission middleware, and audit trail
|
|
800
|
+
- Onboarding wizard has progress persistence, empty states, product tour, activation metric tracking, and re-engagement detection
|
|
801
|
+
- Structured report emitted for each skill invoked
|
|
802
|
+
|
|
803
|
+
## Cost Profile
|
|
804
|
+
|
|
805
|
+
~12,000–22,000 tokens per full pack run (all 6 skills). Individual skill: ~2,000–4,000 tokens. Sonnet default for code generation and security patterns. Use haiku for pattern detection scans (Steps 1–2 of each skill); escalate to sonnet for code generation and security audit; escalate to opus for architectural decisions (isolation strategy selection, RBAC schema design).
|