billing-middleware.mjs
229 lines 9.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Billing gate: deduct credits + enforce storage caps before hosted operations.
3 * BILLING_ENFORCE=false (default) → shadow logs only; no requests are blocked.
4 * BILLING_ENFORCE=true → hard enforcement: 402 on quota/storage exceeded.
5 */
6 import {
7 billingEnforced,
8 billingShadowLogEnabled,
9 COST_CENTS,
10 NOTE_CAP_BY_TIER,
11 CONSOLIDATION_PASSES_BY_TIER,
12 } from './billing-constants.mjs';
13 import {
14 tryDeduct,
15 defaultUserRecord,
16 effectiveMonthlyConsolidationPassesIncluded,
17 } from './billing-logic.mjs';
18 import { loadBillingDb, saveBillingDb, resetMonthlyTokensIfNeeded } from './billing-store.mjs';
19 import { effectiveRequestPath } from './request-path.mjs';
20
21 function operationFromRequest(method, req) {
22 const path = effectiveRequestPath(req);
23 if (method === 'POST' && path.endsWith('/search')) return 'search';
24 if (method === 'POST' && path.endsWith('/index')) return 'index';
25 if (method === 'POST' && /\/memory\/consolidate\/?$/.test(path)) return 'consolidation';
26 if (method === 'POST' && /\/api\/v1\/notes\/?$/.test(path)) return 'note_write';
27 if (method === 'POST' && /\/api\/v1\/notes\/copy\/?$/.test(path)) return 'note_write';
28 if (method === 'POST' && /\/api\/v1\/notes\/delete-by-prefix\/?$/.test(path)) return 'note_write';
29 if (method === 'POST' && /\/api\/v1\/notes\/delete-by-project\/?$/.test(path)) return 'note_write';
30 if (method === 'POST' && /\/api\/v1\/notes\/rename-project\/?$/.test(path)) return 'note_write';
31 if (method === 'PUT' && /\/api\/v1\/notes\//.test(path)) return 'note_write';
32 if (
33 method === 'DELETE' &&
34 /^\/api\/v1\/notes\/.+/.test(path) &&
35 path !== '/api/v1/notes/facets'
36 ) {
37 return 'note_write';
38 }
39 if (method === 'POST' && /\/api\/v1\/proposals\/?$/.test(path)) return 'proposal_write';
40 return null;
41 }
42
43 /**
44 * POST /api/v1/notes or POST /api/v1/notes/copy — storage cap uses target vault; getNoteCount reads to_vault_id from body for copy.
45 */
46 function isNoteCreate(method, req) {
47 const path = (effectiveRequestPath(req) || '/').replace(/\/+$/, '') || '/';
48 if (method !== 'POST') return false;
49 return path === '/api/v1/notes' || path === '/api/v1/notes/copy';
50 }
51
52 /**
53 * Check note count against tier cap.
54 * Returns { ok: true } if under cap, or { ok: false, code: 'STORAGE_QUOTA_EXCEEDED', cap, tier } if over.
55 *
56 * @param {object} u - Billing user record
57 * @param {number} currentNoteCount - Current number of notes for this user
58 * @returns {{ ok: boolean, code?: string, cap?: number, tier?: string }}
59 */
60 function checkNoteStorageCap(u, currentNoteCount) {
61 const tier = String(u?.tier || 'beta');
62 const cap = NOTE_CAP_BY_TIER[tier] ?? null;
63 if (cap === null) return { ok: true };
64 if (currentNoteCount >= cap) {
65 return { ok: false, code: 'STORAGE_QUOTA_EXCEEDED', cap, tier };
66 }
67 return { ok: true };
68 }
69
70 /**
71 * @param {import('express').Request} req
72 * @param {import('express').Response} res
73 * @param {(req: import('express').Request) => string|null} getUserId
74 * @param {{ getNoteCount?: (userId: string, req: import('express').Request) => Promise<number> }} [opts]
75 * @returns {Promise<boolean>} true if request may proceed
76 */
77 export async function runBillingGate(req, res, getUserId, opts = {}) {
78 const op = operationFromRequest(req.method, req);
79 if (!op) return true;
80
81 const uid = getUserId(req);
82 const cost = COST_CENTS[op];
83
84 if (billingShadowLogEnabled() && uid && cost != null && cost > 0) {
85 console.log(
86 JSON.stringify({
87 type: 'knowtation_billing_shadow',
88 ts: new Date().toISOString(),
89 user_id: uid,
90 operation: op,
91 cost_cents: cost,
92 path: effectiveRequestPath(req),
93 billing_enforced: billingEnforced(),
94 })
95 );
96 }
97
98 // Storage cap check — only for note CREATE, only when enforce is on.
99 if (isNoteCreate(req.method, req) && uid) {
100 if (billingEnforced() && typeof opts.getNoteCount === 'function') {
101 try {
102 await resetMonthlyTokensIfNeeded(uid);
103 const db = await loadBillingDb();
104 const u = db.users[uid] || defaultUserRecord(uid);
105
106 const noteCount = await opts.getNoteCount(uid, req);
107 const storageCheck = checkNoteStorageCap(u, noteCount);
108
109 if (!storageCheck.ok) {
110 res.status(402).json({
111 error: `Note storage quota exceeded for tier '${storageCheck.tier}' (cap: ${storageCheck.cap} notes).`,
112 code: 'STORAGE_QUOTA_EXCEEDED',
113 note_cap: storageCheck.cap,
114 tier: storageCheck.tier,
115 });
116 return false;
117 }
118 } catch (e) {
119 // Never block a request due to a storage-check failure — fail open.
120 console.error('[billing] storage cap check failed (non-fatal):', e?.message || String(e));
121 }
122 } else if (billingShadowLogEnabled() && uid) {
123 // Shadow log: record that a note create happened (count enforcement deferred).
124 console.log(
125 JSON.stringify({
126 type: 'knowtation_billing_shadow',
127 ts: new Date().toISOString(),
128 user_id: uid,
129 operation: 'note_create_storage_cap_check',
130 note_count_fetcher_available: typeof opts.getNoteCount === 'function',
131 billing_enforced: billingEnforced(),
132 })
133 );
134 }
135 }
136
137 // Always track usage when the user is authenticated, regardless of enforcement mode.
138 // Enforcement (blocking) only fires when BILLING_ENFORCE=true.
139 if (uid && cost != null && cost > 0) {
140 try {
141 await resetMonthlyTokensIfNeeded(uid);
142 const db = await loadBillingDb();
143 const u = db.users[uid] || defaultUserRecord(uid);
144 if (!db.users[uid]) db.users[uid] = u;
145
146 // Increment operation counters unconditionally so Usage this period is always accurate.
147 // NOTE: 'index' job counter is intentionally omitted here — it is incremented atomically
148 // in recordIndexingTokensAfterBridgeIndex (billing-index-usage.mjs) alongside the token
149 // counter in a single mutateBillingDb call. A second write here would race with that call
150 // and risk overwriting the counter back to 0 on Netlify's eventually-consistent Blob store.
151 if (op === 'search') u.monthly_searches_used = Math.max(0, Math.floor(Number(u.monthly_searches_used) || 0)) + 1;
152 if (op === 'consolidation') {
153 u.monthly_consolidation_jobs_used = Math.max(0, Math.floor(Number(u.monthly_consolidation_jobs_used) || 0)) + 1;
154 u.consolidation_last_pass_at = new Date().toISOString();
155 }
156
157 if (billingEnforced()) {
158 // Consolidation-specific cap check.
159 if (op === 'consolidation') {
160 const passCap = effectiveMonthlyConsolidationPassesIncluded(u);
161
162 // Free tier: no hosted consolidation at all.
163 if (passCap !== null && passCap === 0) {
164 res.status(402).json({
165 error: 'Hosted memory consolidation is not available on the free tier. Upgrade to a paid plan.',
166 code: 'CONSOLIDATION_NOT_AVAILABLE',
167 tier: u.tier || 'free',
168 });
169 return false;
170 }
171
172 // Paid tier with a monthly cap: check if the monthly allotment is exhausted.
173 // NOTE: monthly_consolidation_jobs_used was already incremented above (line ~152),
174 // so the current value reflects the operation being attempted (post-increment).
175 if (passCap !== null) {
176 const passesUsedAfterIncrement = Math.max(0, Math.floor(Number(u.monthly_consolidation_jobs_used) || 0));
177 if (passesUsedAfterIncrement > passCap) {
178 // Monthly exhausted — try to draw one pass from the pack balance.
179 const packPasses = Math.max(0, Math.floor(Number(u.pack_consolidation_passes_balance) || 0));
180 if (packPasses > 0) {
181 u.pack_consolidation_passes_balance = packPasses - 1;
182 } else {
183 // Undo the counter increment so the display stays accurate.
184 u.monthly_consolidation_jobs_used = passesUsedAfterIncrement - 1;
185 res.status(402).json({
186 error: 'Monthly consolidation passes exhausted. Purchase a token pack to add more.',
187 code: 'CONSOLIDATION_QUOTA_EXHAUSTED',
188 tier: u.tier || 'free',
189 monthly_cap: passCap,
190 pack_passes_remaining: 0,
191 });
192 return false;
193 }
194 }
195 }
196 }
197
198 const result = tryDeduct(u, cost);
199 if (!result.ok) {
200 res.status(402).json({
201 error: 'Billing quota exceeded for this operation',
202 code: result.code || 'QUOTA_EXHAUSTED',
203 });
204 return false;
205 }
206 }
207
208 await saveBillingDb(db);
209 } catch (e) {
210 // Never block a request due to a billing tracking failure — fail open.
211 console.error('[billing] usage tracking failed (non-fatal):', e?.message || String(e));
212 }
213 }
214
215 return true;
216 }
217
218 /**
219 * Express middleware factory for the catch-all /api/v1 canister proxy.
220 *
221 * @param {(req: import('express').Request) => string|null} getUserId
222 * @param {{ getNoteCount?: (userId: string, req: import('express').Request) => Promise<number> }} [opts]
223 */
224 export function billingGatewayMiddleware(getUserId, opts = {}) {
225 return async (req, res, next) => {
226 const ok = await runBillingGate(req, res, getUserId, opts);
227 if (ok) next();
228 };
229 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago