From b6f4d85ed706ced22f58c7a1b4e738ed5caef727 Mon Sep 17 00:00:00 2001
From: Zoe
Date: Thu, 8 Oct 2026 14:24:47 -0500
Subject: [PATCH] feat: complete admin invite and user creation and recovery
flows
---
Dockerfile | 3 +
app/components/Dialog/CreateUser.vue | 192 +-
app/components/Dialog/RecoverAccount.vue | 174 +
app/components/Dialog/index.vue | 3 +
app/composables/useDialog.ts | 1 +
app/pages/auth/login.vue | 27 +-
app/pages/auth/recover/[token].vue | 231 ++
app/pages/auth/recover/index.vue | 69 +
app/pages/auth/setup.vue | 2 +-
app/pages/settings/instance/users.vue | 25 +-
.../20261008192016_recovery/migration.sql | 11 +
.../20261008192016_recovery/snapshot.json | 3372 +++++++++++++++++
drizzle/schema.ts | 18 +
lib/auth.ts | 14 +-
package.json | 3 +-
scripts/recover.ts | 65 +
.../api/admin/users/[id]/recovery.delete.ts | 12 +
server/api/admin/users/[id]/recovery.post.ts | 44 +
server/api/admin/users/index.get.ts | 7 +-
server/api/admin/users/index.post.ts | 63 +
server/api/recover/[token].get.ts | 40 +
server/api/recover/[token].post.ts | 95 +
server/utils/recovery.ts | 69 +
23 files changed, 4430 insertions(+), 110 deletions(-)
create mode 100644 app/components/Dialog/RecoverAccount.vue
create mode 100644 app/pages/auth/recover/[token].vue
create mode 100644 app/pages/auth/recover/index.vue
create mode 100644 drizzle/migrations/20261008192016_recovery/migration.sql
create mode 100644 drizzle/migrations/20261008192016_recovery/snapshot.json
create mode 100644 scripts/recover.ts
create mode 100644 server/api/admin/users/[id]/recovery.delete.ts
create mode 100644 server/api/admin/users/[id]/recovery.post.ts
create mode 100644 server/api/admin/users/index.post.ts
create mode 100644 server/api/recover/[token].get.ts
create mode 100644 server/api/recover/[token].post.ts
create mode 100644 server/utils/recovery.ts
diff --git a/Dockerfile b/Dockerfile
index ebbee2b..5ffa0e2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,6 +26,9 @@ COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
# Copy entrypoint script
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
+# Copy the account recovery script (bun run recover )
+COPY --from=build /app/scripts/recover.ts /app/scripts/recover.ts
+
# Copy package.json and lockfile for production dependency install
COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/bun.lock* ./
diff --git a/app/components/Dialog/CreateUser.vue b/app/components/Dialog/CreateUser.vue
index 61014cc..d5a3acb 100644
--- a/app/components/Dialog/CreateUser.vue
+++ b/app/components/Dialog/CreateUser.vue
@@ -1,46 +1,36 @@
@@ -90,66 +80,80 @@ const submit = async () => {
+
+
+
+ Your password is changed.
+
+ Log in with the new one.
+
+ Your saved API keys were deleted, add them again under Settings → AI Providers.
+
+
+
+
+
@@ -308,7 +326,14 @@ const submitTwoFactor = async () => {
-
+
+
+ Forgot your password?
+
+
+
+
New to Veridian?
diff --git a/app/pages/auth/recover/[token].vue b/app/pages/auth/recover/[token].vue
new file mode 100644
index 0000000..3f6d9d9
--- /dev/null
+++ b/app/pages/auth/recover/[token].vue
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Veridian
+
+ Welcome, {{ setup.name }}
+ Set a new password for {{ recovery.email }}
+ This link doesn't work
+
+
+
+
+
+ This link doesn't work anymore. It was used already, has expired, or was replaced by a
+ newer one. Ask whoever runs this instance for a new one.
+
+
+
+ Back to log in
+
+
+
+
+
+
+ An account was created for you as {{
+ setup.email }} . Choose a password to start using it.
+
+
+
+
+
+
+
+ Your {{ keyCount }} saved API {{ keyCount === 1 ? 'key' : 'keys' }} will be deleted
+
+
+
+ They were encrypted with your old password. Without it nobody can decrypt them, not
+ you, not an admin, not the server. There is no way to get them back.
+
+
+
+ You have no saved API keys, so nothing of yours is lost.
+
+
+
+
+
+ Deleted: API keys for {{ recovery.keyProviders.join(', ') }}
+
+
+
+ Reset: two-factor sign-in, all active sessions
+
+
+
+ Kept: chats, agents, files, provider and model lists, settings
+
+
+
+
+
+
+ {{
+ setup ? 'Password' : 'New password' }}
+
+
+
+
+ Confirm
+ password
+
+
+
+
+
+
+
+ I understand my API keys will be permanently deleted and I'll need to add them again.
+
+
+
+
+
+
+ Your password also encrypts your API keys in the browser. If you forget it, saved
+ keys can't be recovered and have to be re-entered.
+
+
+
+ {{ error }}
+
+
+
+ {{ setup ? 'Set password and continue' : keyCount > 0 ? 'Reset password and delete keys' : 'Reset password' }}
+
+
+
+
+ Remember your old password after all?
+
+ Log in instead
+
+ and change it from Settings to keep your keys.
+
+
+
+
+
diff --git a/app/pages/auth/recover/index.vue b/app/pages/auth/recover/index.vue
new file mode 100644
index 0000000..dd4caec
--- /dev/null
+++ b/app/pages/auth/recover/index.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Veridian
+
Forgot your password?
+
+
+
+ This instance doesn't send email. An administrator can create a one-time recovery link for
+ you.
+
+
+
+
+
+ {{ index + 1 }}
+
+ {{ step }}
+
+
+
+
+ Operators locked out of the only admin account can run
+ bun run recover <email>
+ on the server to print a link.
+
+
+
+
+
+ Recovery deletes your saved API keys.
+
+ They are encrypted with your current password and can't be read without it. Your
+ chats, agents and files are kept.
+
+
+
+
+
+ Remembered it?
+
+ Back to log in
+
+
+
+
+
diff --git a/app/pages/auth/setup.vue b/app/pages/auth/setup.vue
index 5202dea..fb7874c 100644
--- a/app/pages/auth/setup.vue
+++ b/app/pages/auth/setup.vue
@@ -157,7 +157,7 @@ const submit = async () => {
Someone else just created the first account, so this instance already has its
administrator. Nothing was created for you.
-
+
Go to log in
diff --git a/app/pages/settings/instance/users.vue b/app/pages/settings/instance/users.vue
index 65ee780..22a0da0 100644
--- a/app/pages/settings/instance/users.vue
+++ b/app/pages/settings/instance/users.vue
@@ -9,6 +9,8 @@ type UserRow = {
role: 'admin' | 'user';
banned: boolean;
twoFactorEnabled: boolean;
+ // created by an admin and has not set a password yet
+ pending: boolean;
createdAt: string;
lastActiveAt: string | null;
};
@@ -139,6 +141,12 @@ 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 });
+};
+
const resetTwoFactor = (user: UserRow) => confirmThen({
title: `Reset two-factor for ${user.name}?`,
message: 'Turns two-factor sign-in off for this account and throws away its backup codes. They sign in with only their password until they set it up again.',
@@ -196,9 +204,8 @@ const createInvite = () => {
};
const openCreateUser = () => {
- openDialog(DialogType.CreateUser, async (result) => {
- if (result.ok === false) return;
-
+ // the dialog can be closed after the account was made, so always reload
+ openDialog(DialogType.CreateUser, async () => {
await loadUsers();
});
};
@@ -278,9 +285,9 @@ const openCreateUser = () => {
+ :class="u.banned ? 'text-red-400' : u.pending ? 'text-amber-400' : 'text-green-400'">
- {{ u.banned ? 'Disabled' : 'Active' }}
+ {{ u.banned ? 'Disabled' : u.pending ? 'Invited' : 'Active' }}
{
Make admin
+
+
+ {{ u.pending ? 'New setup link…' : 'Recover account…' }}
+
@@ -365,7 +377,8 @@ const openCreateUser = () => {
Admins manage accounts, not content: chats, agents, files and API keys of other users are never
- shown here. Disabled accounts can't sign in and nothing of theirs is deleted. Your own account is
+ 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.
diff --git a/drizzle/migrations/20261008192016_recovery/migration.sql b/drizzle/migrations/20261008192016_recovery/migration.sql
new file mode 100644
index 0000000..14e51b4
--- /dev/null
+++ b/drizzle/migrations/20261008192016_recovery/migration.sql
@@ -0,0 +1,11 @@
+CREATE TABLE "recovery_links" (
+ "id" text PRIMARY KEY,
+ "token_hash" text NOT NULL UNIQUE,
+ "user_id" text NOT NULL,
+ "reset_security" boolean DEFAULT true NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX "recovery_links_userId_idx" ON "recovery_links" ("user_id");--> statement-breakpoint
+ALTER TABLE "recovery_links" ADD CONSTRAINT "recovery_links_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
\ No newline at end of file
diff --git a/drizzle/migrations/20261008192016_recovery/snapshot.json b/drizzle/migrations/20261008192016_recovery/snapshot.json
new file mode 100644
index 0000000..035b3d3
--- /dev/null
+++ b/drizzle/migrations/20261008192016_recovery/snapshot.json
@@ -0,0 +1,3372 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "b1b5ed36-74ea-4609-a75b-097cd8719c16",
+ "prevIds": [
+ "bb38ae6d-7cdd-4da9-8b3c-49e7d7ec98f3"
+ ],
+ "ddl": [
+ {
+ "values": [
+ "reasoning",
+ "text",
+ "tool-call",
+ "file"
+ ],
+ "name": "part_type",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": [
+ "user",
+ "assistant"
+ ],
+ "name": "role",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": [
+ "pending",
+ "completed",
+ "failed",
+ "cancelled"
+ ],
+ "name": "status",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "accounts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "agents",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "attachments",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "embeddings",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "files",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "generations",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instance_settings",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "invites",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "message_parts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "messages",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "models",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "providers",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "recovery_links",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "sessions",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "settings",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "tool_calls",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "topics",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "two_factors",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "users",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "verifications",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "provider_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "access_token_expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "refresh_token_expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "scope",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "password",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "system_prompt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "image_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "default_model_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "config",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "topic_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "file_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "file_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "content",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "type": "vector(1536)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "embedding",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "metadata",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "mime_type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "size",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "topic_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "model_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "status",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "status",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tokens",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_settings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'open'",
+ "generated": null,
+ "identity": null,
+ "name": "registration",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_settings"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_settings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_by",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "1",
+ "generated": null,
+ "identity": null,
+ "name": "max_uses",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "uses",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "topic_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tool_call_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "part_type",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "content",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "provider_options",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "finished",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "last_updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "topic_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "parent_message_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "generation_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "role",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "role",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "content",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "active_child_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "deleted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "external_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "provider_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "cost",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "input_modalities",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "output_modalities",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "capabilities",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "context_window",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 1,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "supported_parameters",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "is_custom",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "released_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "config",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "token_hash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "reset_security",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ip_address",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_agent",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "impersonated_by",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "settings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "settings"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "system_assistants",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "settings"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "appearance",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "settings"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tool_name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "status",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'pending'",
+ "generated": null,
+ "identity": null,
+ "name": "status",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "input",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "output",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "agent_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "renaming",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "rename_attempt_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "rename_expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'manual'",
+ "generated": null,
+ "identity": null,
+ "name": "title_source",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "secret",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "backup_codes",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "verified",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "failed_verification_count",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "locked_until",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "email",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "email_verified",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "image",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "two_factor_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "'user'",
+ "generated": null,
+ "identity": null,
+ "name": "role",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "banned",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ban_reason",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ban_expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "identifier",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "value",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "now()",
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "accounts_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "agents_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "topic_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "attachments_topicId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "attachments_messageId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "attachments_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "file_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "embeddings_fileId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "files_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "files"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "topic_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "generations_topicId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "generations_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "topic_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "message_parts_topicId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "message_parts_messageId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "message_parts_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "topic_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "messages_topicId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "parent_message_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "messages_parentMessageId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "messages_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "models_providerId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "models_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "providers_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "providers"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "recovery_links_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "sessions_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "settings_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "settings"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "tool_calls_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "tool_calls"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "topics_agentId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "topics_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "secret",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "two_factors_secret_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "two_factors_userId_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "verifications_identifier_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "verifications"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "accounts_user_id_users_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "default_model_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "models",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "name": "agents_default_model_id_models_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "agents"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "topic_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "topics",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "attachments_topic_id_topics_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "message_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "messages",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "attachments_message_id_messages_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "file_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "files",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "attachments_file_id_files_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "attachments"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "file_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "files",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "embeddings_file_id_files_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "embeddings"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "topic_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "topics",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "generations_topic_id_topics_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "generations"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "created_by"
+ ],
+ "schemaTo": "public",
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "invites_created_by_users_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "invites"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "topic_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "topics",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "message_parts_topic_id_topics_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "message_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "messages",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "message_parts_message_id_messages_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "tool_call_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "tool_calls",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "message_parts_tool_call_id_tool_calls_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "message_parts"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "topic_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "topics",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "messages_topic_id_topics_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "generation_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "generations",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "messages_generation_id_generations_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "messages"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "provider_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "providers",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "models_provider_id_providers_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "models"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "recovery_links_user_id_users_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "recovery_links"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "sessions_user_id_users_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "agent_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "topics_agent_id_agents_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "topics"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "user_id"
+ ],
+ "schemaTo": "public",
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "two_factors_user_id_users_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "two_factors"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "accounts_pkey",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "agents_pkey",
+ "schema": "public",
+ "table": "agents",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "attachments_pkey",
+ "schema": "public",
+ "table": "attachments",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "embeddings_pkey",
+ "schema": "public",
+ "table": "embeddings",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "files_pkey",
+ "schema": "public",
+ "table": "files",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "generations_pkey",
+ "schema": "public",
+ "table": "generations",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "instance_settings_pkey",
+ "schema": "public",
+ "table": "instance_settings",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "invites_pkey",
+ "schema": "public",
+ "table": "invites",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "message_parts_pkey",
+ "schema": "public",
+ "table": "message_parts",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "messages_pkey",
+ "schema": "public",
+ "table": "messages",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "models_pkey",
+ "schema": "public",
+ "table": "models",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "providers_pkey",
+ "schema": "public",
+ "table": "providers",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "recovery_links_pkey",
+ "schema": "public",
+ "table": "recovery_links",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "sessions_pkey",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "settings_pkey",
+ "schema": "public",
+ "table": "settings",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "tool_calls_pkey",
+ "schema": "public",
+ "table": "tool_calls",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "topics_pkey",
+ "schema": "public",
+ "table": "topics",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "two_factors_pkey",
+ "schema": "public",
+ "table": "two_factors",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "users_pkey",
+ "schema": "public",
+ "table": "users",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "verifications_pkey",
+ "schema": "public",
+ "table": "verifications",
+ "entityType": "pks"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false,
+ "name": "invites_token_key",
+ "schema": "public",
+ "table": "invites",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "token_hash"
+ ],
+ "nullsNotDistinct": false,
+ "name": "recovery_links_token_hash_key",
+ "schema": "public",
+ "table": "recovery_links",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false,
+ "name": "sessions_token_key",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "email"
+ ],
+ "nullsNotDistinct": false,
+ "name": "users_email_key",
+ "schema": "public",
+ "table": "users",
+ "entityType": "uniques"
+ }
+ ],
+ "renames": []
+}
\ No newline at end of file
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 551833a..1864a28 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -374,3 +374,21 @@ export const invites = pgTable(
createdAt: timestamptz("created_at").defaultNow().notNull(),
},
);
+
+// One-time links that let someone who forgot their password set a new one.
+// Only a hash of the token is kept, the link itself is shown once when made.
+export const recoveryLinks = pgTable(
+ "recovery_links",
+ {
+ id: text("id").primaryKey().$defaultFn(nanoid),
+ tokenHash: text("token_hash").notNull().unique(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ // also turn two-factor off and sign every device out when it is used
+ resetSecurity: boolean("reset_security").notNull().default(true),
+ expiresAt: timestamptz("expires_at").notNull(),
+ createdAt: timestamptz("created_at").defaultNow().notNull(),
+ },
+ (table) => [index("recovery_links_userId_idx").on(table.userId)],
+);
diff --git a/lib/auth.ts b/lib/auth.ts
index ef2f24e..5663681 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -18,10 +18,12 @@ const accessControl = createAccessControl(defaultStatements);
// 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. /api/admin/users does that.
+// 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: ['create', 'list', 'set-role', 'ban', 'get', 'update'],
+ user: ['list', 'set-role', 'ban', 'get', 'update'],
session: ['list', 'revoke', 'delete'],
}),
user: accessControl.newRole({
@@ -74,9 +76,11 @@ export const auth = betterAuth({
}
}
- // creating a user as an admin skips the sign up password rules
- if (ctx.path === '/admin/create-user') {
- const password = ctx.body?.password;
+ // 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) {
throw new APIError('BAD_REQUEST', {
message: 'Password is too short',
diff --git a/package.json b/package.json
index f161a76..70b31dc 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
- "postinstall": "nuxt prepare"
+ "postinstall": "nuxt prepare",
+ "recover": "bun scripts/recover.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.71",
diff --git a/scripts/recover.ts b/scripts/recover.ts
new file mode 100644
index 0000000..1ae92f1
--- /dev/null
+++ b/scripts/recover.ts
@@ -0,0 +1,65 @@
+/**
+ * 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
+ *
+ * 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';
+
+const LIFETIME_MS = 24 * 60 * 60 * 1000;
+
+const main = async (): Promise => {
+ const email = process.argv[2]?.trim().toLowerCase();
+
+ if (!email) {
+ console.error('Usage: bun run recover ');
+ return 1;
+ }
+
+ const pool = new Pool({
+ 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;
+
+ if (!user) {
+ 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 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)',
+ [randomUUID(), tokenHash, user.id, expiresAt],
+ );
+ await pool.end();
+
+ const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
+
+ console.log(`Recovery link for ${user.name} <${email}>:`);
+ 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.');
+
+ return 0;
+};
+
+process.exit(await main());
diff --git a/server/api/admin/users/[id]/recovery.delete.ts b/server/api/admin/users/[id]/recovery.delete.ts
new file mode 100644
index 0000000..74129be
--- /dev/null
+++ b/server/api/admin/users/[id]/recovery.delete.ts
@@ -0,0 +1,12 @@
+import { eq } from 'drizzle-orm';
+import { recoveryLinks } from '~~/drizzle/schema';
+import { db } from '~~/server/lib/db';
+
+/** Cancels the recovery link of a user, if there is one. */
+export default defineEventHandler(async (event) => {
+ await protectAdminRoute(event);
+
+ await db.delete(recoveryLinks).where(eq(recoveryLinks.userId, getRouterParam(event, 'id')!));
+
+ return { ok: true };
+});
diff --git a/server/api/admin/users/[id]/recovery.post.ts b/server/api/admin/users/[id]/recovery.post.ts
new file mode 100644
index 0000000..582ccbc
--- /dev/null
+++ b/server/api/admin/users/[id]/recovery.post.ts
@@ -0,0 +1,44 @@
+import * as z from 'zod';
+import { db } from '~~/server/lib/db';
+
+/**
+ * Makes a one-time link the user can set a new password with, or their first
+ * one if the account was created by an admin and never had a password. The link is
+ * only returned here, afterwards nobody can read it back, admins included.
+ */
+export default defineEventHandler(async (event) => {
+ await protectAdminRoute(event);
+
+ const result = await readValidatedBody(event, (body) =>
+ z
+ .object({
+ // also turn two-factor off and sign every device out
+ resetSecurity: z.boolean(),
+ })
+ .safeParse(body),
+ );
+
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.issues[0]!.message,
+ });
+ }
+
+ const id = getRouterParam(event, 'id')!;
+
+ const account = await db.query.users.findFirst({
+ where: {
+ id,
+ },
+ });
+
+ if (!account) {
+ throw createError({
+ statusCode: 404,
+ statusMessage: 'User not found',
+ });
+ }
+
+ return await createRecoveryLink(id, result.data.resetSecurity);
+});
diff --git a/server/api/admin/users/index.get.ts b/server/api/admin/users/index.get.ts
index c2f4d3f..701e873 100644
--- a/server/api/admin/users/index.get.ts
+++ b/server/api/admin/users/index.get.ts
@@ -1,5 +1,5 @@
-import { asc, eq, max } from 'drizzle-orm';
-import { sessions, users } from '~~/drizzle/schema';
+import { asc, eq, max, sql } from 'drizzle-orm';
+import { accounts, sessions, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
@@ -24,6 +24,9 @@ export default defineEventHandler(async (event) => {
// sessions are touched as they are used, the newest one is the
// closest thing there is to a last seen time
lastActiveAt: max(sessions.updatedAt),
+ // no password yet: created by an admin and still waiting for its
+ // owner to open the setup link
+ pending: sql`not exists (select 1 from ${accounts} where ${accounts.userId} = ${users.id} and ${accounts.providerId} = 'credential')`,
})
.from(users)
.leftJoin(sessions, eq(sessions.userId, users.id))
diff --git a/server/api/admin/users/index.post.ts b/server/api/admin/users/index.post.ts
new file mode 100644
index 0000000..b4bee31
--- /dev/null
+++ b/server/api/admin/users/index.post.ts
@@ -0,0 +1,63 @@
+import * as z from 'zod';
+import { auth } from '~~/lib/auth';
+
+/**
+ * Creates an account for someone, without a password. Nobody can sign in to
+ * it until its owner opens the setup link returned here and picks one, so the
+ * admin never knows a password of theirs. The link is only returned here.
+ */
+export default defineEventHandler(async (event) => {
+ await protectAdminRoute(event);
+
+ const result = await readValidatedBody(event, (body) =>
+ z
+ .object({
+ name: z.string().trim().min(1).max(128),
+ email: z.email(),
+ })
+ .safeParse(body),
+ );
+
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.issues[0]!.message,
+ data: {
+ code: 'INVALID_BODY',
+ },
+ });
+ }
+
+ const { name, email } = result.data;
+
+ let userId: string;
+
+ try {
+ // called without the request, the admin check already happened above
+ const { user } = await auth.api.createUser({
+ body: {
+ name,
+ email,
+ role: 'user',
+ },
+ });
+
+ userId = user.id;
+ } catch (error: any) {
+ const code = error?.body?.code as string | undefined;
+
+ if (code === undefined) {
+ console.error('Failed to create user:', error);
+ }
+
+ throw createError({
+ statusCode: code === undefined ? 500 : 400,
+ statusMessage: 'Failed to create user',
+ data: {
+ code: code ?? 'INTERNAL_ERROR',
+ },
+ });
+ }
+
+ return await createRecoveryLink(userId, false);
+});
diff --git a/server/api/recover/[token].get.ts b/server/api/recover/[token].get.ts
new file mode 100644
index 0000000..fb9c7e6
--- /dev/null
+++ b/server/api/recover/[token].get.ts
@@ -0,0 +1,40 @@
+import { and, eq, sql } from 'drizzle-orm';
+import { providers } from '~~/drizzle/schema';
+import { db } from '~~/server/lib/db';
+
+/**
+ * Tells the recovery page whether a link still works and what using it will
+ * do: set the first password of a new account, or replace a forgotten one. Public on purpose: whoever opens it cannot sign in, that is the point.
+ */
+export default defineEventHandler(async (event) => {
+ const link = await findRecoveryLink(getRouterParam(event, 'token')!);
+
+ if (!link) {
+ return { valid: false as const };
+ }
+
+ // an account that never had a password is being set up, not recovered
+ if (!await hasPassword(link.userId)) {
+ return {
+ valid: true as const,
+ newAccount: true as const,
+ email: link.email,
+ name: link.name,
+ };
+ }
+
+ const withKey = await db
+ .select({ name: providers.name })
+ .from(providers)
+ .where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
+
+ return {
+ valid: true as const,
+ newAccount: false as const,
+ email: link.email,
+ // the providers whose saved key is lost when the link is used
+ keyProviders: withKey.map(p => p.name),
+ resetSecurity: link.resetSecurity,
+ twoFactorEnabled: !!link.twoFactorEnabled,
+ };
+});
diff --git a/server/api/recover/[token].post.ts b/server/api/recover/[token].post.ts
new file mode 100644
index 0000000..ca60da4
--- /dev/null
+++ b/server/api/recover/[token].post.ts
@@ -0,0 +1,95 @@
+import { and, eq, gt, sql } from 'drizzle-orm';
+import * as z from 'zod';
+import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
+import { auth } from '~~/lib/auth';
+import { db } from '~~/server/lib/db';
+
+/**
+ * Sets a password with a one-time link.
+ *
+ * For an account an admin just created this is its first password, and there
+ * is nothing else to do. For a forgotten password it is a recovery:
+ *
+ * The saved API keys are encrypted with a key derived from the old password.
+ * Nobody has that password anymore, so the keys can never be read again and
+ * are deleted here. Chats, agents, files and everything else stay.
+ */
+export default defineEventHandler(async (event) => {
+ const result = await readValidatedBody(event, (body) =>
+ z
+ .object({
+ password: z.string().min(8).max(128),
+ })
+ .safeParse(body),
+ );
+
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.issues[0]!.message,
+ data: {
+ code: 'INVALID_BODY',
+ },
+ });
+ }
+
+ const tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!);
+ const { password } = result.data;
+
+ const { password: hasher, internalAdapter } = await auth.$context;
+ const passwordHash = await hasher.hash(password);
+
+ const recovered = await db.transaction(async (tx) => {
+ // 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 either of these fails the link comes back with the rest of the
+ // transaction
+ if (!await hasPassword(link.userId)) {
+ await internalAdapter.linkAccount({
+ providerId: 'credential',
+ accountId: link.userId,
+ password: passwordHash,
+ userId: link.userId,
+ });
+
+ return 'created';
+ }
+
+ await internalAdapter.updatePassword(link.userId, passwordHash);
+
+ // 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,
+ })
+ .where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
+
+ 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';
+ });
+
+ if (recovered === null) {
+ throw createError({
+ statusCode: 410,
+ statusMessage: 'This recovery link is not valid anymore',
+ data: {
+ code: 'RECOVERY_LINK_INVALID',
+ },
+ });
+ }
+
+ return { newAccount: recovered === 'created' };
+});
diff --git a/server/utils/recovery.ts b/server/utils/recovery.ts
new file mode 100644
index 0000000..8075ac6
--- /dev/null
+++ b/server/utils/recovery.ts
@@ -0,0 +1,69 @@
+import { createHash, randomBytes } from 'node:crypto';
+import { and, eq, gt, lte, sql } from 'drizzle-orm';
+import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
+import { db } from '~~/server/lib/db';
+
+export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
+// a new person may take a while to get round to it
+export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * An account an admin created has no password until its owner sets one with
+ * a setup link. Until then nobody can sign in to it.
+ */
+export const hasPassword = async (userId: string): Promise => {
+ const rows = await db
+ .select({ id: accounts.id })
+ .from(accounts)
+ .where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
+ .limit(1);
+
+ return rows.length > 0;
+};
+
+// scripts/recover.ts makes links the same way, keep the two in step
+export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex');
+
+/**
+ * Makes a one-time link a user can set a password with and returns its token:
+ * a recovery link for someone who forgot theirs, a setup link for an account
+ * that never had one. Only one link works at a time, making a new one cancels
+ * the ones before it.
+ */
+export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => {
+ const token = randomBytes(32).toString('base64url');
+ const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS;
+ const expiresAt = new Date(Date.now() + lifetime);
+
+ await db.transaction(async (tx) => {
+ await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
+ await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
+ await tx.insert(recoveryLinks).values({
+ tokenHash: hashRecoveryToken(token),
+ userId,
+ resetSecurity,
+ expiresAt,
+ });
+ });
+
+ return { token, expiresAt };
+};
+
+export const findRecoveryLink = async (token: string) => {
+ const rows = await db
+ .select({
+ id: recoveryLinks.id,
+ userId: recoveryLinks.userId,
+ resetSecurity: recoveryLinks.resetSecurity,
+ expiresAt: recoveryLinks.expiresAt,
+ email: users.email,
+ name: users.name,
+ twoFactorEnabled: users.twoFactorEnabled,
+ })
+ .from(recoveryLinks)
+ .innerJoin(users, eq(users.id, recoveryLinks.userId))
+ .where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date())))
+ .limit(1);
+
+ return rows[0] ?? null;
+};