diff --git a/README.md b/README.md index 8035150..7d78d4e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Do Better Norge Legal Tools Hub -MVP docroot for `tools.dobetternorge.com`. +MVP docroot for `tools.dobetternorge.no`. ## Required environment -- `DBN_TOOLS_PASSCODE_HASH` +- CaveauAI client access for `DBN_CAVEAU_CLIENT_SLUG` and `DBN_CAVEAU_PACKAGE_SLUG` - `DBN_AZURE_OPENAI_ENDPOINT` - `DBN_AZURE_OPENAI_API_KEY` - `DBN_AZURE_OPENAI_API_VERSION` @@ -14,15 +14,21 @@ MVP docroot for `tools.dobetternorge.com`. Optional: - `DBN_AI_PORTAL_ROOT` (defaults to sibling `ai-portal`) -- `DBN_CAVEAU_CLIENT_SLUG` (defaults to `dave-jr-legal`) +- `DBN_CAVEAU_CLIENT_SLUG` (defaults to `dobetter`) +- `DBN_CAVEAU_PACKAGE_SLUG` (defaults to `family-legal`) - `DBN_TOOLS_SUPPORT_DIR` - `DBN_TOOLS_METADATA_LOG` -Create the passcode hash with: +## Authentication -```bash -php -r "echo password_hash('replace-this-passcode', PASSWORD_DEFAULT), PHP_EOL;" -``` +The login form authenticates against Caveau `client_users` for the configured +client slug. The client must be active, the user must be active, and the client +must have an active subscription to the configured corpus package. + +Use `scripts/setup-caveau-access.php` for repeatable local/production setup of +the Do Better Norge Caveau owner account, family-legal subscription, and +white-label domain mappings. Pass the account password through +`DBN_SETUP_PASSWORD` at runtime only; do not commit it. The APIs process pasted text in memory and write only metadata such as tool name, latency, language, source count, chunk count, deployment, and anonymous session id. diff --git a/api/health.php b/api/health.php index 3b13206..94b9692 100644 --- a/api/health.php +++ b/api/health.php @@ -8,9 +8,9 @@ dbnToolsRequireMethod('GET'); dbnToolsRequireAuth(); $checks = []; -$checks['passcode_hash'] = [ - 'ok' => (bool)dbnToolsEnv('DBN_TOOLS_PASSCODE_HASH'), - 'detail' => dbnToolsEnv('DBN_TOOLS_PASSCODE_HASH') ? 'Configured' : 'Missing DBN_TOOLS_PASSCODE_HASH', +$checks['caveau_auth'] = [ + 'ok' => true, + 'detail' => 'Tools login uses Caveau client_users for tenant ' . dbnToolsClientSlug(), ]; $azure = new DbnAzureOpenAiGateway(); @@ -42,15 +42,16 @@ try { $checks['db_connectivity'] = ['ok' => true, 'detail' => 'CaveauAI admin DB reachable']; $client = dbnToolsFetchClient($db); - $checks['dave_jr_legal_client'] = [ + $checks['dobetter_client'] = [ 'ok' => (bool)$client, 'detail' => $client ? 'Client id ' . $client['id'] . ' found' : 'Client slug ' . dbnToolsClientSlug() . ' not found', ]; - $package = dbnToolsFetchPackage('family-legal', $db); + $packageSlug = dbnToolsRequiredPackageSlug(); + $package = dbnToolsFetchPackage($packageSlug, $db); $checks['family_legal_package'] = [ - 'ok' => (bool)$package, - 'detail' => $package ? 'Package id ' . $package['id'] . ' found' : 'family-legal package not found', + 'ok' => (bool)$package && !empty($package['is_active']), + 'detail' => $package ? 'Package id ' . $package['id'] . ' found' : $packageSlug . ' package not found', ]; $subOk = $client && $package && dbnToolsHasActiveSubscription((int)$client['id'], (int)$package['id'], $db); @@ -60,7 +61,7 @@ try { ]; } catch (Throwable $e) { $checks['db_connectivity'] = ['ok' => false, 'detail' => $e->getMessage()]; - $checks['dave_jr_legal_client'] = ['ok' => false, 'detail' => 'Not checked']; + $checks['dobetter_client'] = ['ok' => false, 'detail' => 'Not checked']; $checks['family_legal_package'] = ['ok' => false, 'detail' => 'Not checked']; $checks['family_legal_subscription'] = ['ok' => false, 'detail' => 'Not checked']; } diff --git a/api/session.php b/api/session.php index b46e16b..107c2b4 100644 --- a/api/session.php +++ b/api/session.php @@ -16,27 +16,51 @@ if ($password === '') { dbnToolsError('Password is required.', 422, 'missing_password'); } -$configuredEmail = dbnToolsAuthEmail(); -$hash = dbnToolsAuthPasswordHash(); +try { + $db = dbnToolsDb(); + $client = dbnToolsFetchClient($db); + if (!$client || empty($client['is_active'])) { + dbnToolsError('Do Better Norge Caveau workspace is not active.', 503, 'client_unavailable'); + } -if ($configuredEmail === null || trim($configuredEmail) === '' || $hash === null || trim($hash) === '') { - dbnToolsError('Authentication credentials are not configured.', 503, 'auth_not_configured'); + $user = dbnToolsFetchActiveClientUser($email, (int)$client['id'], $db); +} catch (DbnToolsHttpException $e) { + dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra); +} catch (Throwable $e) { + error_log('DBN tools login error: ' . $e->getMessage()); + dbnToolsError('Caveau authentication is not available.', 503, 'auth_unavailable'); } -$emailMatch = hash_equals(strtolower(trim($configuredEmail)), $email); -$passwordMatch = password_verify($password, $hash); - -if (!$emailMatch || !$passwordMatch) { +if (!$user || !password_verify($password, (string)$user['password_hash'])) { dbnToolsError('Email or password was not accepted.', 401, 'invalid_credentials'); } +$packageAccess = dbnToolsCanUsePackage((int)$client['id'], dbnToolsRequiredPackageSlug(), $db); +if (empty($packageAccess['ok'])) { + dbnToolsError( + (string)$packageAccess['message'], + (int)$packageAccess['status'], + (string)$packageAccess['code'] + ); +} + session_regenerate_id(true); $_SESSION['dbn_tools_authenticated'] = true; $_SESSION['dbn_tools_authenticated_at'] = time(); $_SESSION['dbn_tools_anon_id'] = $_SESSION['dbn_tools_anon_id'] ?? bin2hex(random_bytes(16)); +$_SESSION['dbn_tools_client_id'] = (int)$client['id']; +$_SESSION['dbn_tools_client_slug'] = (string)$client['slug']; +$_SESSION['dbn_tools_user_id'] = (int)$user['id']; +$_SESSION['dbn_tools_user_email'] = (string)$user['email']; +$_SESSION['dbn_tools_user_role'] = (string)$user['role']; +$_SESSION['dbn_tools_package_slug'] = dbnToolsRequiredPackageSlug(); dbnToolsRespond([ 'ok' => true, 'authenticated' => true, 'session' => dbnToolsAnonymousSessionId(), + 'user' => [ + 'email' => (string)$user['email'], + 'role' => (string)$user['role'], + ], ]); diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 6745795..bc8605a 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -24,7 +24,7 @@ final class DbnLegalToolsService $limit = max(1, min(10, $limit)); $trace = [ - $this->trace('Query interpretation', 'Searching Dave Jr Legal private corpus plus the subscribed family-legal package.', 'complete'), + $this->trace('Query interpretation', 'Searching Do Better Norge private corpus plus the subscribed family-legal package.', 'complete'), $this->trace('Search tools used', 'ClientRagPipeline::searchAll with keyword mode, private corpus enabled, shared package filter set to family-legal.', 'running'), ]; @@ -397,7 +397,7 @@ PROMPT; dbnToolsAbort('The family-legal corpus package is not active.', 503, 'package_unavailable'); } if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'])) { - dbnToolsAbort('Dave Jr Legal does not have an active family-legal subscription.', 503, 'subscription_missing'); + dbnToolsAbort('Do Better Norge does not have an active family-legal subscription.', 503, 'subscription_missing'); } return $package; } @@ -468,7 +468,7 @@ PROMPT; return [ 'title' => $title, 'excerpt' => dbnToolsExcerpt((string)($chunk['content'] ?? ''), 620), - 'package_or_corpus' => (string)($chunk['source_name'] ?? $chunk['source_type'] ?? 'Dave Jr Legal'), + 'package_or_corpus' => (string)($chunk['source_name'] ?? $chunk['source_type'] ?? 'Do Better Norge'), 'score' => $score, 'document_id' => isset($chunk['document_id']) ? (int)$chunk['document_id'] : null, 'chunk_id' => isset($chunk['id']) ? (int)$chunk['id'] : null, @@ -535,7 +535,7 @@ PROMPT; $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$row) { $row['similarity'] = 0.25; - $row['source_name'] = 'Dave Jr Legal private corpus'; + $row['source_name'] = 'Do Better Norge private corpus'; $row['source_type'] = 'private'; } return $rows; diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 324a41a..edafbe6 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -110,17 +110,29 @@ dbnToolsStartSession(); function dbnToolsIsAuthenticated(): bool { - return !empty($_SESSION['dbn_tools_authenticated']); + return !empty($_SESSION['dbn_tools_authenticated']) + && !empty($_SESSION['dbn_tools_user_id']) + && !empty($_SESSION['dbn_tools_client_id']) + && (string)($_SESSION['dbn_tools_client_slug'] ?? '') === dbnToolsClientSlug(); } -function dbnToolsAuthEmail(): ?string +function dbnToolsAuthenticatedUser(): ?array { - return dbnToolsEnv('DBN_TOOLS_AUTH_EMAIL'); + if (!dbnToolsIsAuthenticated()) { + return null; + } + + return [ + 'user_id' => isset($_SESSION['dbn_tools_user_id']) ? (int)$_SESSION['dbn_tools_user_id'] : null, + 'client_id' => isset($_SESSION['dbn_tools_client_id']) ? (int)$_SESSION['dbn_tools_client_id'] : null, + 'email' => (string)($_SESSION['dbn_tools_user_email'] ?? ''), + 'role' => (string)($_SESSION['dbn_tools_user_role'] ?? ''), + ]; } -function dbnToolsAuthPasswordHash(): ?string +function dbnToolsRequiredPackageSlug(): string { - return dbnToolsEnv('DBN_TOOLS_AUTH_PASSWORD_HASH'); + return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal'; } function dbnToolsAnonymousSessionId(): string @@ -168,7 +180,7 @@ function dbnToolsRequireMethod(string $method): void function dbnToolsRequireAuth(): void { if (!dbnToolsIsAuthenticated()) { - dbnToolsError('Passcode session required.', 401, 'session_required'); + dbnToolsError('Caveau session required.', 401, 'session_required'); } } @@ -354,7 +366,7 @@ function dbnToolsRagDb(): PDO function dbnToolsClientSlug(): string { - return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dave-jr-legal'; + return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter'; } function dbnToolsFetchClient(?PDO $db = null): ?array @@ -370,11 +382,53 @@ function dbnToolsRequireClient(): array { $client = dbnToolsFetchClient(); if (!$client || empty($client['is_active'])) { - dbnToolsAbort('Dave Jr Legal client tenant is not active or was not found.', 503, 'client_unavailable'); + dbnToolsAbort('Do Better Norge client tenant is not active or was not found.', 503, 'client_unavailable'); } return $client; } +function dbnToolsFetchActiveClientUser(string $email, int $clientId, ?PDO $db = null): ?array +{ + $db = $db ?: dbnToolsDb(); + $stmt = $db->prepare( + 'SELECT id, client_id, username, email, display_name, password_hash, role, is_active + FROM client_users + WHERE client_id = ? AND email = ? AND is_active = 1 + LIMIT 1' + ); + $stmt->execute([$clientId, $email]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; +} + +function dbnToolsCanUsePackage(int $clientId, string $packageSlug, ?PDO $db = null): array +{ + $db = $db ?: dbnToolsDb(); + $package = dbnToolsFetchPackage($packageSlug, $db); + if (!$package || empty($package['is_active'])) { + return [ + 'ok' => false, + 'status' => 503, + 'code' => 'package_unavailable', + 'message' => "The {$packageSlug} corpus package is not active.", + ]; + } + + if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'], $db)) { + return [ + 'ok' => false, + 'status' => 403, + 'code' => 'subscription_missing', + 'message' => 'This Caveau workspace does not have access to the required corpus package.', + ]; + } + + return [ + 'ok' => true, + 'package' => $package, + ]; +} + function dbnToolsFetchPackage(string $slug = 'family-legal', ?PDO $db = null): ?array { $db = $db ?: dbnToolsDb(); diff --git a/index.php b/index.php index 93b63ac..a4ea4bf 100644 --- a/index.php +++ b/index.php @@ -12,11 +12,11 @@ $authenticated = dbnToolsIsAuthenticated(); Do Better Norge Legal Tools - + - + diff --git a/scripts/setup-caveau-access.php b/scripts/setup-caveau-access.php new file mode 100644 index 0000000..b5f0e3d --- /dev/null +++ b/scripts/setup-caveau-access.php @@ -0,0 +1,219 @@ +prepare( + 'SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?' + ); + $stmt->execute([$table, $column]); + return (int)$stmt->fetchColumn() > 0; +} + +function setupJson(array $value): string +{ + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +try { + $db = dbnToolsDb(); + $db->beginTransaction(); + + $client = dbnToolsFetchClient($db); + if (!$client) { + $apiKeyHash = hash('sha256', random_bytes(32)); + $apiKeyPrefix = 'dbn_' . substr(bin2hex(random_bytes(4)), 0, 8); + + $columns = [ + 'slug' => $clientSlug, + 'name' => 'Do Better Norge', + 'contact_email' => $email, + 'industry' => 'Legal services', + 'country' => 'NO', + 'plan' => 'enterprise', + 'api_key_hash' => $apiKeyHash, + 'api_key_prefix' => $apiKeyPrefix, + 'monthly_query_limit' => 10000, + 'monthly_document_limit' => 1000, + 'monthly_user_limit' => 25, + 'is_active' => 1, + 'subscription_status' => 'active', + 'payment_method' => 'manual', + 'billing_email' => $email, + ]; + + if (setupColumnExists($db, 'clients', 'white_label_enabled')) { + $columns['white_label_enabled'] = 1; + } + if (setupColumnExists($db, 'clients', 'white_label_config')) { + $columns['white_label_config'] = setupJson([ + 'brand_name' => 'Do Better Norge', + 'primary_color' => '#0c5f82', + 'support_email' => $email, + 'hide_powered_by' => false, + ]); + } + + $names = array_keys($columns); + $placeholders = array_fill(0, count($names), '?'); + $stmt = $db->prepare( + 'INSERT INTO clients (' . implode(', ', $names) . ') + VALUES (' . implode(', ', $placeholders) . ')' + ); + $stmt->execute(array_values($columns)); + $clientId = (int)$db->lastInsertId(); + } else { + $clientId = (int)$client['id']; + $updates = [ + 'name' => 'Do Better Norge', + 'contact_email' => $email, + 'industry' => 'Legal services', + 'country' => 'NO', + 'plan' => 'enterprise', + 'monthly_query_limit' => 10000, + 'monthly_document_limit' => 1000, + 'monthly_user_limit' => 25, + 'is_active' => 1, + 'subscription_status' => 'active', + 'payment_method' => 'manual', + 'billing_email' => $email, + ]; + + if (setupColumnExists($db, 'clients', 'white_label_enabled')) { + $updates['white_label_enabled'] = 1; + } + if (setupColumnExists($db, 'clients', 'white_label_config')) { + $updates['white_label_config'] = setupJson([ + 'brand_name' => 'Do Better Norge', + 'primary_color' => '#0c5f82', + 'support_email' => $email, + 'hide_powered_by' => false, + ]); + } + + $set = implode(', ', array_map(static fn (string $key): string => "{$key} = ?", array_keys($updates))); + $stmt = $db->prepare("UPDATE clients SET {$set} WHERE id = ?"); + $stmt->execute([...array_values($updates), $clientId]); + } + + $passwordHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + $stmt = $db->prepare('SELECT id, client_id FROM client_users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $existingUser = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existingUser) { + $stmt = $db->prepare( + 'UPDATE client_users + SET client_id = ?, username = ?, display_name = ?, password_hash = ?, role = ?, is_active = 1 + WHERE id = ?' + ); + $stmt->execute([$clientId, $username, $displayName, $passwordHash, 'owner', (int)$existingUser['id']]); + $userId = (int)$existingUser['id']; + } else { + $stmt = $db->prepare( + 'INSERT INTO client_users (client_id, username, email, display_name, password_hash, role, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1)' + ); + $stmt->execute([$clientId, $username, $email, $displayName, $passwordHash, 'owner']); + $userId = (int)$db->lastInsertId(); + } + + $package = dbnToolsFetchPackage($packageSlug, $db); + if (!$package) { + $stmt = $db->prepare( + 'INSERT INTO corpus_packages (slug, name, description, icon, is_free, included_in_plans, is_active, sort_order) + VALUES (?, ?, ?, ?, 1, ?, 1, 10)' + ); + $stmt->execute([ + $packageSlug, + 'Family Legal', + 'Family law corpus package for Do Better Norge legal tools.', + 'LAW', + setupJson(['enterprise']), + ]); + $packageId = (int)$db->lastInsertId(); + } else { + $packageId = (int)$package['id']; + $db->prepare('UPDATE corpus_packages SET is_active = 1 WHERE id = ?')->execute([$packageId]); + } + + $stmt = $db->prepare( + 'INSERT INTO client_corpus_subscriptions (client_id, package_id, is_active, source) + VALUES (?, ?, 1, ?) + ON DUPLICATE KEY UPDATE is_active = VALUES(is_active), source = VALUES(source), cancelled_at = NULL' + ); + $stmt->execute([$clientId, $packageId, 'manual']); + + if (setupColumnExists($db, 'client_domains', 'hostname') && $domain !== '') { + $domainColumns = [ + 'client_id' => $clientId, + 'hostname' => $domain, + 'service_type' => 'portal', + 'is_active' => 1, + ]; + if (setupColumnExists($db, 'client_domains', 'display_name')) { + $domainColumns['display_name'] = 'Do Better Norge Caveau'; + } + if (setupColumnExists($db, 'client_domains', 'service_config')) { + $domainColumns['service_config'] = setupJson([ + 'audience' => 'internal', + 'search_private' => true, + 'search_shared' => true, + 'package_ids' => [$packageId], + 'allowed_classifications' => ['public', 'public-record', 'internal', 'client-work'], + 'language' => 'no', + ]); + } + if (setupColumnExists($db, 'client_domains', 'is_verified')) { + $domainColumns['is_verified'] = 1; + } + if (setupColumnExists($db, 'client_domains', 'verified_at')) { + $domainColumns['verified_at'] = date('Y-m-d H:i:s'); + } + + $names = array_keys($domainColumns); + $updates = implode(', ', array_map(static fn (string $key): string => "{$key} = VALUES({$key})", $names)); + $stmt = $db->prepare( + 'INSERT INTO client_domains (' . implode(', ', $names) . ') + VALUES (' . implode(', ', array_fill(0, count($names), '?')) . ') + ON DUPLICATE KEY UPDATE ' . $updates + ); + $stmt->execute(array_values($domainColumns)); + } + + $db->commit(); + + echo "Caveau access configured.\n"; + echo "Client: {$clientSlug} (#{$clientId})\n"; + echo "User: {$email} (#{$userId}) role owner\n"; + echo "Package: {$packageSlug} (#{$packageId}) active subscription\n"; + echo "Portal domain: {$domain}\n"; +} catch (Throwable $e) { + if (isset($db) && $db instanceof PDO && $db->inTransaction()) { + $db->rollBack(); + } + fwrite(STDERR, "Setup failed: {$e->getMessage()}\n"); + exit(1); +}