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 # Do Better Norge Legal Tools Hub
MVP docroot for `tools.dobetternorge.com`. MVP docroot for `tools.dobetternorge.no`.
## Required environment ## 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_ENDPOINT`
- `DBN_AZURE_OPENAI_API_KEY` - `DBN_AZURE_OPENAI_API_KEY`
- `DBN_AZURE_OPENAI_API_VERSION` - `DBN_AZURE_OPENAI_API_VERSION`
@@ -14,15 +14,21 @@ MVP docroot for `tools.dobetternorge.com`.
Optional: Optional:
- `DBN_AI_PORTAL_ROOT` (defaults to sibling `ai-portal`) - `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_SUPPORT_DIR`
- `DBN_TOOLS_METADATA_LOG` - `DBN_TOOLS_METADATA_LOG`
Create the passcode hash with: ## Authentication
```bash The login form authenticates against Caveau `client_users` for the configured
php -r "echo password_hash('replace-this-passcode', PASSWORD_DEFAULT), PHP_EOL;" 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, 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. latency, language, source count, chunk count, deployment, and anonymous session id.
+9 -8
View File
@@ -8,9 +8,9 @@ dbnToolsRequireMethod('GET');
dbnToolsRequireAuth(); dbnToolsRequireAuth();
$checks = []; $checks = [];
$checks['passcode_hash'] = [ $checks['caveau_auth'] = [
'ok' => (bool)dbnToolsEnv('DBN_TOOLS_PASSCODE_HASH'), 'ok' => true,
'detail' => dbnToolsEnv('DBN_TOOLS_PASSCODE_HASH') ? 'Configured' : 'Missing DBN_TOOLS_PASSCODE_HASH', 'detail' => 'Tools login uses Caveau client_users for tenant ' . dbnToolsClientSlug(),
]; ];
$azure = new DbnAzureOpenAiGateway(); $azure = new DbnAzureOpenAiGateway();
@@ -42,15 +42,16 @@ try {
$checks['db_connectivity'] = ['ok' => true, 'detail' => 'CaveauAI admin DB reachable']; $checks['db_connectivity'] = ['ok' => true, 'detail' => 'CaveauAI admin DB reachable'];
$client = dbnToolsFetchClient($db); $client = dbnToolsFetchClient($db);
$checks['dave_jr_legal_client'] = [ $checks['dobetter_client'] = [
'ok' => (bool)$client, 'ok' => (bool)$client,
'detail' => $client ? 'Client id ' . $client['id'] . ' found' : 'Client slug ' . dbnToolsClientSlug() . ' not found', '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'] = [ $checks['family_legal_package'] = [
'ok' => (bool)$package, 'ok' => (bool)$package && !empty($package['is_active']),
'detail' => $package ? 'Package id ' . $package['id'] . ' found' : 'family-legal package not found', 'detail' => $package ? 'Package id ' . $package['id'] . ' found' : $packageSlug . ' package not found',
]; ];
$subOk = $client && $package && dbnToolsHasActiveSubscription((int)$client['id'], (int)$package['id'], $db); $subOk = $client && $package && dbnToolsHasActiveSubscription((int)$client['id'], (int)$package['id'], $db);
@@ -60,7 +61,7 @@ try {
]; ];
} catch (Throwable $e) { } catch (Throwable $e) {
$checks['db_connectivity'] = ['ok' => false, 'detail' => $e->getMessage()]; $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_package'] = ['ok' => false, 'detail' => 'Not checked'];
$checks['family_legal_subscription'] = ['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'); dbnToolsError('Password is required.', 422, 'missing_password');
} }
$configuredEmail = dbnToolsAuthEmail(); try {
$hash = dbnToolsAuthPasswordHash(); $db = dbnToolsDb();
$client = dbnToolsFetchClient($db);
if ($configuredEmail === null || trim($configuredEmail) === '' || $hash === null || trim($hash) === '') { if (!$client || empty($client['is_active'])) {
dbnToolsError('Authentication credentials are not configured.', 503, 'auth_not_configured'); dbnToolsError('Do Better Norge Caveau workspace is not active.', 503, 'client_unavailable');
} }
$emailMatch = hash_equals(strtolower(trim($configuredEmail)), $email); $user = dbnToolsFetchActiveClientUser($email, (int)$client['id'], $db);
$passwordMatch = password_verify($password, $hash); } 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'); 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_regenerate_id(true);
$_SESSION['dbn_tools_authenticated'] = true; $_SESSION['dbn_tools_authenticated'] = true;
$_SESSION['dbn_tools_authenticated_at'] = time(); $_SESSION['dbn_tools_authenticated_at'] = time();
$_SESSION['dbn_tools_anon_id'] = $_SESSION['dbn_tools_anon_id'] ?? bin2hex(random_bytes(16)); $_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([ dbnToolsRespond([
'ok' => true, 'ok' => true,
'authenticated' => true, 'authenticated' => true,
'session' => dbnToolsAnonymousSessionId(), '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)); $limit = max(1, min(10, $limit));
$trace = [ $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'), $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'); dbnToolsAbort('The family-legal corpus package is not active.', 503, 'package_unavailable');
} }
if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'])) { 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; return $package;
} }
@@ -468,7 +468,7 @@ PROMPT;
return [ return [
'title' => $title, 'title' => $title,
'excerpt' => dbnToolsExcerpt((string)($chunk['content'] ?? ''), 620), '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, 'score' => $score,
'document_id' => isset($chunk['document_id']) ? (int)$chunk['document_id'] : null, 'document_id' => isset($chunk['document_id']) ? (int)$chunk['document_id'] : null,
'chunk_id' => isset($chunk['id']) ? (int)$chunk['id'] : null, 'chunk_id' => isset($chunk['id']) ? (int)$chunk['id'] : null,
@@ -535,7 +535,7 @@ PROMPT;
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$row) { foreach ($rows as &$row) {
$row['similarity'] = 0.25; $row['similarity'] = 0.25;
$row['source_name'] = 'Dave Jr Legal private corpus'; $row['source_name'] = 'Do Better Norge private corpus';
$row['source_type'] = 'private'; $row['source_type'] = 'private';
} }
return $rows; return $rows;
+62 -8
View File
@@ -110,17 +110,29 @@ dbnToolsStartSession();
function dbnToolsIsAuthenticated(): bool 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 function dbnToolsAnonymousSessionId(): string
@@ -168,7 +180,7 @@ function dbnToolsRequireMethod(string $method): void
function dbnToolsRequireAuth(): void function dbnToolsRequireAuth(): void
{ {
if (!dbnToolsIsAuthenticated()) { 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 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 function dbnToolsFetchClient(?PDO $db = null): ?array
@@ -370,11 +382,53 @@ function dbnToolsRequireClient(): array
{ {
$client = dbnToolsFetchClient(); $client = dbnToolsFetchClient();
if (!$client || empty($client['is_active'])) { 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; 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 function dbnToolsFetchPackage(string $slug = 'family-legal', ?PDO $db = null): ?array
{ {
$db = $db ?: dbnToolsDb(); $db = $db ?: dbnToolsDb();
+2 -2
View File
@@ -12,11 +12,11 @@ $authenticated = dbnToolsIsAuthenticated();
<title>Do Better Norge Legal Tools</title> <title>Do Better Norge Legal Tools</title>
<meta name="description" content="Do Better Norge legal preparation tools with source-grounded evidence trails."> <meta name="description" content="Do Better Norge legal preparation tools with source-grounded evidence trails.">
<meta name="robots" content="noindex,nofollow"> <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: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:description" content="Legal preparation tools with visible evidence trails and uncertainty notes.">
<meta property="og:type" content="website"> <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"> <meta name="theme-color" content="#f7f8fb">
<link rel="stylesheet" href="assets/css/tools.css"> <link rel="stylesheet" href="assets/css/tools.css">
</head> </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);
}