From 8b77acb828c1de66b6addf757080cfd5bc7fcd75 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Sat, 16 May 2026 21:05:08 +0200 Subject: [PATCH] feat: free-tier credit system + Syttende Mai access for Google users - FreeTier.php: credit check/deduct/reset engine with hourly rate limit - bootstrap.php: dbnmDb() singleton, dbnToolsIsFreeTier(), credit gate helpers - index.php: store tier=free|approved in session from SSO JWT - All 7 API endpoints: credit gate (402/429) + X-Credits-Remaining header - layout.php: credit meta tag, JS balance var, Syttende Mai banner (05-17 only) - tools.js: credit badge in topbar, 402 modal, 429 toast, dbnUpdateCredits() - barnevernet.js + deep-research.js: wire 402/429 handling for NDJSON streams - tools.css: styles for credit badge, no-credits modal, rate-limit toast Co-Authored-By: Claude Sonnet 4.6 --- api/ask.php | 3 + api/barnevernet.php | 3 + api/deep-research.php | 3 + api/extract.php | 3 + api/redact.php | 3 + api/timeline.php | 3 + api/transcribe.php | 3 + assets/css/tools.css | 111 ++++++++++++++++++++++++++++++++ assets/js/barnevernet.js | 11 +++- assets/js/deep-research.js | 11 +++- assets/js/tools.js | 69 ++++++++++++++++++++ includes/FreeTier.php | 126 +++++++++++++++++++++++++++++++++++++ includes/bootstrap.php | 97 ++++++++++++++++++++++++++++ includes/layout.php | 19 ++++++ index.php | 1 + 15 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 includes/FreeTier.php diff --git a/api/ask.php b/api/ask.php index 4b94722..e91df18 100644 --- a/api/ask.php +++ b/api/ask.php @@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('ask'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'ask'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $input = dbnToolsJsonInput(25000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); diff --git a/api/barnevernet.php b/api/barnevernet.php index ac1b4e0..632216a 100644 --- a/api/barnevernet.php +++ b/api/barnevernet.php @@ -6,6 +6,8 @@ require_once __DIR__ . '/../includes/BvjAnalyzerAgent.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('barnevernet'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'barnevernet'); @ini_set('output_buffering', '0'); @ini_set('zlib.output_compression', '0'); @@ -16,6 +18,7 @@ ob_implicit_flush(true); header('Content-Type: application/x-ndjson; charset=utf-8'); header('Cache-Control: no-store'); header('X-Accel-Buffering: no'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $language = 'en'; $startTime = microtime(true); diff --git a/api/deep-research.php b/api/deep-research.php index 55271ab..4347d4b 100644 --- a/api/deep-research.php +++ b/api/deep-research.php @@ -6,6 +6,8 @@ require_once __DIR__ . '/../includes/DeepResearchAgent.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('deep-research'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'deep-research'); // Stream-friendly response — defeat output buffering so the user's browser // receives progress events while the agent runs (can take 60-180s for @@ -19,6 +21,7 @@ ob_implicit_flush(true); header('Content-Type: application/x-ndjson; charset=utf-8'); header('Cache-Control: no-store'); header('X-Accel-Buffering: no'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $language = 'en'; $startTime = microtime(true); diff --git a/api/extract.php b/api/extract.php index 613ffdc..5ba70d7 100644 --- a/api/extract.php +++ b/api/extract.php @@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/bootstrap.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('extract'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'extract'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } try { if (empty($_FILES['file']) || !is_array($_FILES['file'])) { diff --git a/api/redact.php b/api/redact.php index 7e995d2..948db7e 100644 --- a/api/redact.php +++ b/api/redact.php @@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('redact'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'redact'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $input = dbnToolsJsonInput(400000); dbnToolsWithTelemetry('redact', '', function () use ($input): array { diff --git a/api/timeline.php b/api/timeline.php index 7a10cdc..9fc42a6 100644 --- a/api/timeline.php +++ b/api/timeline.php @@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('timeline'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'timeline'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $input = dbnToolsJsonInput(400000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); diff --git a/api/transcribe.php b/api/transcribe.php index 0345db9..d9f3664 100644 --- a/api/transcribe.php +++ b/api/transcribe.php @@ -5,6 +5,9 @@ require_once __DIR__ . '/../includes/LegalTools.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); +$ftUid = dbnToolsFreeTierCheck('transcribe'); +$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'transcribe'); +if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } set_time_limit(0); ignore_user_abort(true); diff --git a/assets/css/tools.css b/assets/css/tools.css index df7d763..547787f 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -4313,3 +4313,114 @@ body { .site-footer__inner { grid-template-columns: 1fr; } .site-footer__bottom { flex-direction: column; } } + +/* ── Syttende Mai banner ──────────────────────────────────────────────────── */ +.syttende-mai-banner { + background: #ba0c2f; + color: #fff; + padding: 9px 20px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 0.875rem; + font-weight: 500; + letter-spacing: 0.01em; + position: sticky; + top: 0; + z-index: 200; +} +.syttende-mai-close { + background: none; + border: none; + color: rgba(255,255,255,0.75); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 4px; + margin-left: 8px; +} +.syttende-mai-close:hover { color: #fff; } + +/* ── Free-tier credit badge ──────────────────────────────────────────────── */ +.credit-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 20px; + font-size: 0.78rem; + font-weight: 600; + background: var(--soft-teal); + color: var(--teal-dark); + border: 1px solid rgba(15, 118, 110, 0.25); + white-space: nowrap; + cursor: default; +} +.credit-badge.is-low { + background: #fff7ed; + color: #92400e; + border-color: rgba(183, 121, 31, 0.3); +} +.credit-badge.is-empty { + background: #fff0e8; + color: #c2410c; + border-color: rgba(194, 65, 12, 0.3); +} + +/* ── Credit-empty modal ───────────────────────────────────────────────────── */ +.credit-modal-overlay { + position: fixed; + inset: 0; + background: rgba(22, 19, 15, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; +} +.credit-modal { + background: var(--panel); + border-radius: 12px; + padding: 32px 28px 24px; + max-width: 380px; + width: calc(100% - 40px); + box-shadow: var(--shadow); + text-align: center; +} +.credit-modal__icon { font-size: 2.5rem; margin-bottom: 12px; } +.credit-modal h3 { margin: 0 0 8px; font-size: 1.15rem; color: var(--ink); } +.credit-modal p { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; line-height: 1.55; } +.credit-modal__actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } +.credit-modal__actions a { + padding: 8px 20px; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + display: inline-block; +} +.credit-modal__cta { background: var(--teal); color: #fff; } +.credit-modal__cta:hover { background: var(--teal-dark); } +.credit-modal__dismiss { background: var(--line); color: var(--ink); } +.credit-modal__dismiss:hover { background: #c8cdd8; } + +/* ── Rate-limit toast ────────────────────────────────────────────────────── */ +.credit-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: #1b2330; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 0.875rem; + z-index: 500; + white-space: nowrap; + box-shadow: 0 4px 16px rgba(0,0,0,0.25); + animation: toastIn 0.2s ease; +} +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(8px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} diff --git a/assets/js/barnevernet.js b/assets/js/barnevernet.js index 7891744..a5c8c68 100644 --- a/assets/js/barnevernet.js +++ b/assets/js/barnevernet.js @@ -432,10 +432,19 @@ } if (!response.ok || !response.body) { - setStatus(`Request failed (${response.status}).`, 'error'); + if (response.status === 402 || response.status === 429) { + const d = await response.json().catch(() => ({})); + if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d); + } else { + setStatus(`Request failed (${response.status}).`, 'error'); + } els.runButton.disabled = false; return; } + const creditsRemaining = response.headers.get('X-Credits-Remaining'); + if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(parseInt(creditsRemaining, 10)); + } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); diff --git a/assets/js/deep-research.js b/assets/js/deep-research.js index 6aa929d..2e9f40d 100644 --- a/assets/js/deep-research.js +++ b/assets/js/deep-research.js @@ -364,10 +364,19 @@ } if (!response.ok || !response.body) { - setStatus(`Request failed (${response.status}).`, 'error'); + if (response.status === 402 || response.status === 429) { + const d = await response.json().catch(() => ({})); + if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d); + } else { + setStatus(`Request failed (${response.status}).`, 'error'); + } els.runButton.disabled = false; return; } + const creditsRemaining = response.headers.get('X-Credits-Remaining'); + if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') { + window.dbnUpdateCredits(parseInt(creditsRemaining, 10)); + } // Read the NDJSON stream const reader = response.body.getReader(); diff --git a/assets/js/tools.js b/assets/js/tools.js index 8cc6f7e..d680339 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -1202,6 +1202,12 @@ async function postJson(url, payload) { body: JSON.stringify(payload), }); const data = await response.json().catch(() => ({})); + if (response.status === 402 || response.status === 429) { + dbnFreeTierError(response.status, data); + throw new Error(data.error?.message || (response.status === 429 ? 'rate_limit' : 'no_credits')); + } + const remaining = response.headers.get('X-Credits-Remaining'); + if (remaining !== null) { dbnUpdateCredits(parseInt(remaining, 10)); } if (!response.ok) { throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`); } @@ -1929,3 +1935,66 @@ function escapeHtml(value) { .replaceAll('"', '"') .replaceAll("'", '''); } + +// ── Free-tier credit badge ──────────────────────────────────────────────── + +let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1; + +function dbnUpdateCredits(balance) { + if (typeof balance !== 'number' || balance < 0) return; + _freeTierBalance = balance; + const badge = document.getElementById('creditBadge'); + if (!badge) return; + badge.textContent = '🪙 ' + balance + ' credit' + (balance !== 1 ? 's' : ''); + badge.classList.toggle('is-low', balance > 0 && balance <= 2); + badge.classList.toggle('is-empty', balance === 0); +} + +// Exposed so barnevernet.js / deep-research.js can call it +window.dbnUpdateCredits = dbnUpdateCredits; + +function dbnFreeTierError(status, data) { + if (status === 429) { + const toast = document.createElement('div'); + toast.className = 'credit-toast'; + toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 5000); + return; + } + // 402 — no credits + const overlay = document.createElement('div'); + overlay.className = 'credit-modal-overlay'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); +} + +// Exposed so barnevernet.js / deep-research.js can call it +window.dbnFreeTierError = dbnFreeTierError; + +// Inject credit badge into topbar on page load +document.addEventListener('DOMContentLoaded', () => { + if (_freeTierBalance < 0) return; + const actions = document.querySelector('.topbar-actions'); + if (!actions) return; + const badge = document.createElement('span'); + badge.id = 'creditBadge'; + badge.className = 'credit-badge'; + badge.title = 'Free monthly credits remaining'; + dbnUpdateCredits(_freeTierBalance); // set initial state before inserting + badge.textContent = '🪙 ' + _freeTierBalance + ' credit' + (_freeTierBalance !== 1 ? 's' : ''); + badge.classList.toggle('is-low', _freeTierBalance > 0 && _freeTierBalance <= 2); + badge.classList.toggle('is-empty', _freeTierBalance === 0); + actions.insertBefore(badge, actions.firstChild); +}); diff --git a/includes/FreeTier.php b/includes/FreeTier.php new file mode 100644 index 0000000..1832f07 --- /dev/null +++ b/includes/FreeTier.php @@ -0,0 +1,126 @@ + 0, + 'search' => 0, + 'ask' => 1, + 'extract' => 1, + 'timeline' => 2, + 'redact' => 2, + 'barnevernet' => 3, + 'advocate' => 3, + 'deep-research' => 5, + 'transcribe' => 2, // flat rate; actual duration unknown upfront + ]; + + /** Credit cost for a given tool slug. Returns 1 for unknown tools. */ + public static function cost(string $tool): int + { + return self::COSTS[$tool] ?? 1; + } + + /** + * Check whether the user may proceed with a tool call. + * Handles monthly reset automatically. + * + * Returns ['ok' => true, 'balance' => int] + * or ['ok' => false, 'balance' => int, 'reason' => 'no_credits'|'rate_limit'] + */ + public static function check(int $userId, string $tool): array + { + $db = dbnmDb(); + $cost = self::cost($tool); + + // Auto-reset balance if a new month has begun since last reset + $db->prepare( + 'UPDATE user_tool_credits + SET balance = allowance, last_reset = CURDATE() + WHERE user_id = ? + AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))' + )->execute([$userId]); + + $row = $db->prepare( + 'SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1' + ); + $row->execute([$userId]); + $credits = $row->fetch(PDO::FETCH_ASSOC); + + if ($credits === false) { + // No credits row — treat as 0 balance (shouldn't happen after ensureFreeTierCredits) + return ['ok' => false, 'balance' => 0, 'reason' => 'no_credits']; + } + + $balance = (int)$credits['balance']; + + // Free tools always pass + if ($cost === 0) { + return ['ok' => true, 'balance' => $balance]; + } + + if ($balance < $cost) { + return ['ok' => false, 'balance' => $balance, 'reason' => 'no_credits']; + } + + // Hourly rate limit check (counts any tool that costs > 0) + $hourlyCount = $db->prepare( + 'SELECT COUNT(*) FROM user_tool_usage_log + WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0' + ); + $hourlyCount->execute([$userId]); + if ((int)$hourlyCount->fetchColumn() >= self::HOURLY_LIMIT) { + return ['ok' => false, 'balance' => $balance, 'reason' => 'rate_limit']; + } + + return ['ok' => true, 'balance' => $balance]; + } + + /** + * Deduct credits for a completed tool call and log the usage. + * Safe to call even when cost is 0 (logs the call but deducts nothing). + */ + public static function deduct(int $userId, string $tool): int + { + $db = dbnmDb(); + $cost = self::cost($tool); + + if ($cost > 0) { + $db->prepare( + 'UPDATE user_tool_credits + SET balance = GREATEST(0, balance - ?) + WHERE user_id = ?' + )->execute([$cost, $userId]); + } + + $db->prepare( + 'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)' + )->execute([$userId, $tool, $cost]); + + $row = $db->prepare('SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1'); + $row->execute([$userId]); + $r = $row->fetch(PDO::FETCH_ASSOC); + return $r ? (int)$r['balance'] : 0; + } + + /** + * Current balance for a user (after any pending monthly reset). + */ + public static function balance(int $userId): int + { + $result = self::check($userId, 'corpus-search'); // cost=0, triggers reset if needed + return $result['balance']; + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 3c092cb..91596c1 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -385,6 +385,103 @@ function dbnToolsRagDb(): PDO } } +/** + * PDO connection to dobetternorge_maindb (user accounts + credits). + * Credentials come from DBNM_DB_* env vars. + */ +function dbnmDb(): PDO +{ + static $pdo = null; + if ($pdo !== null) { + return $pdo; + } + + $host = dbnToolsEnv('DBNM_DB_HOST', 'localhost'); + $name = dbnToolsEnv('DBNM_DB_NAME', 'dobetternorge_maindb'); + $user = dbnToolsEnv('DBNM_DB_USER', 'root'); + $pass = dbnToolsEnv('DBNM_DB_PASS', ''); + + try { + $pdo = new PDO( + "mysql:host={$host};dbname={$name};charset=utf8mb4", + $user, + $pass, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (PDOException $e) { + throw new DbnToolsHttpException('Credit database is not reachable.', 503, 'credit_db_unavailable'); + } + + return $pdo; +} + +/** + * True when the current session is a free-tier SSO user (Google login). + * False for CaveauAI client sessions (always unlimited). + */ +function dbnToolsIsFreeTier(): bool +{ + return !empty($_SESSION['dbn_tools_authenticated']) + && !empty($_SESSION['dbn_tools_sso_uid']) + && ($_SESSION['dbn_tools_tier'] ?? '') === 'free'; +} + +/** + * Enforce free-tier credit gate before a tool call. + * Exits with JSON 402/429 if the user is over limit or out of credits. + * No-op for CaveauAI sessions. + * + * @return int The user_id for use in dbnToolsFreeTierDeduct() + */ +function dbnToolsFreeTierCheck(string $tool): int +{ + if (!dbnToolsIsFreeTier()) { + return 0; + } + + require_once __DIR__ . '/FreeTier.php'; + $uid = (int)$_SESSION['dbn_tools_sso_uid']; + $result = FreeTier::check($uid, $tool); + + if (!$result['ok']) { + $isRateLimit = ($result['reason'] ?? '') === 'rate_limit'; + http_response_code($isRateLimit ? 429 : 402); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + echo json_encode([ + 'ok' => false, + 'error' => ['code' => $result['reason'], 'message' => $isRateLimit + ? 'Rate limit reached — you can make up to 10 requests per hour on the free tier.' + : 'No credits remaining. Your 10 free credits reset on the 1st of next month.', + ], + 'balance' => $result['balance'], + ], JSON_UNESCAPED_UNICODE); + exit; + } + + return $uid; +} + +/** + * Deduct credits after a successful tool call. + * The $uid returned from dbnToolsFreeTierCheck() must be passed in. + * No-op when uid === 0 (non-free-tier session). + * + * @return int Remaining balance (for response header) + */ +function dbnToolsFreeTierDeduct(int $uid, string $tool): int +{ + if ($uid === 0) { + return -1; + } + require_once __DIR__ . '/FreeTier.php'; + return FreeTier::deduct($uid, $tool); +} + function dbnToolsClientSlug(): string { return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter'; diff --git a/includes/layout.php b/includes/layout.php index a1922d3..686d00a 100644 --- a/includes/layout.php +++ b/includes/layout.php @@ -16,6 +16,13 @@ $toolTitle = $toolMeta['label'] ?? ($toolTitle ?? dbnToolsT('suite_title', $uiLa $toolKind = $toolMeta['sub'] ?? ($toolKind ?? ''); $toolBadge = $toolMeta['badge'] ?? ($toolBadge ?? ''); $langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/'; + +// Credit balance for free-tier users +$layoutFreeTierBalance = -1; +if (dbnToolsIsFreeTier()) { + require_once __DIR__ . '/FreeTier.php'; + $layoutFreeTierBalance = FreeTier::balance((int)$_SESSION['dbn_tools_sso_uid']); +} ?> @@ -23,13 +30,25 @@ $langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/'; <?= htmlspecialchars($toolTitle) ?> - Do Better Norge += 0): ?> + + + + +
diff --git a/index.php b/index.php index e3cc147..98bb024 100644 --- a/index.php +++ b/index.php @@ -34,6 +34,7 @@ if (isset($_GET['sso']) && !dbnToolsIsAuthenticated()) { $_SESSION['dbn_tools_user_id'] = (int)$tokenData['uid']; $_SESSION['dbn_tools_user_email'] = (string)$tokenData['email']; $_SESSION['dbn_tools_user_role'] = 'sso'; + $_SESSION['dbn_tools_tier'] = (string)($tokenData['tier'] ?? 'free'); header('Location: ' . $returnPath); exit; }