Gate tools login with Caveau access

This commit is contained in:
2026-05-08 17:12:38 +02:00
parent 9b22947eb2
commit 62dbb8d900
7 changed files with 341 additions and 37 deletions
+13 -7
View File
@@ -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.
+9 -8
View File
@@ -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'];
}
+32 -8
View File
@@ -16,27 +16,51 @@ if ($password === '') {
dbnToolsError('Password is required.', 422, 'missing_password');
}
$configuredEmail = dbnToolsAuthEmail();
$hash = dbnToolsAuthPasswordHash();
if ($configuredEmail === null || trim($configuredEmail) === '' || $hash === null || trim($hash) === '') {
dbnToolsError('Authentication credentials are not configured.', 503, 'auth_not_configured');
try {
$db = dbnToolsDb();
$client = dbnToolsFetchClient($db);
if (!$client || empty($client['is_active'])) {
dbnToolsError('Do Better Norge Caveau workspace is not active.', 503, 'client_unavailable');
}
$emailMatch = hash_equals(strtolower(trim($configuredEmail)), $email);
$passwordMatch = password_verify($password, $hash);
$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');
}
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'],
],
]);
+4 -4
View File
@@ -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;
+62 -8
View File
@@ -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;
}
function dbnToolsAuthPasswordHash(): ?string
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 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();
+2 -2
View File
@@ -12,11 +12,11 @@ $authenticated = dbnToolsIsAuthenticated();
<title>Do Better Norge Legal Tools</title>
<meta name="description" content="Do Better Norge legal preparation tools with source-grounded evidence trails.">
<meta name="robots" content="noindex,nofollow">
<link rel="canonical" href="https://tools.dobetternorge.com/">
<link rel="canonical" href="https://tools.dobetternorge.no/">
<meta property="og:title" content="Do Better Norge Legal Tools">
<meta property="og:description" content="Legal preparation tools with visible evidence trails and uncertainty notes.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://tools.dobetternorge.com/">
<meta property="og:url" content="https://tools.dobetternorge.no/">
<meta name="theme-color" content="#f7f8fb">
<link rel="stylesheet" href="assets/css/tools.css">
</head>
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This setup script must be run from the command line.\n");
exit(1);
}
$password = dbnToolsEnv('DBN_SETUP_PASSWORD');
if ($password === null || $password === '') {
fwrite(STDERR, "Set DBN_SETUP_PASSWORD for this process only.\n");
exit(1);
}
$clientSlug = dbnToolsClientSlug();
$packageSlug = dbnToolsRequiredPackageSlug();
$email = strtolower(trim(dbnToolsEnv('DBN_SETUP_EMAIL') ?: 'daveadmin@dobetternorge.no'));
$username = trim(dbnToolsEnv('DBN_SETUP_USERNAME') ?: 'daveadmin');
$displayName = trim(dbnToolsEnv('DBN_SETUP_DISPLAY_NAME') ?: 'Dave Admin');
$domain = strtolower(trim(dbnToolsEnv('DBN_SETUP_PORTAL_DOMAIN') ?: 'ai.dobetternorge.no'));
function setupColumnExists(PDO $db, string $table, string $column): bool
{
$stmt = $db->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);
}