feat: admin controls

This commit is contained in:
Zoe
2026-10-15 10:07:41 -05:00
parent b6f4d85ed7
commit ff7797d3f8
41 changed files with 612 additions and 427 deletions
+10 -12
View File
@@ -5,7 +5,7 @@ import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64
const emit = defineEmits(['confirm', 'cancel']);
const { user } = useAuth();
const { providers } = await useModels();
const { providers, refresh } = await useModels();
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
@@ -45,18 +45,19 @@ const importKey = (jwk: JsonWebKey) =>
// one. The server never sees the plaintext keys.
const decryptProviderKeys = async (
oldJwk: JsonWebKey,
): Promise<Result<{ id: string; plaintext: string }[], string[]>> => {
): Promise<Result<{ id: string; originalApiKey: string; plaintext: string }[], string[]>> => {
const oldKey = await importKey(oldJwk);
const decrypted: { id: string; plaintext: string }[] = [];
const decrypted: { id: string; originalApiKey: string; plaintext: string }[] = [];
const failed: string[] = [];
for (const provider of providers.value) {
if (!provider.config.apiKey) continue;
try {
const plaintext = await decrypt(oldKey, base64ToUint8Array(provider.config.apiKey));
decrypted.push({ id: provider.id, plaintext });
const originalApiKey = provider.config.apiKey;
const plaintext = await decrypt(oldKey, base64ToUint8Array(originalApiKey));
decrypted.push({ id: provider.id, originalApiKey, plaintext });
} catch {
failed.push(provider.name);
}
@@ -101,9 +102,9 @@ const submit = async () => {
steps[1]!.state = 'active';
const newJwk = await deriveKey(form.next, userId);
const newKey = await importKey(newJwk);
const providerKeys: { id: string; apiKey: string }[] = [];
for (const { id, plaintext } of decrypted.data) {
providerKeys.push({ id, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
const providerKeys: { id: string; originalApiKey: string; apiKey: string }[] = [];
for (const { id, originalApiKey, plaintext } of decrypted.data) {
providerKeys.push({ id, originalApiKey, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
}
steps[1]!.state = 'done';
@@ -142,10 +143,7 @@ const submit = async () => {
// from here on this browser has to use the new key
localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
providers.value = providers.value.map(p =>
newKeys.has(p.id) ? { ...p, config: { ...p.config, apiKey: newKeys.get(p.id)! } } : p
);
await refresh();
// the server signs the other devices out as part of the same request
steps[2]!.state = 'done';
+6 -4
View File
@@ -13,7 +13,8 @@ const props = defineProps<{
const emit = defineEmits(['confirm', 'cancel']);
// also turn two-factor off and sign every device out when the link is used
// also turn two-factor off when the link is used, every device is signed out
// either way
const resetSecurity = ref(true);
const busy = ref(false);
@@ -96,14 +97,15 @@ const revoke = async () => {
were given before.
</p>
<div v-if="!user.pending" role="note" class="flex gap-2.5 px-3 py-2.5 rounded-lg border border-red-400/40 bg-red-600/12 text-sm leading-normal">
<div v-if="!user.pending" role="note"
class="flex gap-2.5 px-3 py-2.5 rounded-lg border border-red-400/40 bg-red-600/12 text-sm leading-normal">
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<div class="flex flex-col gap-0.5">
<strong class="font-semibold">This deletes their saved API keys</strong>
<span class="text-[13px] text-[var(--text-secondary)]">
The keys are encrypted with the old password and nobody can decrypt them without it. They
are removed when the link is used, not when it is created. Chats, agents and files are
kept.
not deleted. Using the link signs them out on all devices.
</span>
</div>
</div>
@@ -135,7 +137,7 @@ const revoke = async () => {
<label v-if="!user.pending" class="flex items-start gap-2.5 text-sm cursor-pointer">
<input v-model="resetSecurity" type="checkbox" :disabled="busy" class="mt-0.75 flex-shrink-0"
style="accent-color: var(--color-accent)" />
<span>Also reset two-factor and sign out all their sessions</span>
<span>Also reset two-factor sign-in</span>
</label>
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
+2 -5
View File
@@ -88,7 +88,7 @@ const submitPassword = async () => {
if (action.value === 'enable') {
const { data, error: err } = await authClient.twoFactor.enable(body);
if (err || !data) {
if (err || !data || data.method !== 'totp') {
error.value = describeError(err, 'Two-factor could not be set up. Try again.');
} else {
// nothing is turned on until a code from the app is verified
@@ -136,12 +136,9 @@ const verifyCode = async () => {
code.value = '';
busy.value = false;
// the boxes are disabled while busy and can't take focus until now
await nextTick();
codeInputEl.value?.focus();
} else {
// set here rather than by whoever opened the dialog, so it is right
// even if the dialog gets dismissed on the last step
if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
step.value = 'codes';
}
@@ -155,7 +152,7 @@ const numberedBackupCodes = computed(() =>
);
const startCheck = async () => {
// a different code every time, so going back to peek at one is not enough
// a different code every time
checkIndex.value = Math.floor(Math.random() * backupCodes.value.length);
checkInput.value = '';
error.value = '';
+11 -2
View File
@@ -97,10 +97,13 @@ const submit = async () => {
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
}
await $fetch(`/api/provider/${provider.id}`, {
const saved = await $fetch(`/api/provider/${provider.id}`, {
method: 'PATCH',
body: { config, enabled: true },
body: { config, enabled: true, expectedUpdatedAt: new Date(provider.updatedAt).toISOString() },
});
providers.value = providers.value.map(p => p.id === provider.id
? { ...p, config, enabled: true, updatedAt: new Date(saved.updatedAt) }
: p);
} catch (err) {
console.error('Failed to save provider:', err);
error.value = 'The provider could not be saved. Try again.';
@@ -146,6 +149,12 @@ const submit = async () => {
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
: p);
if (!models.some(model => model.outputModalities.includes("text") && (model.enabled || enabledIds.has(model.id)))) {
error.value = "The models were fetched, but none could be enabled. Try again.";
busy.value = false;
return;
}
emit('done');
};
</script>
+8 -3
View File
@@ -134,7 +134,7 @@ export const useModels = async () => {
});
}
const updateProvider = async (id: string, updates: Partial<Provider>) => {
const updateProvider = async (id: string, updates: Partial<Provider>, expectedUpdatedAt?: string) => {
const provider = providers.value.find(p => p.id === id);
if (!provider) return;
@@ -142,10 +142,14 @@ export const useModels = async () => {
await $fetch(`/api/provider/${id}`, {
method: 'PATCH',
body: updates,
body: {
...updates,
expectedUpdatedAt: expectedUpdatedAt ?? new Date(original.updatedAt).toISOString(),
},
onRequest() {
// a config update only carries the fields that changed
providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p
p.id === id ? { ...p, ...updates, config: { ...p.config, ...updates.config } } : p
);
},
onRequestError() {
@@ -166,6 +170,7 @@ export const useModels = async () => {
return {
providers,
refresh,
allModels,
getModel,
getProvider,
+1 -1
View File
@@ -259,7 +259,7 @@ const submitTwoFactor = async () => {
<div class="flex flex-col gap-1.5">
<label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email"
<input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
+10 -2
View File
@@ -96,7 +96,8 @@ const submit = async () => {
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -105,6 +106,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
@@ -159,10 +162,15 @@ const submit = async () => {
<span class="i-tabler-trash text-4 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<span>Deleted: API keys for <span class="case-capital">{{ recovery.keyProviders.join(', ') }}</span></span>
</li>
<li class="flex items-start gap-2">
<span class="i-tabler-logout text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Signed out: every device that is signed in to this account</span>
</li>
<li v-if="recovery.resetSecurity" class="flex items-start gap-2">
<span class="i-tabler-shield-off text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Reset: two-factor sign-in, all active sessions</span>
<span>Reset: two-factor sign-in</span>
</li>
<li class="flex items-start gap-2">
<span class="i-tabler-circle-check text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
+4 -1
View File
@@ -5,7 +5,8 @@ definePageMeta({
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -14,6 +15,8 @@ definePageMeta({
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
+1 -1
View File
@@ -228,7 +228,7 @@ const submit = async () => {
<div class="flex flex-col gap-1.5">
<label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email"
<input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
+5 -2
View File
@@ -136,7 +136,8 @@ const submit = async () => {
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -145,6 +146,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
@@ -183,7 +186,7 @@ const submit = async () => {
<div class="flex flex-col gap-1.5">
<label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email"
<input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
+8 -10
View File
@@ -131,7 +131,7 @@ const setRole = (user: UserRow, role: 'admin' | 'user') => {
confirmThen({
title: `Make ${user.name} an admin?`,
message: 'Admins can create, disable and delete accounts, change roles and change who can register. They never see anyone else\'s chats, files or API keys.',
message: 'Admins can create, disable and delete accounts, change roles and change who can register.',
confirmLabel: 'Make admin',
}, change);
};
@@ -141,8 +141,6 @@ const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not b
return !error;
});
// The dialog makes the link and shows it, nothing in the list changes. For an
// account that has no password yet it is a new setup link instead.
const recoverAccount = (user: UserRow) => {
openDialog(DialogType.RecoverAccount, undefined, { user });
};
@@ -268,9 +266,11 @@ const openCreateUser = () => {
<span class="flex flex-col min-w-0">
<span class="font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{{ u.name }}
<span v-if="u.id === me?.id" class="font-normal text-[var(--text-secondary)]">(you)</span>
<span v-if="u.id === me?.id"
class="font-normal text-[var(--text-secondary)]">(you)</span>
</span>
<span class="text-xs text-[var(--text-secondary)] whitespace-nowrap overflow-hidden text-ellipsis">
<span
class="text-xs text-[var(--text-secondary)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ u.email }}
</span>
</span>
@@ -308,7 +308,6 @@ const openCreateUser = () => {
<span class="i-svg-spinners-180-ring-with-bg text-4 text-[var(--text-secondary)]"></span>
</span>
<!-- your own account is managed from Profile and Security -->
<Dropdown v-else-if="u.id !== me?.id" placement="bottom-end" dropdownClass="w-60">
<template #default="{ setRef, isOpen, toggle }">
<button :ref="setRef" type="button" :aria-label="`Actions for ${u.name}`"
@@ -376,10 +375,9 @@ const openCreateUser = () => {
</div>
<p class="mt-4 max-w-190 text-[13px] leading-relaxed text-[var(--text-secondary)]">
Admins manage accounts, not content: chats, agents, files and API keys of other users are never
shown here. Invited accounts have no password until their owner opens the setup link. Disabled
accounts can't sign in and nothing of theirs is deleted. Your own account is
managed from Profile and Security, and you can't remove your own admin role.
Invited accounts have no password until their owner opens the setup link.
you manage your account from Profile and Security, you can't remove
your own admin role.
</p>
</div>
</template>
+57 -37
View File
@@ -68,55 +68,73 @@ const toggleProvider = async () => {
});
};
let apiKeyTimeout: NodeJS.Timeout | undefined;
let apiKeyTimeout: ReturnType<typeof setTimeout> | undefined;
let proxyUrlTimeout: ReturnType<typeof setTimeout> | undefined;
const configError = ref("");
const updateApiKey = async (value: string) => {
if (!provider.value) return;
// Config saves run one after the other, and each reads the revision only when
// it is sent. A save that was typed while another was still on its way would
// otherwise carry the revision from before that one and be turned down.
let configSaves: Promise<void> = Promise.resolve();
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
);
const encypted = await encryptData(key, value);
const saveConfig = (
id: string,
failure: string,
config: () => Promise<{ apiKey?: string; apiProxyUrl?: string }>,
expectedEncryptionKey?: string,
) => {
configSaves = configSaves.then(async () => {
// A queued edit must not pair an old encryption key with a new revision.
if (expectedEncryptionKey !== undefined && localStorage.getItem("encryptionKey") !== expectedEncryptionKey) {
configError.value = failure;
return;
}
const current = providers.value.find(p => p.id === id);
if (!current) {
return;
}
configError.value = "";
try {
await updateProvider(id, { config: await config() }, new Date(current.updatedAt).toISOString());
} catch {
configError.value = failure;
}
});
};
if (apiKeyTimeout) {
const updateApiKey = (value: string) => {
const snapshot = provider.value;
const storedKey = localStorage.getItem("encryptionKey");
if (!snapshot || !storedKey) {
return;
}
clearTimeout(apiKeyTimeout);
}
apiKeyTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiKey: uint8ArrayToBase64(encypted),
},
});
apiKeyTimeout = setTimeout(() => {
saveConfig(snapshot.id, "The key could not be saved. Reload this page before trying again.", async () => {
const key = await crypto.subtle.importKey("jwk", JSON.parse(storedKey), "AES-GCM", false, ["encrypt", "decrypt"]);
const encrypted = await encryptData(key, value);
return { apiKey: uint8ArrayToBase64(encrypted) };
}, storedKey);
}, 700);
};
let proxyUrlTimeout: NodeJS.Timeout | undefined;
const updateProxyUrl = async (value: string) => {
if (!provider.value) return;
const updateProxyUrl = (value: string) => {
const snapshot = provider.value;
if (!snapshot) {
return;
}
apiProxyUrl.value = value;
if (proxyUrlTimeout) {
clearTimeout(proxyUrlTimeout);
}
proxyUrlTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiProxyUrl: value,
},
});
proxyUrlTimeout = setTimeout(() => {
saveConfig(snapshot.id, "The address could not be saved. Reload this page before trying again.", async () => ({ apiProxyUrl: value }));
}, 700);
};
onUnmounted(() => {
clearTimeout(apiKeyTimeout);
clearTimeout(proxyUrlTimeout);
});
const fetchingModels = ref(false);
const fetchModels = async () => {
@@ -407,6 +425,8 @@ const isFormValid = computed(() => {
</div>
</div>
<p v-if="configError" role="alert" class="text-sm text-red-400">{{ configError }}</p>
<div class="flex flex-row justify-center text-xs">
<p class="text-[var(--text-secondary)]">
<span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using
+2 -4
View File
@@ -27,8 +27,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
</h2>
<div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)"
:key="p.id"
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)" :key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2">
@@ -51,8 +50,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
</h2>
<div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)"
:key="p.id"
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2">
+4 -1
View File
@@ -83,7 +83,8 @@ const submit = async () => {
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -92,6 +93,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
+4 -1
View File
@@ -15,7 +15,8 @@ const back = () => navigateTo('/setup/instance');
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -24,6 +25,8 @@ const back = () => navigateTo('/setup/instance');
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
+4 -4
View File
@@ -9,14 +9,15 @@ const finish = () => navigateTo(to ?? '/');
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<!-- Background decorative blobs -->
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
@@ -25,8 +26,7 @@ const finish = () => navigateTo(to ?? '/');
</div>
<p class="text-sm leading-normal text-center text-[var(--text-secondary)]">
Veridian runs on your own API keys. Add one provider now and its models are ready when you
land on Home.
Veridian runs on your own API keys. Add one now and it'll be ready for you're ready to get started.
</p>
<OnboardingProviderForm submit-label="Fetch models and continue" @done="finish"></OnboardingProviderForm>
+2 -1
View File
@@ -385,7 +385,8 @@ export const recoveryLinks = pgTable(
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// also turn two-factor off and sign every device out when it is used
// also turn two-factor off when it is used, every device is signed out
// either way
resetSecurity: boolean("reset_security").notNull().default(true),
expiresAt: timestamptz("expires_at").notNull(),
createdAt: timestamptz("created_at").defaultNow().notNull(),
+48 -29
View File
@@ -1,5 +1,4 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import { db } from "../server/lib/db";
import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
import { betterAuth } from 'better-auth/minimal';
import { createAccessControl } from 'better-auth/plugins/access';
@@ -8,23 +7,26 @@ import { defaultStatements } from 'better-auth/plugins/admin/access';
import { twoFactor } from 'better-auth/plugins/two-factor';
import * as schema from '../drizzle/schema';
import { getRegistrationMode } from '../server/utils/instance';
import { claimInvite, INVITE_TOKEN_HEADER, releaseInvite } from '../server/utils/invites';
import { hasUsableAdministrator } from '../server/lib/account-security';
import { authDatabase, currentTransaction } from '../server/lib/auth-transaction';
import { claimInvite, INVITE_TOKEN_HEADER } from '../server/utils/invites';
import { isSetupNeeded } from '../server/utils/setup';
const MIN_PASSWORD_LENGTH = 8;
const ADMIN_MUTATIONS = new Set(['/admin/set-role', '/admin/ban-user', '/admin/unban-user', '/admin/update-user']);
const transactionRequired = () => new APIError('INTERNAL_SERVER_ERROR', {
message: 'This has to run in a transaction',
code: 'TRANSACTION_REQUIRED',
});
const accessControl = createAccessControl(defaultStatements);
// Admins manage accounts, not content. Signing in as someone else and setting
// another user's password or email are left out on purpose: the first would
// show their chats, the others would lock them out of their encrypted API keys.
// Deleting is left out too, because Better Auth would only remove the account
// and leave its agents, topics and files behind. Creating is left out so new
// accounts are always made without a password and with a setup link.
// /api/admin/users does both.
const roles = {
admin: accessControl.newRole({
user: ['list', 'set-role', 'ban', 'get', 'update'],
session: ['list', 'revoke', 'delete'],
user: ['set-role', 'ban'],
session: ['revoke'],
}),
user: accessControl.newRole({
user: [],
@@ -33,7 +35,7 @@ const roles = {
};
export const auth = betterAuth({
database: drizzleAdapter(db, {
database: drizzleAdapter(authDatabase, {
provider: "pg",
schema,
usePlural: true,
@@ -44,7 +46,7 @@ export const auth = betterAuth({
updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60,
maxAge: 60,
},
},
@@ -53,8 +55,21 @@ export const auth = betterAuth({
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Who may register is an instance setting admins change at runtime.
if (ctx.path === "/change-password" && ctx.request) {
throw new APIError("FORBIDDEN", {
message: "Use the account password endpoint",
code: "USE_ACCOUNT_PASSWORD_ENDPOINT",
});
}
if (ctx.path === '/sign-up/email') {
if (await isSetupNeeded()) {
throw new APIError('FORBIDDEN', {
message: 'This instance has not been set up yet',
code: 'SETUP_NEEDED',
});
}
const mode = await getRegistrationMode();
if (mode === 'closed') {
@@ -65,9 +80,12 @@ export const auth = betterAuth({
}
if (mode === 'invite') {
const tx = currentTransaction();
if (!tx) throw transactionRequired();
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
if (!token || !await claimInvite(token)) {
if (!token || !await claimInvite(tx, token)) {
throw new APIError('FORBIDDEN', {
message: 'This invite link is not valid anymore',
code: 'INVITE_INVALID',
@@ -76,9 +94,6 @@ export const auth = betterAuth({
}
}
// Creating a user as an admin skips the sign up password rules. No
// password at all is fine: that account can't be signed in to until
// its owner sets one with a setup link.
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
const password = ctx.body.password;
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
@@ -89,12 +104,13 @@ export const auth = betterAuth({
}
}
// The admin endpoints already stop admins from removing or disabling
// themselves. Giving up their own admin role is the one way left to
// end up with an instance nobody can manage, so that is refused too.
if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
if (!ADMIN_MUTATIONS.has(ctx.path)) return;
const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role;
if (!currentTransaction()) throw transactionRequired();
if (ctx.path !== '/admin/set-role') return;
const role = ctx.body?.role;
if (role === undefined) return;
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
@@ -109,14 +125,17 @@ export const auth = betterAuth({
}
}),
// The invite was claimed before the account was made. When making it
// then fails, say because the email is taken, the use is given back.
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-up/email' || !isAPIError(ctx.context.returned)) return;
if (!ADMIN_MUTATIONS.has(ctx.path) || isAPIError(ctx.context.returned)) return;
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
if (token && await getRegistrationMode() === 'invite') {
await releaseInvite(token);
const tx = currentTransaction();
if (!tx) throw transactionRequired();
if (!await hasUsableAdministrator(tx)) {
throw new APIError('CONFLICT', {
message: 'The instance must keep a usable administrator',
code: 'LAST_ADMIN',
});
}
}),
},
-1
View File
@@ -167,7 +167,6 @@ export default defineNuxtConfig({
},
public: {
disableSignup: process.env.DISABLE_SIGNUP === 'true' || process.env.DISABLE_SIGNUP === '1',
publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
}
},
+33 -38
View File
@@ -1,65 +1,60 @@
/**
* Prints a one-time recovery link for an account. Meant for whoever runs the
* server when nobody can sign in as an admin anymore:
*
* bun run recover <email>
*
* It only talks to the database, the server does not have to be running. The
* link works like the ones admins make under Settings → Users: it lets the
* account set a new password, deletes its saved API keys, turns two-factor
* off and signs every device out.
*
* Kept free of imports from the app so it also runs in the production image.
* The link has to be made the same way as in server/utils/recovery.ts.
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Pool } from 'pg';
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { Pool } from "pg";
const LIFETIME_MS = 24 * 60 * 60 * 1000;
// Standalone so operators can recover an account without running the server.
const main = async (): Promise<number> => {
const email = process.argv[2]?.trim().toLowerCase();
if (!email) {
console.error('Usage: bun run recover <email>');
console.error("Usage: bun run recover <email>");
return 1;
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
connectionString: process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/veridian",
});
const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]);
const user = found.rows[0] as { id: string; name: string } | undefined;
const client = await pool.connect();
try {
await client.query("BEGIN");
// Same user-row lock as the web issuance and redemption endpoints.
const found = await client.query<{ id: string; name: string }>(
"SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email],
);
const user = found.rows[0];
if (!user) {
await client.query("ROLLBACK");
console.error(`No account with the email ${email}.`);
await pool.end();
return 1;
}
const token = randomBytes(32).toString('base64url');
const tokenHash = createHash('sha256').update(token).digest('hex');
const token = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(token).digest("hex");
const expiresAt = new Date(Date.now() + LIFETIME_MS);
// only one link works at a time
await pool.query('DELETE FROM recovery_links WHERE user_id = $1', [user.id]);
await pool.query(
'INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)',
await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
await client.query(
"INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)",
[randomUUID(), tokenHash, user.id, expiresAt],
);
await pool.end();
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
await client.query("COMMIT");
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? "http://localhost:3000").replace(/\/$/, "");
console.log(`Recovery link for ${user.name} <${email}>:`);
console.log('');
console.log("");
console.log(` ${baseUrl}/auth/recover/${token}`);
console.log('');
console.log('It works once and expires in 24 hours. Using it deletes the saved API keys');
console.log('of this account, turns two-factor off and signs every device out.');
console.log("");
console.log("It works once and expires in 24 hours. Using it deletes the saved API keys");
console.log("of this account, turns two-factor off and signs every device out.");
return 0;
} catch (error) {
await client.query("ROLLBACK");
console.error("Failed to create recovery link:", error);
return 1;
} finally {
client.release();
await pool.end();
}
};
process.exit(await main());
+13 -15
View File
@@ -1,7 +1,6 @@
import { and, eq, ne } from 'drizzle-orm';
import * as z from 'zod';
import { users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { hasUsableAdministrator } from '~~/server/lib/account-security';
import { db } from '~~/server/lib/db';
/**
@@ -43,24 +42,17 @@ export default defineEventHandler(async (event) => {
const twoFactorEnabled = !!account?.twoFactorEnabled;
// an instance always keeps one admin, someone has to be able to manage it
if (account?.role === 'admin') {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, 'admin'), ne(users.id, userId)))
.limit(1);
if (otherAdmins.length === 0) {
// Checked again when the account is deleted. Doing it here as well keeps
// a deletion that is refused anyway from using up a backup code.
if (account?.role === 'admin' && !await db.transaction(tx => hasUsableAdministrator(tx, userId))) {
throw createError({
statusCode: 409,
statusMessage: 'The last admin cannot be deleted',
statusMessage: 'The last usable admin cannot be deleted',
data: {
code: 'LAST_ADMIN',
},
});
}
}
if (twoFactorEnabled && code === undefined) {
throw createError({
@@ -111,7 +103,14 @@ export default defineEventHandler(async (event) => {
});
}
await deleteAccount(userId);
const deleted = await deleteAccount(userId);
if (!deleted.ok) {
throw createError({
statusCode: 409,
statusMessage: "The last usable admin cannot be deleted",
data: { code: deleted.error },
});
}
// the session is gone, but the browser would keep trusting its cached
// copy for a few minutes unless the cookies go too
@@ -121,7 +120,6 @@ export default defineEventHandler(async (event) => {
deleteCookie(event, cookie.name, {
path: cookie.attributes.path,
secure: cookie.attributes.secure,
sameSite: cookie.attributes.sameSite,
httpOnly: cookie.attributes.httpOnly,
});
}
+47 -81
View File
@@ -1,116 +1,82 @@
import { and, eq } from 'drizzle-orm';
import * as z from 'zod';
import { providers } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
import { and, eq, sql } from "drizzle-orm";
import * as z from "zod";
import { providers, recoveryLinks } from "~~/drizzle/schema";
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
import { auth } from "~~/lib/auth";
import { authTransaction } from "~~/server/lib/auth-transaction";
/**
* Changes the password and swaps in the provider API keys re-encrypted with
* the new password in one go. API keys are encrypted in the browser with a key
* derived from the password, so a password change that leaves the old
* ciphertexts behind would make every saved key unreadable.
*/
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const result = await readValidatedBody(event, (body) =>
z
.object({
currentPassword: z.string().min(1),
const { user } = await protectRoute(event);
const result = await readValidatedBody(event, (body) => z.object({
currentPassword: z.string().min(1).max(128),
newPassword: z.string().min(8).max(128),
providerKeys: z.array(z.object({
id: z.string(),
originalApiKey: z.string().min(1),
apiKey: z.string().min(1),
})),
})
.safeParse(body),
);
}).safeParse(body));
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
}
const { currentPassword, newPassword, providerKeys } = result.data;
const replacements = new Map(providerKeys.map(key => [key.id, key]));
let cookies: string[];
const existing = await db.query.providers.findMany({
where: {
userId,
},
});
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
// Every saved key has to come back re-encrypted, otherwise it would be
// stuck encrypted with the old password.
const withKey = existing.filter(p => !!p.config.apiKey);
const complete = withKey.every(p => newKeys.has(p.id)) && newKeys.size === withKey.length;
try {
cookies = await authTransaction(async (tx) => {
await lockAccount(tx, user.id);
const existing = await tx.select().from(providers).where(eq(providers.userId, user.id));
const withKey = existing.filter(provider => !!provider.config.apiKey);
const complete = replacements.size === providerKeys.length
&& replacements.size === withKey.length
&& withKey.every(provider => replacements.get(provider.id)?.originalApiKey === provider.config.apiKey);
if (!complete) {
throw createError({
statusCode: 409,
statusMessage: 'Provider keys out of date',
data: {
code: 'PROVIDER_KEYS_OUT_OF_DATE',
},
statusMessage: "Provider keys out of date",
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
});
}
let cookies: string[] = [];
try {
// The password change runs inside the transaction so a rejected
// password rolls the key updates back.
await db.transaction(async (tx) => {
for (const provider of withKey) {
await tx.update(providers)
.set({
config: {
...provider.config,
apiKey: newKeys.get(provider.id)!,
},
})
.where(and(eq(providers.id, provider.id), eq(providers.userId, userId)));
}
const { headers } = await auth.api.changePassword({
body: {
currentPassword,
newPassword,
// other devices still hold an encryption key derived from the
// old password, so they have to sign in again
revokeOtherSessions: true,
},
body: { currentPassword, newPassword, revokeOtherSessions: true },
headers: event.headers,
returnHeaders: true,
});
cookies = headers.getSetCookie();
});
} catch (error: any) {
const code = error?.body?.code as string | undefined;
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, user.id));
await revokeTwoFactorTrust(tx, user.id);
if (code === undefined) {
console.error('Failed to change password:', error);
// Invalidate pending config writes even for providers without a key.
const updatedAt = new Date(Math.max(Date.now(), ...existing.map(provider => provider.updatedAt.getTime() + 1)));
await tx.update(providers).set({ updatedAt }).where(eq(providers.userId, user.id));
for (const key of providerKeys) {
await tx.update(providers).set({
config: sql`jsonb_set(${providers.config}, '{apiKey}', ${JSON.stringify(key.apiKey)}::jsonb)`,
updatedAt,
}).where(and(eq(providers.id, key.id), eq(providers.userId, user.id)));
}
throw createError({
statusCode: code === undefined ? 500 : 400,
statusMessage: 'Failed to change password',
message: error?.body?.message ?? 'Failed to change password',
data: {
code: code ?? 'INTERNAL_ERROR',
},
return headers.getSetCookie();
});
} catch (error) {
if (error instanceof Error && "body" in error) {
const body = z.object({ code: z.string(), message: z.string().optional() }).safeParse(error.body);
if (body.success) {
throw createError({ statusCode: 400, message: body.data.message, data: { code: body.data.code } });
}
}
throw error;
}
// No replacement session reaches the browser until its transaction commits.
for (const cookie of cookies) {
appendResponseHeader(event, 'set-cookie', cookie);
appendResponseHeader(event, "set-cookie", cookie);
}
return { ok: true };
});
+1 -9
View File
@@ -1,17 +1,9 @@
import { and, eq } from 'drizzle-orm';
import { sessions } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
const sessionData = await auth.api.getSession({ headers: event.headers });
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const sessionData = await protectRoute(event);
const id = getRouterParam(event, 'id')!;
+1 -9
View File
@@ -1,4 +1,3 @@
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
@@ -6,14 +5,7 @@ import { db } from '~~/server/lib/db';
* out on purpose: the client only ever needs the id to sign a device out.
*/
export default defineEventHandler(async (event) => {
const sessionData = await auth.api.getSession({ headers: event.headers });
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const sessionData = await protectRoute(event);
const result = await db.query.sessions.findMany({
where: {
+8 -1
View File
@@ -34,7 +34,14 @@ export default defineEventHandler(async (event) => {
});
}
await deleteAccount(id);
const deleted = await deleteAccount(id);
if (!deleted.ok) {
throw createError({
statusCode: 409,
statusMessage: "The last usable admin cannot be deleted",
data: { code: deleted.error },
});
}
return { ok: true };
});
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) =>
z
.object({
// also turn two-factor off and sign every device out
// also turn two-factor off, every device is signed out either way
resetSecurity: z.boolean(),
})
.safeParse(body),
@@ -1,6 +1,7 @@
import { eq } from 'drizzle-orm';
import { twoFactors, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
/**
* Turns two-factor sign-in off for a user who lost their authenticator app
@@ -26,6 +27,8 @@ export default defineEventHandler(async (event) => {
}
await db.transaction(async (tx) => {
await lockAccount(tx, id);
await revokeTwoFactorTrust(tx, id);
await tx.delete(twoFactors).where(eq(twoFactors.userId, id));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
});
+44 -3
View File
@@ -1,5 +1,46 @@
import { auth } from '~~/lib/auth';
import { auth } from "~~/lib/auth";
import { lockAdministration } from "~~/server/lib/account-security";
import { authTransaction } from "~~/server/lib/auth-transaction";
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event));
const SIGN_UP = "/api/auth/sign-up/email";
// Requests that have to be undone as a whole when they fail. The hooks in
// lib/auth.ts refuse to run these without a transaction, so a URL that slips
// past this list is rejected there instead of going through unguarded.
const ADMIN_MUTATIONS = new Set([
"/api/auth/admin/set-role",
"/api/auth/admin/ban-user",
"/api/auth/admin/unban-user",
"/api/auth/admin/update-user",
]);
export default defineEventHandler(async (event) => {
const request = toWebRequest(event);
const path = new URL(request.url).pathname.replace(/\/+$/, "");
const adminMutation = ADMIN_MUTATIONS.has(path);
if (request.method !== "POST" || (!adminMutation && path !== SIGN_UP)) {
return auth.handler(request);
}
try {
return await authTransaction(async (tx) => {
if (adminMutation) {
// Deletion, banning and demotion all wait for each other here.
await lockAdministration(tx);
}
const response = await auth.handler(request);
if (!response.ok) {
// The auth handler returns errors as responses; roll back any writes.
throw response;
}
return response;
});
} catch (error) {
if (error instanceof Response) {
return error;
}
throw error;
}
});
+5 -2
View File
@@ -22,8 +22,11 @@ export default defineEventHandler(async (event) => {
// Path 2: Session auth (for client-side access)
if (!authorized) {
try {
const sessionData = await auth.api.getSession({ headers: event.headers });
if (sessionData) {
const sessionData = await auth.api.getSession({
headers: event.headers,
query: { disableCookieCache: true },
});
if (sessionData && !sessionData.user.banned) {
event.context.user = sessionData.user;
authorized = true;
}
+17 -1
View File
@@ -1,3 +1,19 @@
import { generateFileToken } from '~~/server/utils/file-token';
const FILE_PREFIX = '/api/files/';
// Uploaded avatars are served to signed in people only, and whoever opens an
// invite is not signed in yet. A short-lived signed URL lets them see it.
const publicImage = (image: string | null): string | null => {
const secret = process.env.BETTER_AUTH_SECRET;
if (!image || !image.startsWith(FILE_PREFIX) || !secret) return image;
const { exp, sig } = generateFileToken(image.slice(FILE_PREFIX.length), secret);
return `${image}?exp=${exp}&sig=${sig}`;
};
/**
* Tells the register page whether an invite link still works and who it is
* from. Public on purpose, the person opening the link has no account yet.
@@ -16,7 +32,7 @@ export default defineEventHandler(async (event) => {
inviter: {
id: invite.inviterId,
name: invite.inviterName,
image: invite.inviterImage,
image: publicImage(invite.inviterImage),
},
};
});
+40 -45
View File
@@ -1,68 +1,63 @@
import { and, eq } from "drizzle-orm";
import { providers } from "~~/drizzle/schema";
import { and, eq, gt } from "drizzle-orm";
import * as z from "zod";
import { providers, sessions } from "~~/drizzle/schema";
import { lockAccount } from "~~/server/lib/account-security";
import { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event, (body) =>
z
.object({
const { user, session } = await protectRoute(event);
const result = await readValidatedBody(event, (body) => z.object({
config: z.object({
apiKey: z.string().optional(),
apiProxyUrl: z.string().optional(),
}).optional(),
expectedUpdatedAt: z.iso.datetime().optional(),
enabled: z.boolean().optional(),
})
.safeParse(body),
);
}).refine(body => body.config === undefined || body.expectedUpdatedAt !== undefined, {
message: "Provider config updates require a revision",
}).safeParse(body));
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
}
const providerId = getRouterParam(event, 'providerId')!;
const providerId = getRouterParam(event, "providerId");
if (!providerId) {
throw createError({ statusCode: 400, statusMessage: "Invalid provider" });
}
const { config, enabled, expectedUpdatedAt } = result.data;
const { config, enabled } = result.data;
const existing = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
return await db.transaction(async (tx) => {
await lockAccount(tx, user.id);
// A request may have waited behind a password rotation or recovery.
const [activeSession] = await tx.select({ id: sessions.id }).from(sessions).where(and(
eq(sessions.id, session.id), eq(sessions.userId, user.id), gt(sessions.expiresAt, new Date()),
));
if (!activeSession) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const [existing] = await tx.select().from(providers).where(and(
eq(providers.id, providerId), eq(providers.userId, user.id),
));
if (!existing) {
throw createError({ statusCode: 404, statusMessage: "Invalid provider" });
}
if (config !== undefined && existing.updatedAt.getTime() !== new Date(expectedUpdatedAt ?? "").getTime()) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
statusCode: 409,
statusMessage: "Provider config out of date",
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
});
}
const res = await db.update(providers)
.set({
config,
const updatedAt = new Date(Math.max(Date.now(), existing.updatedAt.getTime() + 1));
await tx.update(providers).set({
config: config === undefined ? undefined : { ...existing.config, ...config },
enabled,
})
.where(
and(
eq(providers.id, providerId),
eq(providers.userId, userId),
)
);
updatedAt,
}).where(and(eq(providers.id, providerId), eq(providers.userId, user.id)));
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
return { ok: true, updatedAt };
});
}
return { ok: true };
});
+41 -14
View File
@@ -1,8 +1,9 @@
import { and, eq, gt, sql } from 'drizzle-orm';
import * as z from 'zod';
import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
import { accounts, providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
import { authTransaction } from "~~/server/lib/auth-transaction";
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
* Sets a password with a one-time link.
@@ -33,24 +34,45 @@ export default defineEventHandler(async (event) => {
});
}
const tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!);
const token = getRouterParam(event, 'token')!;
const tokenHash = hashRecoveryToken(token);
const { password } = result.data;
const invalidLink = () => createError({
statusCode: 410,
statusMessage: 'This recovery link is not valid anymore',
data: {
code: 'RECOVERY_LINK_INVALID',
},
});
// Hashing a password is slow on purpose and anyone can call this, so a
// link that does not work is turned away before any of that is done.
const candidate = await findRecoveryLink(token);
if (!candidate) {
throw invalidLink();
}
const { password: hasher, internalAdapter } = await auth.$context;
const passwordHash = await hasher.hash(password);
const recovered = await db.transaction(async (tx) => {
const recovered = await authTransaction(async (tx) => {
await lockAccount(tx, candidate.userId);
// deleting the link is what makes it work once: of two requests with
// the same link only one gets a row back
const [link] = await tx.delete(recoveryLinks)
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
if (!link) return null;
if (!link) {
return null;
}
// if either of these fails the link comes back with the rest of the
// transaction
if (!await hasPassword(link.userId)) {
const [credential] = await tx.select({ id: accounts.id }).from(accounts)
.where(and(eq(accounts.userId, link.userId), eq(accounts.providerId, 'credential')));
if (!credential) {
await internalAdapter.linkAccount({
providerId: 'credential',
accountId: link.userId,
@@ -63,32 +85,37 @@ export default defineEventHandler(async (event) => {
await internalAdapter.updatePassword(link.userId, passwordHash);
await tx.update(providers).set({
updatedAt: sql`greatest(date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', ${providers.updatedAt}) + interval '1 millisecond')`,
}).where(eq(providers.userId, link.userId));
// A provider without its key can't be used, so it is switched off
// until the key is entered again. The provider and its models stay.
await tx.update(providers)
.set({
config: sql`${providers.config} - 'apiKey'`,
enabled: false,
updatedAt: sql`${providers.updatedAt}`,
})
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
// Every device is signed out, always: the ones still signed in hold
// an encryption key derived from the old password.
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
await revokeTwoFactorTrust(tx, link.userId);
if (link.resetSecurity) {
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
}
return 'recovered';
});
// used or expired between the check above and here
if (recovered === null) {
throw createError({
statusCode: 410,
statusMessage: 'This recovery link is not valid anymore',
data: {
code: 'RECOVERY_LINK_INVALID',
},
});
throw invalidLink();
}
return { newAccount: recovered === 'created' };
+2 -2
View File
@@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm';
import * as z from 'zod';
import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
import { authTransaction } from '~~/server/lib/auth-transaction';
// any fixed number works, it only has to be the same for every request
const SETUP_LOCK_ID = 7_261_008;
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
let alreadyDone = false;
try {
await db.transaction(async (tx) => {
await authTransaction(async (tx) => {
// two people submitting the form at the same moment wait here, and
// the second one then finds the first one's account
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
+46
View File
@@ -0,0 +1,46 @@
import type { Transaction } from "./auth-transaction";
import { and, eq, isNotNull, ne, or, isNull, sql, like } from "drizzle-orm";
import { accounts, users, verifications } from "../../drizzle/schema";
const ADMIN_LOCK_ID = 7_261_009;
// Deletion, banning and demotion must all hold this lock until commit.
export const lockAdministration = async (tx: Transaction): Promise<void> => {
await tx.execute(sql`select pg_advisory_xact_lock(${ADMIN_LOCK_ID})`);
};
export const hasUsableAdministrator = async (tx: Transaction, excludedUserId?: string): Promise<boolean> => {
const rows = await tx.select({ id: users.id })
.from(users)
.innerJoin(accounts, eq(accounts.userId, users.id))
.where(and(
eq(users.role, "admin"),
excludedUserId === undefined ? undefined : ne(users.id, excludedUserId),
or(eq(users.banned, false), isNull(users.banned)),
eq(accounts.providerId, "credential"),
isNotNull(accounts.password),
ne(accounts.password, ""),
))
.limit(1);
return rows.length > 0;
};
// All credential, recovery-link and provider-config writes use the user row
// as their common lock, including when no credential or recovery row exists.
export const lockAccount = async (tx: Transaction, userId: string): Promise<boolean> => {
const rows = await tx.select({ id: users.id }).from(users)
.where(eq(users.id, userId)).for("update");
return rows.length > 0;
};
export const revokeTwoFactorTrust = async (tx: Transaction, userId: string): Promise<void> => {
await tx.delete(verifications).where(and(
eq(verifications.value, userId),
or(
like(verifications.identifier, "trust-device-%"),
like(verifications.identifier, "2fa-%"),
),
));
};
+30
View File
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { db } from "./db";
export type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const storage = new AsyncLocalStorage<Transaction>();
/** The transaction opened by authTransaction() around the running code, if any. */
export const currentTransaction = (): Transaction | undefined => storage.getStore();
// What the one auth instance is given as its database. Every access goes to
// the transaction opened by authTransaction() around the running code, or to
// the pool when there is none, so no second auth instance is needed to make
// Better Auth write on a transaction's connection.
export const authDatabase = new Proxy(db, {
get(_target, property) {
const target = storage.getStore() ?? db;
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
/**
* Runs the callback in a transaction that everything the auth instance does
* inside it joins: its endpoints, its hooks and its internal adapter all
* commit or roll back together with the callback's own writes.
*/
export const authTransaction = <T>(callback: (tx: Transaction) => Promise<T>): Promise<T> =>
db.transaction(tx => storage.run(tx, () => callback(tx)));
+12 -1
View File
@@ -10,6 +10,8 @@ import {
users,
} from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { hasUsableAdministrator, lockAccount, lockAdministration } from "~~/server/lib/account-security";
import { Err, Ok } from "~~/types/result";
/**
* Deletes an account and everything that belongs to it.
@@ -21,7 +23,15 @@ import { db } from '~~/server/lib/db';
* Objects in file storage are left alone on purpose.
*/
export const deleteAccount = async (userId: string) => {
await db.transaction(async (tx) => {
return await db.transaction(async (tx) => {
await lockAdministration(tx);
await lockAccount(tx, userId);
const [account] = await tx.select({ role: users.role }).from(users).where(eq(users.id, userId));
if (account?.role === "admin" && !await hasUsableAdministrator(tx, userId)) {
return Err("LAST_ADMIN" as const);
}
// messages, their parts, generations and attachments cascade from topics
await tx.delete(topics).where(eq(topics.userId, userId));
await tx.delete(agents).where(eq(agents.userId, userId));
@@ -35,5 +45,6 @@ export const deleteAccount = async (userId: string) => {
// sessions, accounts and two-factor rows cascade from the user
await tx.delete(users).where(eq(users.id, userId));
return Ok(undefined);
});
};
+6 -12
View File
@@ -1,26 +1,20 @@
import type { H3Event } from 'h3';
import { db } from '~~/server/lib/db';
/**
* Like protectRoute(), but only lets admins through.
*
* The role is read from the database: the session cookie keeps a copy of the
* user for a few minutes, and someone who just stopped being an admin should
* not keep the access for that long.
* protectRoute() reads the user from the database on every request, so someone
* who just stopped being an admin loses the access right away.
*/
export const protectAdminRoute = async (event: H3Event) => {
await protectRoute(event);
const sessionData = await protectRoute(event);
const account = await db.query.users.findFirst({
where: {
id: event.context.user!.id as string,
},
});
if (account?.role !== 'admin' || account.banned) {
if (sessionData.user.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
});
}
return sessionData;
};
+6 -2
View File
@@ -2,9 +2,12 @@ import type { H3Event } from 'h3';
import { auth } from '~~/lib/auth';
export const protectRoute = async (event: H3Event) => {
const sessionData = await auth.api.getSession({ headers: event.headers });
const sessionData = await auth.api.getSession({
headers: event.headers,
query: { disableCookieCache: true },
});
if (sessionData === null) {
if (sessionData === null || sessionData.user.banned) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
}
event.context.user = sessionData.user;
return sessionData;
};
+6 -10
View File
@@ -1,5 +1,6 @@
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
import { invites, users } from '../../drizzle/schema';
import type { Transaction } from '../lib/auth-transaction';
import { db } from '../lib/db';
export const INVITE_TOKEN_HEADER = 'x-invite-token';
@@ -9,10 +10,12 @@ const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt
/**
* Takes one use of an invite. It is a single statement, so two people
* registering with the same single use link at once cannot both get in.
* registering with the same single use link at once cannot both get in. It
* runs on the transaction of the registration, a failed one gives the use back
* by rolling back.
*/
export const claimInvite = async (token: string): Promise<boolean> => {
const claimed = await db.update(invites)
export const claimInvite = async (tx: Transaction, token: string): Promise<boolean> => {
const claimed = await tx.update(invites)
.set({ uses: sql`${invites.uses} + 1` })
.where(and(eq(invites.token, token), usable()))
.returning({ id: invites.id });
@@ -20,13 +23,6 @@ export const claimInvite = async (token: string): Promise<boolean> => {
return claimed.length > 0;
};
/** Gives a use back when the registration it was taken for did not go through. */
export const releaseInvite = async (token: string) => {
await db.update(invites)
.set({ uses: sql`${invites.uses} - 1` })
.where(and(eq(invites.token, token), gt(invites.uses, 0)));
};
export const findUsableInvite = async (token: string) => {
const rows = await db
.select({
+4 -1
View File
@@ -1,7 +1,8 @@
import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, lte, sql } from 'drizzle-orm';
import { and, eq, gt, lte } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { lockAccount } from "~~/server/lib/account-security";
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it
@@ -36,7 +37,9 @@ export const createRecoveryLink = async (userId: string, resetSecurity: boolean)
const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => {
await lockAccount(tx, userId);
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
// nothing else ever looks at a link that ran out, so they go here
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token),
+2 -2
View File
@@ -1,5 +1,5 @@
import { users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { users } from '../../drizzle/schema';
import { db } from '../lib/db';
/**
* An instance needs setting up for as long as it has no accounts. The first