@wopr-network/platform-core 1.42.2 → 1.43.0

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.
@@ -0,0 +1,35 @@
1
+ name: Build Key Server Image
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ paths:
6
+ - 'src/billing/crypto/**'
7
+ - 'src/db/schema/crypto.ts'
8
+ - 'drizzle/migrations/**'
9
+ - 'Dockerfile.key-server'
10
+ - 'package.json'
11
+ - 'pnpm-lock.yaml'
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: self-hosted
16
+ permissions:
17
+ contents: read
18
+ packages: write
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - uses: docker/login-action@v3
23
+ with:
24
+ registry: ghcr.io
25
+ username: ${{ github.actor }}
26
+ password: ${{ secrets.GITHUB_TOKEN }}
27
+
28
+ - uses: docker/build-push-action@v6
29
+ with:
30
+ context: .
31
+ file: Dockerfile.key-server
32
+ push: true
33
+ tags: |
34
+ ghcr.io/${{ github.repository_owner }}/crypto-key-server:latest
35
+ ghcr.io/${{ github.repository_owner }}/crypto-key-server:${{ github.sha }}
@@ -0,0 +1,20 @@
1
+ FROM node:24-alpine AS build
2
+ WORKDIR /app
3
+ COPY package.json pnpm-lock.yaml ./
4
+ RUN corepack enable && pnpm install --frozen-lockfile
5
+ COPY . .
6
+ RUN pnpm build
7
+
8
+ FROM node:24-alpine
9
+ WORKDIR /app
10
+ RUN addgroup -S crypto && adduser -S crypto -G crypto
11
+ COPY --from=build /app/dist ./dist
12
+ COPY --from=build /app/drizzle ./drizzle
13
+ COPY --from=build /app/node_modules ./node_modules
14
+ COPY --from=build /app/package.json ./
15
+ USER crypto
16
+
17
+ EXPOSE 3100
18
+ ENV PORT=3100
19
+ HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3100/chains || exit 1
20
+ CMD ["node", "dist/billing/crypto/key-server-entry.js"]
@@ -0,0 +1,430 @@
1
+ # Platform-Core Gateway Billing Flow Research
2
+
3
+ **Date:** 2026-03-17
4
+ **Researcher:** Claude Agent
5
+
6
+ ## Executive Summary
7
+
8
+ The platform-core gateway implements a **double-entry ledger billing system** where credits are deducted per LLM call. Service keys bind to tenants, and billing is tenant-based. The system is mostly complete but **lacks a "platform service account" pattern** for internal WOPR services to bill against a shared account.
9
+
10
+ ---
11
+
12
+ ## 1. Gateway Billing Flow (Request → Credit Deduction)
13
+
14
+ ### Request Path: `/v1/chat/completions`
15
+
16
+ ```
17
+ 1. SERVICE KEY AUTHENTICATION (middleware)
18
+ ├─ Extract Authorization: Bearer <service_key>
19
+ ├─ Hash the key (SHA-256)
20
+ ├─ Query gateway_service_keys table → find tenantId
21
+ └─ Set c.set("gatewayTenant", { id, ... })
22
+ File: src/gateway/service-key-auth.ts:40-70
23
+
24
+ 2. PROXY HANDLER (src/gateway/proxy.ts)
25
+ ├─ Get tenant from context: c.get("gatewayTenant")
26
+ ├─ Extract request params (model, tokens, etc.)
27
+ └─ Resolve cost in cents via rate-lookup
28
+
29
+ 3. PRE-CALL CREDIT CHECK (src/gateway/credit-gate.ts:40-85)
30
+ ├─ Query ledger.balance(tenant.id)
31
+ ├─ Check: balance >= estimated_cost (soft check)
32
+ ├─ Check: balance >= grace_buffer (hard check, default -$0.50)
33
+ └─ If insufficient: return 402 Payment Required (CreditError)
34
+
35
+ 4. UPSTREAM PROXY CALL
36
+ ├─ Forward request to provider (OpenAI, Anthropic, etc.)
37
+ ├─ Stream response to bot
38
+ └─ Capture actual usage (tokens, cost, etc.)
39
+
40
+ 5. POST-CALL CREDIT DEBIT (src/gateway/credit-gate.ts:120-180)
41
+ ├─ Calculate actual cost in cents (with margin, e.g., 1.3x)
42
+ ├─ Call ledger.debit(tenantId, chargeCredit, "adapter_usage", {
43
+ │ description: "Gateway {capability} via {provider}",
44
+ │ allowNegative: true,
45
+ │ attributedUserId: <optional>
46
+ │ })
47
+ ├─ Fire-and-forget (never fails the response)
48
+ ├─ Emit meter event for analytics
49
+ └─ Trigger side effects:
50
+ ├─ onDebitComplete() → check auto-topup triggers
51
+ ├─ onBalanceExhausted() → fire when balance crosses zero
52
+ └─ onSpendAlertCrossed() → fire when spend threshold hit
53
+
54
+ 6. RESPONSE
55
+ └─ Return bot response (success or error from upstream)
56
+ ```
57
+
58
+ **Files involved:**
59
+ - `src/gateway/service-key-auth.ts` - Bearer token extraction → tenant resolution
60
+ - `src/gateway/proxy.ts` - Main handler, orchestrates flow
61
+ - `src/gateway/credit-gate.ts` - Balance check & debit logic
62
+ - `src/gateway/rate-lookup.ts` - Cost calculation (cents)
63
+
64
+ ---
65
+
66
+ ## 2. Tenant & Service Key Model
67
+
68
+ ### Tenant Types
69
+
70
+ ```typescript
71
+ // src/db/schema/tenants.ts
72
+ type: "personal" | "org"
73
+
74
+ tenants {
75
+ id: string (nanoid)
76
+ name: string
77
+ slug: string (unique)
78
+ type: "personal" | "org"
79
+ ownerId: string (user who created it)
80
+ billingEmail: string
81
+ createdAt: bigint (epoch ms)
82
+ }
83
+ ```
84
+
85
+ **Current model:**
86
+ - **personal** tenant = 1:1 with user (ownerId = user.id)
87
+ - **org** tenant = multi-user organization
88
+
89
+ **Missing:** No "service account" or "platform account" type. This is the blocking gap.
90
+
91
+ ### Service Key → Tenant Mapping
92
+
93
+ ```typescript
94
+ // src/db/schema/gateway-service-keys.ts
95
+ gatewayServiceKeys {
96
+ id: string
97
+ keyHash: string (SHA-256, raw key never stored)
98
+ tenantId: string (FK tenants.id)
99
+ instanceId: string (one key per bot instance)
100
+ createdAt: bigint (epoch ms)
101
+ revokedAt: bigint | null
102
+ }
103
+ ```
104
+
105
+ **Lookup flow:**
106
+ 1. Extract bearer token from Authorization header
107
+ 2. Hash with SHA-256
108
+ 3. Query: `SELECT tenantId FROM gateway_service_keys WHERE keyHash = ?`
109
+ 4. Return tenant (used for all subsequent billing)
110
+
111
+ **Key constraint:** A service key is 1:1 with a tenant. All calls using that key bill against the same tenant.
112
+
113
+ ---
114
+
115
+ ## 3. Double-Entry Ledger Implementation
116
+
117
+ ### Schema
118
+
119
+ ```typescript
120
+ // src/db/schema/ledger.ts
121
+
122
+ // Chart of Accounts (master list of accounts)
123
+ accounts {
124
+ id: string
125
+ code: string (unique, e.g., "1000-TENANT-LIAB")
126
+ name: string
127
+ type: "asset" | "liability" | "equity" | "revenue" | "expense"
128
+ normalSide: "debit" | "credit"
129
+ tenantId: string | null (NULL = system, per-tenant = tenant-scoped)
130
+ }
131
+
132
+ // Journal Entries (transaction headers)
133
+ journalEntries {
134
+ id: string
135
+ postedAt: string (ISO)
136
+ entryType: string ("purchase", "usage", "grant", "refund", "dividend", "expiry", "correction")
137
+ description: string
138
+ referenceId: string (unique, dedup key)
139
+ tenantId: string (FK tenants.id)
140
+ metadata: jsonb ({
141
+ funding_source?: string
142
+ attributed_user_id?: string
143
+ stripe_fingerprint?: string
144
+ // ... more fields
145
+ })
146
+ createdBy: string ("system", "admin:<id>", "cron:expiry", etc.)
147
+ }
148
+
149
+ // Journal Lines (individual debits/credits)
150
+ journalLines {
151
+ id: string
152
+ journalEntryId: string (FK)
153
+ accountId: string (FK)
154
+ amount: bigint (nanodollars, always positive)
155
+ side: "debit" | "credit"
156
+ }
157
+
158
+ // Materialized Cache (derived, can be reconstructed)
159
+ accountBalances {
160
+ accountId: string (PK, FK accounts.id)
161
+ balance: bigint (nanodollars, net balance)
162
+ lastUpdated: string (ISO)
163
+ }
164
+ ```
165
+
166
+ ### Billing Transaction Example
167
+
168
+ **Event:** Gateway processes a $0.25 API call for tenant "tenant-123"
169
+
170
+ ```
171
+ 1. Create accounts (if not exist):
172
+ - Asset account: "1000-TENANT-LIAB-tenant-123" (liability: decreases = debit)
173
+ - Expense account: "5000-API-USAGE" (system, revenue type)
174
+
175
+ 2. Create journal entry:
176
+ {
177
+ id: "je-abc123",
178
+ entryType: "usage",
179
+ description: "Gateway /v1/chat/completions via openai",
180
+ referenceId: "call-xyz",
181
+ tenantId: "tenant-123",
182
+ metadata: {
183
+ attributed_user_id: "user-456",
184
+ capability: "chat",
185
+ provider: "openai"
186
+ },
187
+ createdBy: "system",
188
+ postedAt: now()
189
+ }
190
+
191
+ 3. Create two journal lines (balanced):
192
+ [
193
+ {
194
+ journalEntryId: "je-abc123",
195
+ accountId: "acct-liability",
196
+ amount: 25000000, // 0.25 * 1e8 nanodollars
197
+ side: "debit" // debit = reduce liability (tenant owes us less)
198
+ },
199
+ {
200
+ journalEntryId: "je-abc123",
201
+ accountId: "acct-api-usage",
202
+ amount: 25000000, // nanodollars
203
+ side: "credit" // credit = increase revenue
204
+ }
205
+ ]
206
+
207
+ 4. Update account_balances atomically (same txn):
208
+ - liability account: subtract 25000000
209
+ - revenue account: add 25000000
210
+ ```
211
+
212
+ **Balance query:**
213
+ ```sql
214
+ SELECT SUM(CASE WHEN side = 'debit' THEN -amount ELSE amount END)
215
+ FROM journal_lines
216
+ WHERE accountId = 'acct-liability-tenant-123'
217
+ AND journalEntryId IN (SELECT id FROM journal_entries WHERE tenantId = 'tenant-123')
218
+ ```
219
+
220
+ ---
221
+
222
+ ## 4. Credit Deduction Mechanism
223
+
224
+ ### ILedger Interface
225
+
226
+ ```typescript
227
+ // src/monetization/credits/index.ts (re-exported from @wopr-network/platform-core/credits)
228
+ interface ILedger {
229
+ balance(tenantId: string): Promise<Credit>
230
+ debit(
231
+ tenantId: string,
232
+ amount: Credit,
233
+ entryType: DebitType, // "adapter_usage", "phone_cost", etc.
234
+ options?: {
235
+ description?: string
236
+ allowNegative?: boolean
237
+ attributedUserId?: string
238
+ }
239
+ ): Promise<void>
240
+ // ... other methods
241
+ }
242
+
243
+ type DebitType = "adapter_usage" | "phone_cost" | "refund" | "correction" | ...
244
+ ```
245
+
246
+ ### Debit Flow (src/gateway/credit-gate.ts:120-180)
247
+
248
+ ```typescript
249
+ // 1. Cost calculation
250
+ const chargeCredit = Credit.fromCents(Math.ceil(costUsd * 100) * margin)
251
+ // Example: $0.15 API cost × 1.3 margin = $0.195 = 19.5 cents → 20 cents
252
+
253
+ // 2. Fire debit (non-atomic with balance check — accepted trade-off)
254
+ await deps.creditLedger.debit(tenantId, chargeCredit, "adapter_usage", {
255
+ description: `Gateway chat via openai`,
256
+ allowNegative: true, // Allow balance to go negative (on-account feature)
257
+ attributedUserId: userId // Optional: track which user triggered this
258
+ })
259
+
260
+ // 3. Side effects (fire-and-forget, don't fail response)
261
+ if (deps.onDebitComplete) {
262
+ deps.onDebitComplete(tenantId) // Trigger auto-topup if configured
263
+ }
264
+ if (deps.onBalanceExhausted) {
265
+ const newBalance = await deps.creditLedger.balance(tenantId)
266
+ const wasPositive = newBalance.add(chargeCredit).greaterThan(Credit.ZERO)
267
+ const isNowZeroOrNegative = newBalance.isNegative() || newBalance.isZero()
268
+ if (wasPositive && isNowZeroOrNegative) {
269
+ deps.onBalanceExhausted(tenantId, newBalance.toCentsRounded())
270
+ }
271
+ }
272
+ ```
273
+
274
+ **Key design decisions:**
275
+ - **Fire-and-forget debits** — don't fail the API response if ledger write fails
276
+ - **allowNegative: true** — tenants can go into on-account deficit (up to grace buffer)
277
+ - **Reconciliation via ledger queries** — catch discrepancies in analytics, not in request path
278
+ - **Non-atomic with check** — concurrent requests can both pass the check, one debit may fail
279
+
280
+ ---
281
+
282
+ ## 5. Rate Limiting Per Tenant
283
+
284
+ ### Rate Limit Table
285
+
286
+ ```typescript
287
+ // src/db/schema/rate-limit-entries.ts
288
+ rateLimitEntries {
289
+ key: string (e.g., "tenant:tenant-123:chat/v1")
290
+ scope: string (e.g., "per_minute", "per_second")
291
+ count: integer (current count in window)
292
+ windowStart: bigint (epoch ms, sliding window)
293
+ }
294
+
295
+ Primary Key: (key, scope)
296
+ ```
297
+
298
+ ### Rate Limit Middleware
299
+
300
+ **Location:** `src/gateway/capability-rate-limit.ts`
301
+
302
+ ```typescript
303
+ export interface CapabilityRateLimit {
304
+ key: string // tenant:X:capability
305
+ scope: string // per_minute, per_hour, per_day
306
+ limit: number // max requests per window
307
+ window: number // window duration in ms
308
+ }
309
+ ```
310
+
311
+ **Lookup:**
312
+ 1. Extract tenant from context
313
+ 2. Extract capability from request (chat, tts, sms, etc.)
314
+ 3. Build key: `tenant:${tenantId}:${capability}`
315
+ 4. Query rate-limit-entries for (key, scope)
316
+ 5. Check if count < limit within current window
317
+ 6. If exceeded: return 429 Too Many Requests
318
+
319
+ ---
320
+
321
+ ## 6. Attribution & Tenant Isolation
322
+
323
+ ### Attribution
324
+
325
+ **Current:** Implicit (service key → tenant, no per-call attribution)
326
+
327
+ **Limited support:** `attributedUserId` in debit options (optional)
328
+
329
+ ```typescript
330
+ await ledger.debit(tenantId, amount, "adapter_usage", {
331
+ attributedUserId: userId // Stored in journal entry metadata
332
+ })
333
+ ```
334
+
335
+ **What's missing:**
336
+ - No X-Tenant-Id or X-Attribute-To header support
337
+ - No concept of "billing against a different tenant than the authenticated tenant"
338
+ - No platform-level cost absorption (e.g., "bill WOPR for this call, not the bot tenant")
339
+
340
+ ### Tenant Isolation
341
+
342
+ **Strong isolation:**
343
+ - Service key → single tenant (1:1 mapping)
344
+ - All downstream queries scoped to tenantId
345
+ - Rate limiting per tenant + capability
346
+ - Ledger accounts per tenant
347
+
348
+ **No cross-tenant operations:** Cannot bill one tenant on behalf of another.
349
+
350
+ ---
351
+
352
+ ## 7. Missing: "Platform Service Account" Pattern
353
+
354
+ ### The Gap
355
+
356
+ **Scenario:** WOPR's own services (e.g., playground, demo, internal testing) need to use the gateway without:
357
+ 1. Creating a user account
358
+ 2. Creating a personal/org tenant
359
+ 3. Paying for calls (or paying from a shared WOPR account)
360
+
361
+ **Current workaround:**
362
+ - Create a fake "system" tenant
363
+ - Manually seed with credits
364
+ - Generate a service key
365
+ - **Problem:** No billing separation, no analytics
366
+
367
+ ### Proposed Solution
368
+
369
+ Add a new tenant type:
370
+
371
+ ```typescript
372
+ // src/db/schema/tenants.ts
373
+ type: "personal" | "org" | "platform_service"
374
+
375
+ // Constraints:
376
+ // - ownerId: system account (e.g., "wopr-system")
377
+ // - billingEmail: null (invoiced to WOPR, not a real user)
378
+ // - tier: "internal" | "demo" | "testing" (metadata)
379
+ ```
380
+
381
+ **Benefits:**
382
+ 1. Separate ledger for internal WOPR usage
383
+ 2. Analytics: see how much WOPR spends on its own features
384
+ 3. Cost allocation: bill WOPR services proportionally to consumption
385
+ 4. Audit trail: createdBy = "system", metadata tracks purpose
386
+
387
+ ---
388
+
389
+ ## 8. Key Files & Line Numbers
390
+
391
+ | File | Lines | Purpose |
392
+ |------|-------|---------|
393
+ | `src/gateway/service-key-auth.ts` | 40–70 | Bearer token extraction → tenant resolution |
394
+ | `src/gateway/proxy.ts` | 1–100 | Main proxy handler, orchestrates flow |
395
+ | `src/gateway/credit-gate.ts` | 40–85 | Balance check (pre-call) |
396
+ | `src/gateway/credit-gate.ts` | 120–180 | Debit logic (post-call) |
397
+ | `src/gateway/rate-lookup.ts` | 1–50 | Cost calculation in cents |
398
+ | `src/db/schema/tenants.ts` | 1–25 | Tenant table (no "platform_service" type) |
399
+ | `src/db/schema/gateway-service-keys.ts` | 1–25 | Service key → tenant mapping |
400
+ | `src/db/schema/ledger.ts` | 1–100 | Double-entry ledger (accounts, entries, lines) |
401
+ | `src/db/schema/rate-limit-entries.ts` | 1–25 | Per-tenant rate limit tracking |
402
+ | `src/monetization/credits/index.ts` | 1–40 | ILedger interface exports |
403
+
404
+ ---
405
+
406
+ ## 9. Summary
407
+
408
+ ### ✅ Implemented
409
+
410
+ 1. **Service key auth** — Bearer token → tenant lookup
411
+ 2. **Proxy → credit debit flow** — Request → balance check → upstream → debit
412
+ 3. **Double-entry ledger** — Full GL with journal entries, lines, account balances
413
+ 4. **Rate limiting per tenant** — Separate counters per tenant + capability
414
+ 5. **Graceful overages** — allowNegative flag, grace buffer ($0.50)
415
+ 6. **Fire-and-forget debits** — Non-blocking, reconciliation-based
416
+
417
+ ### ❌ Missing
418
+
419
+ 1. **Platform service account type** — No "internal WOPR" tenant category
420
+ 2. **Attribution headers** — No X-Tenant-Id, X-Attribute-To support
421
+ 3. **Cross-tenant billing** — Cannot bill one tenant on behalf of another
422
+ 4. **Billing metadata in responses** — No X-Credits-Charged header
423
+
424
+ ### 🔧 Actionable Next Steps
425
+
426
+ 1. Add `type: "platform_service"` to tenants schema + constraints
427
+ 2. Seed system-owned platform service tenants at migration
428
+ 3. Implement X-Credits-Charged response header for transparency
429
+ 4. Add optional X-Attribute-To request header for attribution
430
+ 5. Document the ledger structure for internal WOPR analytics
package/biome.json CHANGED
@@ -5,9 +5,7 @@
5
5
  "useIgnoreFile": true
6
6
  },
7
7
  "files": {
8
- "includes": [
9
- "src/**/*.ts"
10
- ]
8
+ "includes": ["src/**/*.ts"]
11
9
  },
12
10
  "formatter": {
13
11
  "enabled": true,
@@ -28,12 +26,7 @@
28
26
  "noConsole": {
29
27
  "level": "error",
30
28
  "options": {
31
- "allow": [
32
- "assert",
33
- "error",
34
- "info",
35
- "warn"
36
- ]
29
+ "allow": ["assert", "error", "info", "warn"]
37
30
  }
38
31
  }
39
32
  },
@@ -0,0 +1 @@
1
+ export {};