thevoidforge 21.0.11 → 21.0.13
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/dist/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/wizard/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Revenue Source Adapter (Read-Only)
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Read-only interface — VoidForge never processes payments directly
|
|
6
|
+
* - Separate from AdPlatformAdapter — revenue in, ad spend out
|
|
7
|
+
* - Polling with overlapping windows for gap-free coverage (ADR-5)
|
|
8
|
+
* - Dedup by externalId to prevent double-counting
|
|
9
|
+
* - Webhook signature verification mandatory when webhooks are implemented
|
|
10
|
+
* - USD-only enforcement (ADR-6) at connection time
|
|
11
|
+
*
|
|
12
|
+
* Agents: Dockson (treasury), Vin (analytics)
|
|
13
|
+
*
|
|
14
|
+
* PRD Reference: §9.4, §9.9, §9.17 (polling improvements), ADR-5/ADR-6
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Cents = number & { readonly __brand: 'Cents' };
|
|
18
|
+
type RevenueSource = 'stripe' | 'paddle';
|
|
19
|
+
|
|
20
|
+
// ── Revenue Source Interface ──────────────────────────
|
|
21
|
+
|
|
22
|
+
interface RevenueSourceAdapter {
|
|
23
|
+
/** Establish connection and verify credentials work */
|
|
24
|
+
connect(credentials: RevenueCredentials): Promise<ConnectionResult>;
|
|
25
|
+
|
|
26
|
+
/** Detect the account's currency for ADR-6 enforcement */
|
|
27
|
+
detectCurrency(credentials: RevenueCredentials): Promise<string>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetch transactions in a date range.
|
|
31
|
+
* Uses overlapping windows: fetch from (lastPollTime - 5 minutes) to now.
|
|
32
|
+
* Dedup by externalId at the caller.
|
|
33
|
+
*/
|
|
34
|
+
getTransactions(range: DateRange, cursor?: string): Promise<TransactionPage>;
|
|
35
|
+
|
|
36
|
+
/** Get current account balance (optional — not all sources support this) */
|
|
37
|
+
getBalance?(): Promise<BalanceResult>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify webhook signature (mandatory when webhooks are implemented).
|
|
41
|
+
* Deferred to remote mode per ADR-5 — not called in v11.x polling mode.
|
|
42
|
+
*/
|
|
43
|
+
verifyWebhookSignature?(payload: Buffer, signature: string, secret: string): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Types ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
interface RevenueCredentials {
|
|
49
|
+
source: RevenueSource;
|
|
50
|
+
apiKey?: string; // Stripe, Paddle (restricted, read-only)
|
|
51
|
+
accessToken?: string; // OAuth token (for future sources)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ConnectionResult {
|
|
55
|
+
connected: boolean;
|
|
56
|
+
accountId?: string;
|
|
57
|
+
accountName?: string;
|
|
58
|
+
currency?: string; // ISO 4217
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DateRange {
|
|
63
|
+
start: string; // ISO 8601
|
|
64
|
+
end: string; // ISO 8601
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TransactionPage {
|
|
68
|
+
transactions: RevenueTransaction[];
|
|
69
|
+
hasMore: boolean;
|
|
70
|
+
cursor?: string; // For pagination — store in daemon state for crash recovery
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RevenueTransaction {
|
|
74
|
+
externalId: string; // Platform's transaction/event ID — dedup key
|
|
75
|
+
type: 'charge' | 'subscription' | 'refund' | 'dispute';
|
|
76
|
+
amount: Cents; // Integer cents. Negative for refunds/disputes.
|
|
77
|
+
currency: 'USD'; // Validated at ingest per ADR-6
|
|
78
|
+
description: string;
|
|
79
|
+
customerId?: string; // Hashed — never store raw email/name
|
|
80
|
+
subscriptionId?: string; // For future MRR calculation (deferred to v11.2)
|
|
81
|
+
metadata: Record<string, string>;
|
|
82
|
+
createdAt: string; // ISO 8601
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface BalanceResult {
|
|
86
|
+
available: Cents;
|
|
87
|
+
pending: Cents;
|
|
88
|
+
currency: 'USD';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── API Response Shapes (for type-safe access on apiCall results) ─────
|
|
92
|
+
|
|
93
|
+
/** Shape of Stripe /account response fields we access */
|
|
94
|
+
interface StripeAccountResponse {
|
|
95
|
+
id: string;
|
|
96
|
+
business_profile?: { name?: string };
|
|
97
|
+
email?: string;
|
|
98
|
+
default_currency?: string;
|
|
99
|
+
[key: string]: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Shape of Stripe /events list response */
|
|
103
|
+
interface StripeEventsResponse {
|
|
104
|
+
data: Array<Record<string, unknown>>;
|
|
105
|
+
has_more: boolean;
|
|
106
|
+
[key: string]: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Shape of Stripe /balance response */
|
|
110
|
+
interface StripeBalanceResponse {
|
|
111
|
+
available: Array<{ currency: string; amount: number }>;
|
|
112
|
+
pending: Array<{ currency: string; amount: number }>;
|
|
113
|
+
[key: string]: unknown;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Shape of Paddle /businesses response */
|
|
117
|
+
interface PaddleBusinessesResponse {
|
|
118
|
+
data: Array<{ id: string; name: string; currency_code: string }>;
|
|
119
|
+
[key: string]: unknown;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Shape of Paddle /transactions response */
|
|
123
|
+
interface PaddleTransactionsResponse {
|
|
124
|
+
data: Array<Record<string, unknown>>;
|
|
125
|
+
meta?: { pagination?: { next?: string } };
|
|
126
|
+
[key: string]: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Reference Implementation: Stripe ──────────────────
|
|
130
|
+
|
|
131
|
+
class StripeAdapter implements RevenueSourceAdapter {
|
|
132
|
+
private apiKey: string = '';
|
|
133
|
+
private readonly baseUrl = 'https://api.stripe.com/v1';
|
|
134
|
+
|
|
135
|
+
async connect(credentials: RevenueCredentials): Promise<ConnectionResult> {
|
|
136
|
+
this.apiKey = credentials.apiKey || '';
|
|
137
|
+
try {
|
|
138
|
+
const account = await this.apiCall('GET', '/account') as StripeAccountResponse;
|
|
139
|
+
return {
|
|
140
|
+
connected: true,
|
|
141
|
+
accountId: account.id,
|
|
142
|
+
accountName: account.business_profile?.name || account.email,
|
|
143
|
+
currency: account.default_currency?.toUpperCase(),
|
|
144
|
+
};
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { connected: false, error: (err as Error).message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async detectCurrency(credentials: RevenueCredentials): Promise<string> {
|
|
151
|
+
const result = await this.connect(credentials);
|
|
152
|
+
return result.currency || 'USD';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getTransactions(range: DateRange, cursor?: string): Promise<TransactionPage> {
|
|
156
|
+
// Use Stripe Events API for sequential, immutable event log (§9.18)
|
|
157
|
+
const params: Record<string, string> = {
|
|
158
|
+
type: 'charge.succeeded',
|
|
159
|
+
'created[gte]': String(Math.floor(new Date(range.start).getTime() / 1000)),
|
|
160
|
+
'created[lte]': String(Math.floor(new Date(range.end).getTime() / 1000)),
|
|
161
|
+
limit: '100',
|
|
162
|
+
};
|
|
163
|
+
if (cursor) params.starting_after = cursor;
|
|
164
|
+
|
|
165
|
+
const data = await this.apiCall('GET', '/events', params) as StripeEventsResponse;
|
|
166
|
+
const transactions: RevenueTransaction[] = data.data.map((event: Record<string, unknown>) => {
|
|
167
|
+
const charge = (event.data as Record<string, unknown>)?.object as Record<string, unknown>;
|
|
168
|
+
return {
|
|
169
|
+
externalId: event.id as string,
|
|
170
|
+
type: 'charge' as const,
|
|
171
|
+
amount: (charge?.amount as number || 0) as Cents, // Stripe amounts are already in cents
|
|
172
|
+
currency: 'USD' as const,
|
|
173
|
+
description: (charge?.description as string) || '',
|
|
174
|
+
customerId: charge?.customer ? hashCustomerId(charge.customer as string) : undefined,
|
|
175
|
+
subscriptionId: charge?.subscription as string | undefined,
|
|
176
|
+
metadata: (charge?.metadata as Record<string, string>) || {},
|
|
177
|
+
createdAt: new Date((event.created as number) * 1000).toISOString(),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
transactions,
|
|
183
|
+
hasMore: data.has_more as boolean,
|
|
184
|
+
cursor: data.data.length > 0 ? data.data[data.data.length - 1].id as string : undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async getBalance(): Promise<BalanceResult> {
|
|
189
|
+
const data = await this.apiCall('GET', '/balance') as StripeBalanceResponse;
|
|
190
|
+
const usd = data.available.find((b) => b.currency === 'usd');
|
|
191
|
+
const pending = data.pending.find((b) => b.currency === 'usd');
|
|
192
|
+
return {
|
|
193
|
+
available: (usd?.amount || 0) as Cents,
|
|
194
|
+
pending: (pending?.amount || 0) as Cents,
|
|
195
|
+
currency: 'USD',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
verifyWebhookSignature(payload: Buffer, signature: string, secret: string): boolean {
|
|
200
|
+
// Stripe uses HMAC-SHA256 for webhook signatures
|
|
201
|
+
const { createHmac } = require('node:crypto');
|
|
202
|
+
const parts = signature.split(',').reduce((acc: Record<string, string>, part: string) => {
|
|
203
|
+
const [key, value] = part.split('=');
|
|
204
|
+
acc[key] = value;
|
|
205
|
+
return acc;
|
|
206
|
+
}, {});
|
|
207
|
+
|
|
208
|
+
const timestamp = parts['t'];
|
|
209
|
+
const expected = parts['v1'];
|
|
210
|
+
if (!timestamp || !expected) return false;
|
|
211
|
+
|
|
212
|
+
const signed = createHmac('sha256', secret)
|
|
213
|
+
.update(`${timestamp}.${payload.toString()}`)
|
|
214
|
+
.digest('hex');
|
|
215
|
+
|
|
216
|
+
// VG-006: Use timing-safe comparison to prevent timing attacks on signature
|
|
217
|
+
const { timingSafeEqual } = require('node:crypto');
|
|
218
|
+
if (signed.length !== expected.length) return false;
|
|
219
|
+
return timingSafeEqual(Buffer.from(signed), Buffer.from(expected));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async apiCall(method: string, path: string, params?: Record<string, string>): Promise<Record<string, unknown>> {
|
|
223
|
+
// Implementation: raw HTTPS (no Stripe SDK — zero dependency principle)
|
|
224
|
+
// Headers: Authorization: Bearer {apiKey}, Content-Type: application/x-www-form-urlencoded
|
|
225
|
+
// Sanitize response strings per §9.19.16 before returning
|
|
226
|
+
throw new Error('HTTP implementation — use node:https, no SDK dependencies');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Paddle Adapter ────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
class PaddleAdapter implements RevenueSourceAdapter {
|
|
233
|
+
private apiKey: string = '';
|
|
234
|
+
private readonly baseUrl = 'https://api.paddle.com';
|
|
235
|
+
|
|
236
|
+
async connect(credentials: RevenueCredentials): Promise<ConnectionResult> {
|
|
237
|
+
this.apiKey = credentials.apiKey || '';
|
|
238
|
+
try {
|
|
239
|
+
const data = await this.apiCall('GET', '/businesses') as PaddleBusinessesResponse;
|
|
240
|
+
const biz = data.data?.[0];
|
|
241
|
+
return {
|
|
242
|
+
connected: true,
|
|
243
|
+
accountId: biz?.id,
|
|
244
|
+
accountName: biz?.name,
|
|
245
|
+
currency: biz?.currency_code,
|
|
246
|
+
};
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return { connected: false, error: (err as Error).message };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async detectCurrency(credentials: RevenueCredentials): Promise<string> {
|
|
253
|
+
const result = await this.connect(credentials);
|
|
254
|
+
return result.currency || 'USD';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getTransactions(range: DateRange, cursor?: string): Promise<TransactionPage> {
|
|
258
|
+
const params: Record<string, string> = {
|
|
259
|
+
'created_at[gte]': range.start,
|
|
260
|
+
'created_at[lte]': range.end,
|
|
261
|
+
per_page: '100',
|
|
262
|
+
};
|
|
263
|
+
if (cursor) params.after = cursor;
|
|
264
|
+
|
|
265
|
+
const data = await this.apiCall('GET', '/transactions', params) as PaddleTransactionsResponse;
|
|
266
|
+
const transactions: RevenueTransaction[] = data.data.map((txn: Record<string, unknown>) => {
|
|
267
|
+
const details = txn.details as Record<string, unknown> | undefined;
|
|
268
|
+
const totals = details?.totals as Record<string, unknown> | undefined;
|
|
269
|
+
const items = txn.items as Array<Record<string, unknown>> | undefined;
|
|
270
|
+
const firstItemPrice = items?.[0]?.price as Record<string, unknown> | undefined;
|
|
271
|
+
return {
|
|
272
|
+
externalId: txn.id as string,
|
|
273
|
+
type: mapPaddleStatus(txn.status as string),
|
|
274
|
+
amount: Math.round(parseFloat(totals?.total as string || '0') * 100) as Cents,
|
|
275
|
+
currency: 'USD' as const,
|
|
276
|
+
description: (firstItemPrice?.description as string) || '',
|
|
277
|
+
customerId: txn.customer_id ? hashCustomerId(txn.customer_id as string) : undefined,
|
|
278
|
+
subscriptionId: txn.subscription_id as string | undefined,
|
|
279
|
+
metadata: (txn.custom_data as Record<string, string>) || {},
|
|
280
|
+
createdAt: txn.created_at as string,
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
transactions,
|
|
286
|
+
hasMore: !!data.meta?.pagination?.next,
|
|
287
|
+
cursor: data.meta?.pagination?.next,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async apiCall(method: string, path: string, params?: Record<string, string>): Promise<Record<string, unknown>> {
|
|
292
|
+
throw new Error('HTTP implementation — use node:https, no SDK dependencies');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Helpers ───────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
function hashCustomerId(customerId: string): string {
|
|
299
|
+
const { createHash } = require('node:crypto');
|
|
300
|
+
return createHash('sha256').update(customerId).digest('hex').substring(0, 16);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function mapPaddleStatus(status: string): RevenueTransaction['type'] {
|
|
304
|
+
if (status === 'completed' || status === 'paid') return 'charge';
|
|
305
|
+
if (status === 'refunded' || status === 'partially_refunded') return 'refund';
|
|
306
|
+
if (status === 'disputed') return 'dispute';
|
|
307
|
+
return 'charge';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type { RevenueSourceAdapter, RevenueCredentials, ConnectionResult, TransactionPage, RevenueTransaction, BalanceResult, DateRange };
|
|
311
|
+
export { StripeAdapter, PaddleAdapter, hashCustomerId };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Service Layer
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - All business logic lives in services, NOT in route handlers
|
|
6
|
+
* - Services are stateless and composable
|
|
7
|
+
* - Services throw typed errors (ApiError), routes catch and format
|
|
8
|
+
* - Database access is scoped — services enforce ownership/tenancy
|
|
9
|
+
* - No HTTP concepts in services (no req/res, no status codes)
|
|
10
|
+
*
|
|
11
|
+
* Agents: Strange (service architecture), Banner (database), Barton (errors)
|
|
12
|
+
*
|
|
13
|
+
* Framework adaptations:
|
|
14
|
+
* Next.js/Node: This file (Prisma ORM, const object exports)
|
|
15
|
+
* Express: Same pattern — services in /lib/, imported by route handlers
|
|
16
|
+
* Django: Service functions in app/services.py, called by views, raise custom exceptions
|
|
17
|
+
* Rails: Service objects in app/services/, called by controllers, raise custom errors
|
|
18
|
+
*
|
|
19
|
+
* === Django Deep Dive ===
|
|
20
|
+
*
|
|
21
|
+
* # app/services/project_service.py
|
|
22
|
+
* from django.db import transaction
|
|
23
|
+
* from .models import Project
|
|
24
|
+
* from .exceptions import NotFoundError, ForbiddenError
|
|
25
|
+
*
|
|
26
|
+
* class ProjectService:
|
|
27
|
+
* @staticmethod
|
|
28
|
+
* def create(user, name, description=None):
|
|
29
|
+
* return Project.objects.create(owner=user, name=name, description=description)
|
|
30
|
+
*
|
|
31
|
+
* @staticmethod
|
|
32
|
+
* def get(user, project_id):
|
|
33
|
+
* try:
|
|
34
|
+
* project = Project.objects.get(id=project_id)
|
|
35
|
+
* except Project.DoesNotExist:
|
|
36
|
+
* raise NotFoundError("Project not found")
|
|
37
|
+
* if project.owner_id != user.id:
|
|
38
|
+
* raise NotFoundError("Project not found") # 404 not 403 — no IDOR
|
|
39
|
+
* return project
|
|
40
|
+
*
|
|
41
|
+
* @staticmethod
|
|
42
|
+
* @transaction.atomic
|
|
43
|
+
* def delete(user, project_id):
|
|
44
|
+
* project = ProjectService.get(user, project_id)
|
|
45
|
+
* project.delete()
|
|
46
|
+
*
|
|
47
|
+
* # Key principles (same as TypeScript):
|
|
48
|
+
* # - Business logic in services, not views
|
|
49
|
+
* # - Ownership checks on every user-scoped query (return 404, not 403)
|
|
50
|
+
* # - Use QuerySet methods (filter, get, select_related) — never raw SQL
|
|
51
|
+
* # - Wrap multi-step mutations in @transaction.atomic
|
|
52
|
+
* # - Raise domain exceptions (NotFoundError, ForbiddenError) — views map to HTTP
|
|
53
|
+
*
|
|
54
|
+
* === FastAPI Deep Dive ===
|
|
55
|
+
*
|
|
56
|
+
* # app/services/project_service.py
|
|
57
|
+
* from sqlalchemy.ext.asyncio import AsyncSession
|
|
58
|
+
* from sqlalchemy import select
|
|
59
|
+
* from .models import Project
|
|
60
|
+
* from .exceptions import NotFoundError
|
|
61
|
+
*
|
|
62
|
+
* class ProjectService:
|
|
63
|
+
* def __init__(self, db: AsyncSession):
|
|
64
|
+
* self.db = db
|
|
65
|
+
*
|
|
66
|
+
* async def create(self, user, name, description=None):
|
|
67
|
+
* project = Project(owner_id=user.id, name=name, description=description)
|
|
68
|
+
* self.db.add(project)
|
|
69
|
+
* await self.db.commit()
|
|
70
|
+
* await self.db.refresh(project)
|
|
71
|
+
* return project
|
|
72
|
+
*
|
|
73
|
+
* async def get(self, user, project_id):
|
|
74
|
+
* result = await self.db.execute(
|
|
75
|
+
* select(Project).where(Project.id == project_id, Project.owner_id == user.id)
|
|
76
|
+
* )
|
|
77
|
+
* project = result.scalar_one_or_none()
|
|
78
|
+
* if not project:
|
|
79
|
+
* raise NotFoundError("Project not found")
|
|
80
|
+
* return project
|
|
81
|
+
*
|
|
82
|
+
* # Key principles:
|
|
83
|
+
* # - Services receive db session via dependency injection (Depends)
|
|
84
|
+
* # - Async by default — all DB operations use await
|
|
85
|
+
* # - Repository pattern: service wraps SQLAlchemy queries
|
|
86
|
+
* # - Same ownership enforcement: filter by owner_id in query, not after fetch
|
|
87
|
+
*
|
|
88
|
+
* See /docs/patterns/error-handling.ts for the canonical error strategy.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
import { db } from '@/lib/db'
|
|
92
|
+
import { ApiError } from '@/lib/errors'
|
|
93
|
+
|
|
94
|
+
// --- Types co-located with the service ---
|
|
95
|
+
interface CreateProjectInput {
|
|
96
|
+
name: string
|
|
97
|
+
description?: string
|
|
98
|
+
ownerId: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ListProjectsInput {
|
|
102
|
+
ownerId: string
|
|
103
|
+
page: number
|
|
104
|
+
limit: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface PaginatedResult<T> {
|
|
108
|
+
items: T[]
|
|
109
|
+
page: number
|
|
110
|
+
limit: number
|
|
111
|
+
total: number
|
|
112
|
+
hasMore: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- Service object (no class needed for most cases) ---
|
|
116
|
+
export const projectService = {
|
|
117
|
+
async create(input: CreateProjectInput) {
|
|
118
|
+
// Business rules enforced here, not in the route
|
|
119
|
+
const existingCount = await db.project.count({
|
|
120
|
+
where: { ownerId: input.ownerId },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Tier enforcement (Ahsoka — access control)
|
|
124
|
+
const MAX_PROJECTS_FREE = 5
|
|
125
|
+
if (existingCount >= MAX_PROJECTS_FREE) {
|
|
126
|
+
throw new ApiError(
|
|
127
|
+
'PROJECT_LIMIT_REACHED',
|
|
128
|
+
'Upgrade to create more projects',
|
|
129
|
+
403
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ALWAYS use `select` on mutations — raw Prisma results include ALL columns,
|
|
134
|
+
// silently leaking sensitive fields. See /docs/patterns/api-route.ts for the rule.
|
|
135
|
+
return db.project.create({
|
|
136
|
+
data: {
|
|
137
|
+
name: input.name,
|
|
138
|
+
description: input.description ?? null,
|
|
139
|
+
ownerId: input.ownerId,
|
|
140
|
+
},
|
|
141
|
+
select: {
|
|
142
|
+
id: true,
|
|
143
|
+
name: true,
|
|
144
|
+
description: true,
|
|
145
|
+
createdAt: true,
|
|
146
|
+
updatedAt: true,
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async list(input: ListProjectsInput): Promise<PaginatedResult<any>> {
|
|
152
|
+
const { ownerId, page, limit } = input
|
|
153
|
+
const skip = (page - 1) * limit
|
|
154
|
+
|
|
155
|
+
// Parallel queries for data + count (Banner — performance)
|
|
156
|
+
const [items, total] = await Promise.all([
|
|
157
|
+
db.project.findMany({
|
|
158
|
+
where: { ownerId },
|
|
159
|
+
orderBy: { createdAt: 'desc' },
|
|
160
|
+
skip,
|
|
161
|
+
take: limit,
|
|
162
|
+
select: {
|
|
163
|
+
id: true,
|
|
164
|
+
name: true,
|
|
165
|
+
description: true,
|
|
166
|
+
createdAt: true,
|
|
167
|
+
updatedAt: true,
|
|
168
|
+
// Only select fields needed — no over-fetching
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
db.project.count({ where: { ownerId } }),
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
items,
|
|
176
|
+
page,
|
|
177
|
+
limit,
|
|
178
|
+
total,
|
|
179
|
+
hasMore: skip + items.length < total,
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async getById(projectId: string, requestingUserId: string) {
|
|
184
|
+
const project = await db.project.findUnique({
|
|
185
|
+
where: { id: projectId },
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
if (!project) {
|
|
189
|
+
throw new ApiError('NOT_FOUND', 'Project not found', 404)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Ownership check (Ahsoka — no IDOR)
|
|
193
|
+
if (project.ownerId !== requestingUserId) {
|
|
194
|
+
// Return 404, not 403 — don't reveal existence
|
|
195
|
+
throw new ApiError('NOT_FOUND', 'Project not found', 404)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return project
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async delete(projectId: string, requestingUserId: string) {
|
|
202
|
+
// Verify ownership before deletion
|
|
203
|
+
const project = await this.getById(projectId, requestingUserId)
|
|
204
|
+
|
|
205
|
+
await db.project.delete({
|
|
206
|
+
where: { id: project.id },
|
|
207
|
+
})
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Error class (lives in /lib/errors.ts) ---
|
|
212
|
+
// Shown here for completeness
|
|
213
|
+
/*
|
|
214
|
+
export class ApiError extends Error {
|
|
215
|
+
constructor(
|
|
216
|
+
public code: string,
|
|
217
|
+
message: string,
|
|
218
|
+
public status: number
|
|
219
|
+
) {
|
|
220
|
+
super(message)
|
|
221
|
+
this.name = 'ApiError'
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
*/
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE Endpoint Pattern — Server-Sent Events with proper lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* Use for: streaming AI responses, progress bars, real-time notifications
|
|
5
|
+
* Don't use for: bidirectional communication (use WebSocket instead)
|
|
6
|
+
*
|
|
7
|
+
* Adaptations:
|
|
8
|
+
* - Express: res.writeHead + res.write (shown below)
|
|
9
|
+
* - FastAPI: StreamingResponse with async generator
|
|
10
|
+
* - Django: StreamingHttpResponse with iterator
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
14
|
+
|
|
15
|
+
// ── Express/Node SSE endpoint ──────────────────────
|
|
16
|
+
|
|
17
|
+
export function handleSSE(req: IncomingMessage, res: ServerResponse): void {
|
|
18
|
+
// Headers — must be set before any write
|
|
19
|
+
res.writeHead(200, {
|
|
20
|
+
'Content-Type': 'text/event-stream',
|
|
21
|
+
'Cache-Control': 'no-cache',
|
|
22
|
+
'Connection': 'keep-alive',
|
|
23
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Keepalive — prevents proxy timeout (Caddy: 60s, nginx: 60s, Cloudflare: 100s)
|
|
27
|
+
const keepalive = setInterval(() => {
|
|
28
|
+
res.write(': keepalive\n\n');
|
|
29
|
+
}, 15000);
|
|
30
|
+
|
|
31
|
+
// Connection timeout — don't hold connections forever
|
|
32
|
+
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
sendEvent(res, 'done', { reason: 'timeout' });
|
|
35
|
+
cleanup();
|
|
36
|
+
}, MAX_DURATION_MS);
|
|
37
|
+
|
|
38
|
+
// Cleanup on client disconnect
|
|
39
|
+
req.on('close', cleanup);
|
|
40
|
+
|
|
41
|
+
function cleanup(): void {
|
|
42
|
+
clearInterval(keepalive);
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
if (!res.writableEnded) res.end();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Send a typed event
|
|
48
|
+
function sendEvent(response: ServerResponse, event: string, data: unknown): void {
|
|
49
|
+
if (response.writableEnded) return;
|
|
50
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Your streaming logic here ──────────────────
|
|
54
|
+
// Example: stream AI response chunks
|
|
55
|
+
// for await (const chunk of aiStream) {
|
|
56
|
+
// sendEvent(res, 'chunk', { text: chunk });
|
|
57
|
+
// }
|
|
58
|
+
// sendEvent(res, 'done', { totalTokens: 1234 });
|
|
59
|
+
// cleanup();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Client-side (vanilla JS) ──────────────────────
|
|
63
|
+
//
|
|
64
|
+
// const source = new EventSource('/api/stream');
|
|
65
|
+
// source.addEventListener('chunk', (e) => {
|
|
66
|
+
// const data = JSON.parse(e.data);
|
|
67
|
+
// appendToUI(data.text);
|
|
68
|
+
// });
|
|
69
|
+
// source.addEventListener('done', (e) => {
|
|
70
|
+
// source.close();
|
|
71
|
+
// });
|
|
72
|
+
// source.addEventListener('error', () => {
|
|
73
|
+
// // EventSource auto-reconnects — add backoff if needed
|
|
74
|
+
// source.close();
|
|
75
|
+
// });
|
|
76
|
+
|
|
77
|
+
// ── React SSE hook ────────────────────────────────
|
|
78
|
+
//
|
|
79
|
+
// Use useRef for mutable callbacks in SSE handlers:
|
|
80
|
+
// const onChunkRef = useRef(onChunk);
|
|
81
|
+
// onChunkRef.current = onChunk; // Update ref on every render
|
|
82
|
+
// useEffect(() => {
|
|
83
|
+
// const source = new EventSource(url);
|
|
84
|
+
// source.addEventListener('chunk', (e) => onChunkRef.current(e));
|
|
85
|
+
// return () => source.close();
|
|
86
|
+
// }, [url]); // Only url in deps, NOT onChunk
|
|
87
|
+
//
|
|
88
|
+
// Why: EventSource registers callbacks once. If onChunk is in the dep array,
|
|
89
|
+
// the effect re-runs on every render, creating/destroying EventSource rapidly.
|
|
90
|
+
// The ref pattern gives the handler access to fresh state without re-registering.
|
|
91
|
+
// (Field report #77: React SSE handler captured stale closure, missed updates)
|
|
92
|
+
|
|
93
|
+
// ── FastAPI adaptation ────────────────────────────
|
|
94
|
+
//
|
|
95
|
+
// from fastapi.responses import StreamingResponse
|
|
96
|
+
// import asyncio, json
|
|
97
|
+
//
|
|
98
|
+
// @app.get("/api/stream")
|
|
99
|
+
// async def stream():
|
|
100
|
+
// async def generate():
|
|
101
|
+
// yield f"event: start\ndata: {json.dumps({'status': 'connected'})}\n\n"
|
|
102
|
+
// async for chunk in ai_stream():
|
|
103
|
+
// yield f"event: chunk\ndata: {json.dumps({'text': chunk})}\n\n"
|
|
104
|
+
// yield f"event: done\ndata: {json.dumps({'status': 'complete'})}\n\n"
|
|
105
|
+
// return StreamingResponse(generate(), media_type="text/event-stream")
|
|
106
|
+
|
|
107
|
+
// ── Django adaptation ─────────────────────────────
|
|
108
|
+
//
|
|
109
|
+
// from django.http import StreamingHttpResponse
|
|
110
|
+
// import json
|
|
111
|
+
//
|
|
112
|
+
// def stream_view(request):
|
|
113
|
+
// def generate():
|
|
114
|
+
// yield f"event: start\ndata: {json.dumps({'status': 'connected'})}\n\n"
|
|
115
|
+
// for chunk in ai_stream():
|
|
116
|
+
// yield f"event: chunk\ndata: {json.dumps({'text': chunk})}\n\n"
|
|
117
|
+
// yield f"event: done\ndata: {json.dumps({'status': 'complete'})}\n\n"
|
|
118
|
+
// return StreamingHttpResponse(generate(), content_type="text/event-stream")
|