@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.
- package/.github/workflows/key-server-image.yml +35 -0
- package/Dockerfile.key-server +20 -0
- package/GATEWAY_BILLING_RESEARCH.md +430 -0
- package/biome.json +2 -9
- package/dist/billing/crypto/__tests__/key-server.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/key-server.test.js +225 -0
- package/dist/billing/crypto/client.d.ts +84 -20
- package/dist/billing/crypto/client.js +76 -46
- package/dist/billing/crypto/client.test.js +76 -83
- package/dist/billing/crypto/index.d.ts +4 -2
- package/dist/billing/crypto/index.js +2 -1
- package/dist/billing/crypto/key-server-entry.d.ts +1 -0
- package/dist/billing/crypto/key-server-entry.js +43 -0
- package/dist/billing/crypto/key-server.d.ts +18 -0
- package/dist/billing/crypto/key-server.js +239 -0
- package/dist/db/schema/crypto.d.ts +247 -0
- package/dist/db/schema/crypto.js +35 -4
- package/dist/fleet/instance.d.ts +2 -0
- package/dist/fleet/instance.js +15 -0
- package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
- package/drizzle/migrations/meta/_journal.json +21 -0
- package/package.json +2 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +247 -0
- package/src/billing/crypto/client.test.ts +80 -96
- package/src/billing/crypto/client.ts +138 -58
- package/src/billing/crypto/index.ts +11 -2
- package/src/billing/crypto/key-server-entry.ts +52 -0
- package/src/billing/crypto/key-server.ts +315 -0
- package/src/db/schema/crypto.ts +45 -4
- package/src/fleet/instance.ts +16 -0
|
@@ -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 {};
|