diff --git a/.env.example b/.env.example index 2f99090..0a53dfe 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,15 @@ CLERK_SECRET_KEY=sk_test_xxxxxxxx # Clerk webhook: Dashboard > Webhooks > your endpoint > Signing Secret (whsec_...) CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxxxxx + +# Better Auth (ADR-033). Secret: `openssl rand -base64 32` (>= 32 chars). URL: public origin of the app (no trailing slash). +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 +# Transactional mail (Resend). MAIL_FROM must use a domain verified in Resend, e.g. "Compasso ". +RESEND_API_KEY= +MAIL_FROM= +# Social sign-in (optional; a provider is enabled only when both values are set). Callback: /api/auth/callback/ +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/drizzle.config.ts b/drizzle.config.ts index 0c5a6d4..0ba792b 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -8,7 +8,7 @@ try { export default defineConfig({ dialect: "postgresql", - schema: "./src/db/schema.ts", + schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"], out: "./drizzle", dbCredentials: { url: process.env.DATABASE_URL ?? "" }, strict: true, diff --git a/drizzle/0005_auth_core.sql b/drizzle/0005_auth_core.sql new file mode 100644 index 0000000..006550d --- /dev/null +++ b/drizzle/0005_auth_core.sql @@ -0,0 +1,107 @@ +CREATE TABLE "auth_account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth_invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "inviter_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth_member" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth_organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp NOT NULL, + "metadata" text, + CONSTRAINT "auth_organization_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "auth_rate_limit" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "count" integer NOT NULL, + "last_request" bigint NOT NULL, + CONSTRAINT "auth_rate_limit_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "auth_session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "active_organization_id" text, + CONSTRAINT "auth_session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "auth_user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "auth_user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "auth_verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "clerk_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "workspaces" ALTER COLUMN "clerk_org_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "auth_id" text;--> statement-breakpoint +ALTER TABLE "workspaces" ADD COLUMN "auth_org_id" text;--> statement-breakpoint +ALTER TABLE "auth_account" ADD CONSTRAINT "auth_account_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_invitation" ADD CONSTRAINT "auth_invitation_organization_id_auth_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."auth_organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_invitation" ADD CONSTRAINT "auth_invitation_inviter_id_auth_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_member" ADD CONSTRAINT "auth_member_organization_id_auth_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."auth_organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_member" ADD CONSTRAINT "auth_member_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_session" ADD CONSTRAINT "auth_session_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "auth_account_user_id_idx" ON "auth_account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "auth_invitation_organization_id_idx" ON "auth_invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "auth_invitation_email_idx" ON "auth_invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "auth_member_organization_id_idx" ON "auth_member" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "auth_member_user_id_idx" ON "auth_member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "auth_session_user_id_idx" ON "auth_session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "auth_verification_identifier_idx" ON "auth_verification" USING btree ("identifier");--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_auth_id_unique" UNIQUE("auth_id");--> statement-breakpoint +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_auth_org_id_unique" UNIQUE("auth_org_id");--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_identity_chk" CHECK ("users"."clerk_id" is not null or "users"."auth_id" is not null);--> statement-breakpoint +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_identity_chk" CHECK ("workspaces"."clerk_org_id" is not null or "workspaces"."auth_org_id" is not null); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..20dc4b1 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2018 @@ +{ + "id": "bbb254ea-d9d8-42ef-bd55-8eb06c64a282", + "prevId": "68e2ba09-141c-4b07-a6c4-c41e1428e55d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_ws_name_uq": { + "name": "organizations_ws_name_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_workspace_id_workspaces_id_fk": { + "name": "organizations_workspace_id_workspaces_id_fk", + "tableFrom": "organizations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_members_project_user_uq": { + "name": "project_members_project_user_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_members_ws_user_idx": { + "name": "project_members_ws_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_workspace_id_workspaces_id_fk": { + "name": "project_members_workspace_id_workspaces_id_fk", + "tableFrom": "project_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_org_name_uq": { + "name": "projects_org_name_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_ws_idx": { + "name": "projects_ws_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#6B7280'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_ws_name_uq": { + "name": "tags_ws_name_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_workspace_id_workspaces_id_fk": { + "name": "tags_workspace_id_workspaces_id_fk", + "tableFrom": "tags", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_name_uq": { + "name": "tasks_project_name_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_ws_idx": { + "name": "tasks_ws_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_workspace_id_workspaces_id_fk": { + "name": "tasks_workspace_id_workspaces_id_fk", + "tableFrom": "tasks", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entries": { + "name": "time_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_billable": { + "name": "is_billable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_entries_one_running_per_user_uq": { + "name": "time_entries_one_running_per_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"time_entries\".\"ended_at\" is null and \"time_entries\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entries_ws_user_start_idx": { + "name": "time_entries_ws_user_start_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entries_ws_project_start_idx": { + "name": "time_entries_ws_project_start_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entries_workspace_id_workspaces_id_fk": { + "name": "time_entries_workspace_id_workspaces_id_fk", + "tableFrom": "time_entries", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_user_id_users_id_fk": { + "name": "time_entries_user_id_users_id_fk", + "tableFrom": "time_entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_project_id_projects_id_fk": { + "name": "time_entries_project_id_projects_id_fk", + "tableFrom": "time_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "time_entries_task_id_tasks_id_fk": { + "name": "time_entries_task_id_tasks_id_fk", + "tableFrom": "time_entries", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "time_entries_end_after_start": { + "name": "time_entries_end_after_start", + "value": "\"time_entries\".\"ended_at\" is null or \"time_entries\".\"ended_at\" > \"time_entries\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.time_entry_audit": { + "name": "time_entry_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "time_entry_id": { + "name": "time_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_entry_audit_entry_idx": { + "name": "time_entry_audit_entry_idx", + "columns": [ + { + "expression": "time_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entry_audit_ws_idx": { + "name": "time_entry_audit_ws_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entry_audit_workspace_id_workspaces_id_fk": { + "name": "time_entry_audit_workspace_id_workspaces_id_fk", + "tableFrom": "time_entry_audit", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_audit_time_entry_id_time_entries_id_fk": { + "name": "time_entry_audit_time_entry_id_time_entries_id_fk", + "tableFrom": "time_entry_audit", + "tableTo": "time_entries", + "columnsFrom": [ + "time_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_audit_actor_user_id_users_id_fk": { + "name": "time_entry_audit_actor_user_id_users_id_fk", + "tableFrom": "time_entry_audit", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry_tags": { + "name": "time_entry_tags", + "schema": "", + "columns": { + "time_entry_id": { + "name": "time_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "time_entry_tags_tag_idx": { + "name": "time_entry_tags_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entry_tags_time_entry_id_time_entries_id_fk": { + "name": "time_entry_tags_time_entry_id_time_entries_id_fk", + "tableFrom": "time_entry_tags", + "tableTo": "time_entries", + "columnsFrom": [ + "time_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_tags_tag_id_tags_id_fk": { + "name": "time_entry_tags_tag_id_tags_id_fk", + "tableFrom": "time_entry_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "time_entry_tags_time_entry_id_tag_id_pk": { + "name": "time_entry_tags_time_entry_id_tag_id_pk", + "columns": [ + "time_entry_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/Recife'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_id" + ] + }, + "users_auth_id_unique": { + "name": "users_auth_id_unique", + "nullsNotDistinct": false, + "columns": [ + "auth_id" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "users_identity_chk": { + "name": "users_identity_chk", + "value": "\"users\".\"clerk_id\" is not null or \"users\".\"auth_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "workspace_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_ws_user_uq": { + "name": "workspace_members_ws_user_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_org_id": { + "name": "auth_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report_timezone": { + "name": "report_timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_clerk_org_id_unique": { + "name": "workspaces_clerk_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_org_id" + ] + }, + "workspaces_auth_org_id_unique": { + "name": "workspaces_auth_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "auth_org_id" + ] + }, + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspaces_identity_chk": { + "name": "workspaces_identity_chk", + "value": "\"workspaces\".\"clerk_org_id\" is not null or \"workspaces\".\"auth_org_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_account_user_id_idx": { + "name": "auth_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_invitation": { + "name": "auth_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_invitation_organization_id_idx": { + "name": "auth_invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_invitation_email_idx": { + "name": "auth_invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_invitation_organization_id_auth_organization_id_fk": { + "name": "auth_invitation_organization_id_auth_organization_id_fk", + "tableFrom": "auth_invitation", + "tableTo": "auth_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_invitation_inviter_id_auth_user_id_fk": { + "name": "auth_invitation_inviter_id_auth_user_id_fk", + "tableFrom": "auth_invitation", + "tableTo": "auth_user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_member": { + "name": "auth_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_member_organization_id_idx": { + "name": "auth_member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_member_user_id_idx": { + "name": "auth_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_member_organization_id_auth_organization_id_fk": { + "name": "auth_member_organization_id_auth_organization_id_fk", + "tableFrom": "auth_member", + "tableTo": "auth_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_member_user_id_auth_user_id_fk": { + "name": "auth_member_user_id_auth_user_id_fk", + "tableFrom": "auth_member", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_organization": { + "name": "auth_organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_organization_slug_unique": { + "name": "auth_organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_rate_limit": { + "name": "auth_rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_rate_limit_key_unique": { + "name": "auth_rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_session_user_id_idx": { + "name": "auth_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verification_identifier_idx": { + "name": "auth_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.workspace_role": { + "name": "workspace_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c0c05b8..42b689e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1791560000000, "tag": "0004_time_entry_audit", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1791570000000, + "tag": "0005_auth_core", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 93cc65d..3ac8df3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-select": "^2.3.8", "@radix-ui/react-slot": "^1.4.0", "@react-pdf/renderer": "^4.9.0", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.3", @@ -306,6 +307,147 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/core": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", + "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", + "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", + "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", + "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", + "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", + "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", + "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", + "license": "MIT" + }, "node_modules/@clerk/backend": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.23.0.tgz", @@ -389,7 +531,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite": { @@ -437,7 +579,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -822,7 +964,7 @@ "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -861,7 +1003,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -2144,7 +2286,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2154,14 +2296,14 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2448,11 +2590,20 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.152.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "funding": { @@ -3471,7 +3622,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -3488,6 +3639,12 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -3792,7 +3949,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -3803,14 +3960,14 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -4480,7 +4637,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", @@ -4508,7 +4665,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" @@ -4518,7 +4675,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -4852,7 +5009,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -4953,6 +5110,172 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", + "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.7", + "@better-auth/drizzle-adapter": "1.7.7", + "@better-auth/kysely-adapter": "1.7.7", + "@better-auth/memory-adapter": "1.7.7", + "@better-auth/mongo-adapter": "1.7.7", + "@better-auth/prisma-adapter": "1.7.7", + "@better-auth/telemetry": "1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.2.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.3.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/better-call/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/bidi-js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", @@ -5104,7 +5427,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/buffer-indexof-polyfill": { @@ -5205,7 +5528,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -5521,6 +5844,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5569,7 +5898,7 @@ "version": "0.31.11", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -6027,7 +6356,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6432,7 +6761,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -6499,7 +6828,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6961,7 +7290,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -7010,7 +7339,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -7367,7 +7696,7 @@ "version": "4.14.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -8148,6 +8477,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-cookie": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", @@ -8304,6 +8642,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8927,6 +9274,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -9183,7 +9545,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -9711,7 +10073,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -9751,7 +10113,7 @@ "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -9782,6 +10144,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9912,6 +10280,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10126,7 +10500,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10145,7 +10519,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -10173,7 +10547,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -10444,7 +10818,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.0.0" @@ -10454,7 +10828,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10464,7 +10838,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -10481,7 +10855,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10499,7 +10873,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -10598,7 +10972,7 @@ "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -11006,7 +11380,7 @@ "version": "8.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -11085,7 +11459,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "peer": true, "dependencies": { @@ -11358,7 +11732,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -11372,7 +11746,7 @@ "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -11402,7 +11776,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -11485,7 +11859,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" @@ -11495,7 +11869,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11609,7 +11983,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "why-is-node-running": "cli.js" diff --git a/package.json b/package.json index 68f31d8..a071487 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.3.8", "@radix-ui/react-slot": "^1.4.0", "@react-pdf/renderer": "^4.9.0", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.3", diff --git a/scripts/migrate-clerk-to-auth.ts b/scripts/migrate-clerk-to-auth.ts new file mode 100644 index 0000000..fada86f --- /dev/null +++ b/scripts/migrate-clerk-to-auth.ts @@ -0,0 +1,23 @@ +/** + * Copies Clerk-era people/workspaces/memberships into Better Auth (ADR-033). + * npx tsx --env-file=.env.local scripts/migrate-clerk-to-auth.ts # dry-run (default) + * npx tsx --env-file=.env.local scripts/migrate-clerk-to-auth.ts --apply # writes + * Prints only the database host, never the connection string. + */ +import { getDb } from "@/db"; +import { getEnv } from "@/env"; +import { migrateClerkToAuth } from "@/server/auth/migrate-from-clerk"; + +async function main() { + const apply = process.argv.includes("--apply"); + console.log(`Database host: ${new URL(getEnv().DATABASE_URL).host}`); + console.log(apply ? "Mode: APPLY (writes)" : "Mode: dry-run (nothing is written; pass --apply to write)"); + const report = await migrateClerkToAuth(getDb(), { apply }); + console.log(JSON.stringify(report, null, 2)); + if (apply && report.orphans > 0) process.exitCode = 1; +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..7638455 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,6 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { getAuth } from "@/server/auth/auth"; + +// Lazy so that importing the route never needs env vars (`next build`). +export const GET = (req: Request) => toNextJsHandler(getAuth()).GET(req); +export const POST = (req: Request) => toNextJsHandler(getAuth()).POST(req); diff --git a/src/db/auth-schema.ts b/src/db/auth-schema.ts new file mode 100644 index 0000000..7123caa --- /dev/null +++ b/src/db/auth-schema.ts @@ -0,0 +1,195 @@ +/** + * Better Auth tables (ADR-033), generated with `npx auth@latest generate` (better-auth 1.7.x; organization + emailOTP plugins, + * rate limit stored in the database) and prefixed `auth_` so they never get mixed up with Compasso's own `users`, + * `organizations` (Clientes) and `workspace_members`. + * The adapter finds tables by the exported names below (`user`, `session`, ...), which must not be renamed. + * Regenerate (and diff) after upgrading better-auth or changing its plugins. Our `users`/`workspaces` link to these by `auth_id`/`auth_org_id`. + */ +import { relations } from "drizzle-orm"; +import { + pgTable, + text, + bigint, + timestamp, + boolean, + integer, + index, +} from "drizzle-orm/pg-core"; + +export const user = pgTable("auth_user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = pgTable( + "auth_session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + }, + (table) => [index("auth_session_user_id_idx").on(table.userId)], +); + +export const account = pgTable( + "auth_account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("auth_account_user_id_idx").on(table.userId)], +); + +export const verification = pgTable( + "auth_verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("auth_verification_identifier_idx").on(table.identifier)], +); + +export const organization = pgTable("auth_organization", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at").notNull(), + metadata: text("metadata"), +}); + +export const member = pgTable( + "auth_member", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [ + index("auth_member_organization_id_idx").on(table.organizationId), + index("auth_member_user_id_idx").on(table.userId), + ], +); + +export const invitation = pgTable( + "auth_invitation", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("auth_invitation_organization_id_idx").on(table.organizationId), + index("auth_invitation_email_idx").on(table.email), + ], +); + +export const rateLimit = pgTable("auth_rate_limit", { + id: text("id").primaryKey(), + key: text("key").notNull().unique(), + count: integer("count").notNull(), + lastRequest: bigint("last_request", { mode: "number" }).notNull(), +}); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + members: many(member), + invitations: many(invitation), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); + +export const organizationRelations = relations(organization, ({ many }) => ({ + members: many(member), + invitations: many(invitation), +})); + +export const memberRelations = relations(member, ({ one }) => ({ + organization: one(organization, { + fields: [member.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [member.userId], + references: [user.id], + }), +})); + +export const invitationRelations = relations(invitation, ({ one }) => ({ + organization: one(organization, { + fields: [invitation.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [invitation.inviterId], + references: [user.id], + }), +})); diff --git a/src/db/schema.ts b/src/db/schema.ts index 31bff6c..ceb61cb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -44,7 +44,10 @@ const updatedAt = () => export const users = pgTable("users", { id: id(), - clerkId: text("clerk_id").notNull().unique(), + /** Clerk user id. Nullable since the move to Better Auth (ADR-033): people created after the switch have only `auth_id`. */ + clerkId: text("clerk_id").unique(), + /** Better Auth user id (`auth_user.id`). Filled by the data migration script and on first sign-in (linked by e-mail). */ + authId: text("auth_id").unique(), email: text("email").notNull().unique(), name: text("name"), avatarUrl: text("avatar_url"), @@ -58,12 +61,13 @@ export const users = pgTable("users", { deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: createdAt(), updatedAt: updatedAt(), -}); +}, (t) => [check("users_identity_chk", sql`${t.clerkId} is not null or ${t.authId} is not null`)]); export const workspaces = pgTable("workspaces", { id: id(), - /** Tenant. Maps 1:1 to a Clerk organization (source of truth for membership/invites). */ - clerkOrgId: text("clerk_org_id").notNull().unique(), + /** Tenant. Maps 1:1 to an organization of the identity provider: Clerk until the switch (`clerk_org_id`), Better Auth after (`auth_org_id`). */ + clerkOrgId: text("clerk_org_id").unique(), + authOrgId: text("auth_org_id").unique(), name: text("name").notNull(), slug: text("slug").notNull().unique(), /** @@ -75,7 +79,7 @@ export const workspaces = pgTable("workspaces", { archivedAt: timestamp("archived_at", { withTimezone: true }), createdAt: createdAt(), updatedAt: updatedAt(), -}); +}, (t) => [check("workspaces_identity_chk", sql`${t.clerkOrgId} is not null or ${t.authOrgId} is not null`)]); export const workspaceMembers = pgTable( "workspace_members", diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..c4d9b74 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,6 @@ +"use client"; + +import { emailOTPClient, organizationClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ plugins: [emailOTPClient(), organizationClient()] }); diff --git a/src/server/auth/auth.smoke.test.ts b/src/server/auth/auth.smoke.test.ts new file mode 100644 index 0000000..68144df --- /dev/null +++ b/src/server/auth/auth.smoke.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import * as authSchema from "@/db/auth-schema"; +import type { Db } from "@/db"; +import { createTestDb, type TestDb } from "@/test/db"; +import { createAuth } from "./auth"; +import { getAuthEnv } from "./env"; + +/** + * Exercises the real Better Auth (email OTP, organization, invitations, hooks) against PGlite with the real + * migrations. It proves the logic and the schema; it does NOT prove the neon-http driver (run the app for that). + */ +describe("better auth", () => { + let db: TestDb; + let auth: ReturnType; + const mails: { to: string; subject: string; text: string }[] = []; + + beforeAll(async () => { + db = await createTestDb(); + auth = createAuth({ + db: db as unknown as Db, + nextJsCookies: false, + env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123", BETTER_AUTH_URL: "http://localhost:3000" }), + send: async (m) => { + mails.push(m); + }, + }); + }); + + async function signIn(email: string) { + mails.length = 0; + await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); + await new Promise((r) => setTimeout(r, 20)); // the OTP mail is intentionally fire-and-forget + const otp = /(\d{6})/.exec(mails[0].text)![1]; + const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true }); + const cookie = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); + return { headers: new Headers({ cookie }), user: res.response.user }; + } + + it("signs in with an e-mail code, creates an organization and invites a second user who joins", async () => { + const owner = await signIn("owner@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Acme", slug: "acme" } }); + await auth.api.setActiveOrganization({ headers: owner.headers, body: { organizationId: org.id } }); + + mails.length = 0; + const invitation = await auth.api.createInvitation({ headers: owner.headers, body: { email: "guest@example.com", role: "member", organizationId: org.id } }); + expect(mails.at(-1)?.text).toContain(`/accept-invitation/${invitation.id}`); + + const guest = await signIn("guest@example.com"); + await auth.api.acceptInvitation({ headers: guest.headers, body: { invitationId: invitation.id } }); + const members = await auth.api.listMembers({ headers: owner.headers, query: { organizationId: org.id } }); + expect(members.members.map((m) => m.role).sort()).toEqual(["member", "owner"]); + + // the session hook: a returning user lands in their first organization + const again = await signIn("owner@example.com"); + expect((await auth.api.getSession({ headers: again.headers }))?.session.activeOrganizationId).toBe(org.id); + }); + + it("does not let another e-mail accept someone else's invitation", async () => { + const owner = await signIn("owner2@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Beta", slug: "beta" } }); + const invitation = await auth.api.createInvitation({ headers: owner.headers, body: { email: "right@example.com", role: "member", organizationId: org.id } }); + const wrong = await signIn("wrong@example.com"); + await expect(auth.api.acceptInvitation({ headers: wrong.headers, body: { invitationId: invitation.id } })).rejects.toThrow(); + }); + + it("rejects a wrong code", async () => { + await auth.api.sendVerificationOTP({ body: { email: "x@example.com", type: "sign-in" } }); + await expect(auth.api.signInEmailOTP({ body: { email: "x@example.com", otp: "000000" } })).rejects.toThrow(); + }); + + it("stores nothing but identity for social accounts (tokens stripped)", async () => { + const ctx = await auth.$context; + const u = await signIn("social@example.com"); + await ctx.internalAdapter.createAccount({ userId: u.user.id, providerId: "google", accountId: "g-1", accessToken: "secret-a", refreshToken: "secret-r", idToken: "secret-i" }); + const rows = await db.select().from(authSchema.account); + const row = rows.find((r) => r.providerId === "google")!; + expect([row.accessToken, row.refreshToken, row.idToken]).toEqual([null, null, null]); + }); +}); diff --git a/src/server/auth/auth.ts b/src/server/auth/auth.ts new file mode 100644 index 0000000..a2e57b6 --- /dev/null +++ b/src/server/auth/auth.ts @@ -0,0 +1,112 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { nextCookies } from "better-auth/next-js"; +import { emailOTP, organization } from "better-auth/plugins"; +import { and, asc, eq, isNull } from "drizzle-orm"; +import { getDb, type Db } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; +import { getAuthEnv } from "./env"; +import { sendMail, type Mailer } from "./mail"; +import { stripProviderTokens } from "./strip-tokens"; + +/** Factory so tests can inject an in-memory database and capture e-mails; the app uses `getAuth()`. */ +export function createAuth({ + db, + send = sendMail, + nextJsCookies = true, + env = getAuthEnv(), +}: { + db: Db; + send?: Mailer; + nextJsCookies?: boolean; + env?: ReturnType; +}) { + const baseURL = env.BETTER_AUTH_URL; + const google = + env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } } + : {}; + const github = + env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET + ? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } } + : {}; + + return betterAuth({ + baseURL, + secret: env.BETTER_AUTH_SECRET, + // neon-http (ADR-003): for provider "pg" the adapter only opens transactions when `transaction: true`, which we leave off. + database: drizzleAdapter(db, { provider: "pg", schema: authSchema }), + socialProviders: { ...google, ...github }, + // Counters live in the database: serverless instances do not share memory. Sending codes is the sensitive path. + rateLimit: { + storage: "database", + window: 60, + max: 100, + customRules: { "/email-otp/send-verification-otp": { window: 60, max: 3 } }, + }, + databaseHooks: { + session: { + create: { + // Sign in lands directly in the user's first organization (Clerk did this with the "active org"). + before: async (session) => { + const [first] = await db + .select({ organizationId: authSchema.member.organizationId }) + .from(authSchema.member) + .where(eq(authSchema.member.userId, session.userId)) + .orderBy(asc(authSchema.member.createdAt)) + .limit(1); + return { data: { ...session, activeOrganizationId: first?.organizationId ?? null } }; + }, + }, + }, + account: { + create: { before: async (account) => ({ data: stripProviderTokens(account) }) }, + update: { before: async (account) => ({ data: stripProviderTokens(account) }) }, + }, + }, + plugins: [ + emailOTP({ + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + async sendVerificationOTP({ email, otp }) { + // Not awaited on purpose (timing attacks). Errors are logged without the message body. + void send({ to: email, subject: "Seu código de acesso ao Compasso", text: `Seu código: ${otp}\nVálido por 5 minutos.` }).catch( + (e: unknown) => console.error("[auth] failed to send sign-in code", e instanceof Error ? e.message : e), + ); + }, + }), + organization({ + async sendInvitationEmail({ id, email, organization: org, inviter }) { + await send({ + to: email, + subject: `${inviter.user.name || inviter.user.email} convidou você para ${org.name} no Compasso`, + text: `Aceite o convite: ${baseURL}/accept-invitation/${id}\n(expira em 48 horas)`, + }); + }, + organizationHooks: { + // Logical removal (LGPD): the member loses access but their time entries stay. + afterRemoveMember: async ({ user, organization: org }) => { + const [ws] = await db.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.authOrgId, org.id)); + const [appUser] = await db.select({ id: users.id }).from(users).where(eq(users.authId, user.id)); + if (!ws || !appUser) return; + const now = new Date(); + await db + .update(workspaceMembers) + .set({ removedAt: now, updatedAt: now }) + .where(and(eq(workspaceMembers.workspaceId, ws.id), eq(workspaceMembers.userId, appUser.id), isNull(workspaceMembers.removedAt))); + }, + }, + }), + ...(nextJsCookies ? [nextCookies()] : []), // must be the last plugin + ], + }); +} + +let instance: ReturnType | undefined; + +/** Lazy singleton, like getDb(): `next build` must not need secrets. */ +export function getAuth() { + return (instance ??= createAuth({ db: getDb() })); +} diff --git a/src/server/auth/env.test.ts b/src/server/auth/env.test.ts new file mode 100644 index 0000000..d3ba6aa --- /dev/null +++ b/src/server/auth/env.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getAuthEnv } from "./env"; + +const secret = "x".repeat(32); + +describe("getAuthEnv", () => { + it("defaults the URL outside production and does not require a secret", () => { + expect(getAuthEnv({ NODE_ENV: "development" }).BETTER_AUTH_URL).toBe("http://localhost:3000"); + }); + it("requires secret and URL in production", () => { + expect(() => getAuthEnv({ NODE_ENV: "production" })).toThrow(/required in production/); + expect(() => getAuthEnv({ NODE_ENV: "production", BETTER_AUTH_SECRET: secret })).toThrow(); + expect(getAuthEnv({ NODE_ENV: "production", BETTER_AUTH_SECRET: secret, BETTER_AUTH_URL: "https://app.example.com" }).BETTER_AUTH_URL).toBe("https://app.example.com"); + }); + it("rejects a short secret", () => { + expect(() => getAuthEnv({ BETTER_AUTH_SECRET: "short" })).toThrow(); + }); +}); diff --git a/src/server/auth/env.ts b/src/server/auth/env.ts new file mode 100644 index 0000000..d8eb3db --- /dev/null +++ b/src/server/auth/env.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +const schema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + BETTER_AUTH_SECRET: z.string().min(32).optional(), + BETTER_AUTH_URL: z.url().optional(), + RESEND_API_KEY: z.string().min(1).optional(), + MAIL_FROM: z.string().min(3).optional(), + GOOGLE_CLIENT_ID: z.string().min(1).optional(), + GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), + GITHUB_CLIENT_ID: z.string().min(1).optional(), + GITHUB_CLIENT_SECRET: z.string().min(1).optional(), +}); + +export type AuthEnv = z.infer & { BETTER_AUTH_URL: string }; + +/** + * Kept apart from `getEnv()` on purpose: until the switch from Clerk (ADR-033) a production deploy without these + * variables must keep working. In production the secret and the public URL are mandatory. + */ +export function getAuthEnv(source: Record = process.env): AuthEnv { + const parsed = schema.safeParse(source); + if (!parsed.success) { + throw new Error(`Invalid auth environment variables: ${JSON.stringify(z.flattenError(parsed.error).fieldErrors)}`); + } + const env = parsed.data; + if (env.NODE_ENV === "production" && (!env.BETTER_AUTH_SECRET || !env.BETTER_AUTH_URL)) { + throw new Error("BETTER_AUTH_SECRET (>= 32 chars) and BETTER_AUTH_URL are required in production"); + } + return { ...env, BETTER_AUTH_URL: env.BETTER_AUTH_URL ?? "http://localhost:3000" }; +} diff --git a/src/server/auth/mail.ts b/src/server/auth/mail.ts new file mode 100644 index 0000000..f038a07 --- /dev/null +++ b/src/server/auth/mail.ts @@ -0,0 +1,23 @@ +import { getAuthEnv } from "./env"; + +export type Mail = { to: string; subject: string; text: string }; +export type Mailer = (message: Mail) => Promise; + +/** + * Transactional mail through the Resend REST API (no SDK). Without a key it only prints to the console, and only + * outside production: in production a missing key is an error and e-mail bodies (which carry OTPs) are never logged. + */ +export const sendMail: Mailer = async (message) => { + const env = getAuthEnv(); + if (!env.RESEND_API_KEY || !env.MAIL_FROM) { + if (env.NODE_ENV === "production") throw new Error("RESEND_API_KEY and MAIL_FROM are required in production"); + console.info(`[mail:dev] to=${message.to} subject="${message.subject}"\n${message.text}`); + return; + } + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ from: env.MAIL_FROM, ...message }), + }); + if (!res.ok) throw new Error(`Resend responded ${res.status}`); +}; diff --git a/src/server/auth/migrate-from-clerk.test.ts b/src/server/auth/migrate-from-clerk.test.ts new file mode 100644 index 0000000..9810a0e --- /dev/null +++ b/src/server/auth/migrate-from-clerk.test.ts @@ -0,0 +1,71 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { Db } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; +import { addMember, createTestDb, seedWorkspace, type TestDb } from "@/test/db"; +import { migrateClerkToAuth } from "./migrate-from-clerk"; + +describe("migrateClerkToAuth", () => { + let db: TestDb; + const run = (apply: boolean) => migrateClerkToAuth(db as unknown as Db, { apply }); + + beforeEach(async () => { + db = await createTestDb(); + const a = await seedWorkspace(db, "a"); // first admin of workspace a + await addMember(db, a.workspaceId, "a2", "admin"); + await addMember(db, a.workspaceId, "a3", "member"); + await seedWorkspace(db, "b"); + }); + + const roles = async () => (await db.select().from(authSchema.member)).map((m) => m.role).sort(); + + it("dry-run counts and writes nothing", async () => { + const r = await run(false); + expect(r.users.toCreate).toBe(4); + expect(r.workspaces.toCreate).toBe(2); + expect(r.members.toCreate).toBe(4); + expect(await db.select().from(authSchema.user)).toHaveLength(0); + expect(await db.select().from(authSchema.member)).toHaveLength(0); + expect((await db.select().from(users).where(eq(users.email, "a@example.com")))[0].authId).toBeNull(); + }); + + it("applies: links ids, makes the first admin the owner and leaves no orphans", async () => { + const r = await run(true); + expect(r).toMatchObject({ users: { created: 4 }, workspaces: { created: 2 }, members: { created: 4, owners: 2, admins: 1, members: 1 }, orphans: 0 }); + expect(await roles()).toEqual(["admin", "member", "owner", "owner"]); + const [u] = await db.select().from(users).where(eq(users.email, "a@example.com")); + const [au] = await db.select().from(authSchema.user).where(eq(authSchema.user.id, u.authId!)); + expect(au).toMatchObject({ email: "a@example.com", emailVerified: true, name: "User a" }); + }); + + it("is idempotent", async () => { + await run(true); + const again = await run(true); + expect(again).toMatchObject({ users: { created: 0 }, workspaces: { created: 0 }, members: { created: 0 }, orphans: 0 }); + expect(await db.select().from(authSchema.member)).toHaveLength(4); + }); + + it("skips deleted users, archived workspaces and removed members", async () => { + await db.update(users).set({ deletedAt: new Date() }).where(eq(users.email, "a3@example.com")); + await db.update(workspaces).set({ archivedAt: new Date() }).where(eq(workspaces.slug, "ws-b")); + const [a2] = await db.select().from(users).where(eq(users.email, "a2@example.com")); + await db.update(workspaceMembers).set({ removedAt: new Date() }).where(eq(workspaceMembers.userId, a2.id)); + const r = await run(true); + expect(r.users.created).toBe(3); // a, a2 (removed from the workspace) and b: the people are still active + expect(r.workspaces.created).toBe(1); + expect(r.members.created).toBe(1); + expect(r.orphans).toBe(0); + }); + + it("reuses Better Auth rows that already exist by e-mail and slug (rerun after a partial failure)", async () => { + await db.insert(authSchema.user).values({ id: "pre-user", email: "a@example.com", name: "Pre", emailVerified: true }); + await db.insert(authSchema.organization).values({ id: "pre-org", name: "Pre", slug: "ws-a", createdAt: new Date() }); + const r = await run(true); + expect(r.users).toMatchObject({ linked: 1, created: 3 }); + expect(r.workspaces).toMatchObject({ linked: 1, created: 1 }); + const [w] = await db.select().from(workspaces).where(eq(workspaces.slug, "ws-a")); + expect(w.authOrgId).toBe("pre-org"); + expect(r.orphans).toBe(0); + }); +}); diff --git a/src/server/auth/migrate-from-clerk.ts b/src/server/auth/migrate-from-clerk.ts new file mode 100644 index 0000000..30527fa --- /dev/null +++ b/src/server/auth/migrate-from-clerk.ts @@ -0,0 +1,120 @@ +import { randomUUID } from "node:crypto"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import type { Db } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; + +export type MigrationReport = { + apply: boolean; + users: { toCreate: number; created: number; linked: number }; + workspaces: { toCreate: number; created: number; linked: number }; + members: { toCreate: number; created: number; owners: number; admins: number; members: number }; + /** Active members whose user or workspace still has no Better Auth link after the run. Must be 0. */ + orphans: number; +}; + +/** + * One-off, idempotent copy of people, workspaces and memberships from our own tables (which already mirror Clerk) + * into the Better Auth tables (ADR-033). No passwords exist (sign-in is by code/social), sessions are not migrated + * and pending Clerk invitations must be re-sent. Deleted users, archived workspaces and removed members are skipped. + * Without `apply` it only counts (dry-run). Rerunning after a partial failure reuses what already exists. + */ +export async function migrateClerkToAuth(db: Db, { apply }: { apply: boolean }): Promise { + const report: MigrationReport = { + apply, + users: { toCreate: 0, created: 0, linked: 0 }, + workspaces: { toCreate: 0, created: 0, linked: 0 }, + members: { toCreate: 0, created: 0, owners: 0, admins: 0, members: 0 }, + orphans: 0, + }; + const now = new Date(); + + // 1. people + const pendingUsers = await db.select().from(users).where(and(isNull(users.authId), isNull(users.deletedAt))); + for (const u of pendingUsers) { + const email = u.email.trim().toLowerCase(); + const [existing] = await db.select({ id: authSchema.user.id }).from(authSchema.user).where(eq(authSchema.user.email, email)); + if (!apply) { + if (existing) report.users.linked++; + else report.users.toCreate++; + continue; + } + let authId = existing?.id; + if (authId) report.users.linked++; + else { + authId = randomUUID(); + await db.insert(authSchema.user).values({ + id: authId, + email, + emailVerified: true, // Clerk verified every address before our mirror existed + name: u.name?.trim() || email.split("@")[0], + image: u.avatarUrl, + createdAt: u.createdAt, + updatedAt: now, + }); + report.users.created++; + } + await db.update(users).set({ authId, updatedAt: now }).where(eq(users.id, u.id)); + } + + // 2. workspaces + const pendingWorkspaces = await db.select().from(workspaces).where(and(isNull(workspaces.authOrgId), isNull(workspaces.archivedAt))); + for (const w of pendingWorkspaces) { + const [existing] = await db.select({ id: authSchema.organization.id }).from(authSchema.organization).where(eq(authSchema.organization.slug, w.slug)); + if (!apply) { + if (existing) report.workspaces.linked++; + else report.workspaces.toCreate++; + continue; + } + let orgId = existing?.id; + if (orgId) report.workspaces.linked++; + else { + orgId = randomUUID(); + await db.insert(authSchema.organization).values({ id: orgId, name: w.name, slug: w.slug, createdAt: w.createdAt }); + report.workspaces.created++; + } + await db.update(workspaces).set({ authOrgId: orgId, updatedAt: now }).where(eq(workspaces.id, w.id)); + } + + // 3. memberships: the earliest admin of each workspace is the owner, other admins stay admins + const wsRows = await db.select().from(workspaces).where(isNull(workspaces.archivedAt)); + for (const w of wsRows) { + const members = await db + .select({ userId: users.id, authId: users.authId, role: workspaceMembers.role }) + .from(workspaceMembers) + .innerJoin(users, eq(users.id, workspaceMembers.userId)) + .where(and(eq(workspaceMembers.workspaceId, w.id), isNull(workspaceMembers.removedAt), isNull(users.deletedAt))) + .orderBy(asc(workspaceMembers.createdAt)); + // In a dry-run nothing is linked yet, so every eligible membership counts as to-create. + const present = w.authOrgId ? await db.select().from(authSchema.member).where(eq(authSchema.member.organizationId, w.authOrgId)) : []; + const hasOwner = present.some((m) => m.role === "owner"); + let ownerAssigned = hasOwner; + for (const m of members) { + if (m.authId && present.some((p) => p.userId === m.authId)) continue; + let role: "owner" | "admin" | "member" = m.role === "admin" ? "admin" : "member"; + if (role === "admin" && !ownerAssigned) { + role = "owner"; + ownerAssigned = true; + } + if (!apply) { + report.members.toCreate++; + continue; + } + await db.insert(authSchema.member).values({ id: randomUUID(), organizationId: w.authOrgId!, userId: m.authId!, role, createdAt: now }); + report.members.created++; + if (role === "owner") report.members.owners++; + else if (role === "admin") report.members.admins++; + else report.members.members++; + } + } + + // 4. orphan check (always meaningful on apply; on dry-run it shows what is still unlinked) + const [orphan] = await db + .select({ n: sql`count(*)::int` }) + .from(workspaceMembers) + .innerJoin(users, eq(users.id, workspaceMembers.userId)) + .innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId)) + .where(and(isNull(workspaceMembers.removedAt), isNull(users.deletedAt), isNull(workspaces.archivedAt), sql`(${users.authId} is null or ${workspaces.authOrgId} is null)`)); + report.orphans = orphan.n; + return report; +} diff --git a/src/server/auth/strip-tokens.test.ts b/src/server/auth/strip-tokens.test.ts new file mode 100644 index 0000000..fb4ad03 --- /dev/null +++ b/src/server/auth/strip-tokens.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { stripProviderTokens } from "./strip-tokens"; + +describe("stripProviderTokens", () => { + it("nulls provider tokens and keeps identity fields", () => { + const out = stripProviderTokens({ + providerId: "google", + accountId: "123", + accessToken: "a", + refreshToken: "r", + idToken: "i", + accessTokenExpiresAt: new Date(), + scope: "email", + }); + expect(out).toMatchObject({ providerId: "google", accountId: "123", scope: "email", accessToken: null, refreshToken: null, idToken: null, accessTokenExpiresAt: null }); + }); + + it("does not add fields that were not there", () => { + expect("accessToken" in stripProviderTokens({ providerId: "x" })).toBe(false); + }); +}); diff --git a/src/server/auth/strip-tokens.ts b/src/server/auth/strip-tokens.ts new file mode 100644 index 0000000..2e51be1 --- /dev/null +++ b/src/server/auth/strip-tokens.ts @@ -0,0 +1,11 @@ +/** + * Compasso only uses Google/GitHub to prove who the person is; it never calls their APIs. Keeping provider tokens + * would add database-leak risk for no benefit (LGPD data minimisation), so they are removed before storing. + */ +const TOKEN_FIELDS = ["accessToken", "refreshToken", "idToken", "accessTokenExpiresAt", "refreshTokenExpiresAt"] as const; + +export function stripProviderTokens>(account: T): T { + const copy: Record = { ...account }; + for (const field of TOKEN_FIELDS) if (field in copy) copy[field] = null; + return copy as T; +} diff --git a/src/server/auth/workspace-context.test.ts b/src/server/auth/workspace-context.test.ts new file mode 100644 index 0000000..b884030 --- /dev/null +++ b/src/server/auth/workspace-context.test.ts @@ -0,0 +1,117 @@ +import { and, eq } from "drizzle-orm"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { Db } from "@/db"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; +import { createTestDb, type TestDb } from "@/test/db"; +import { createAuth } from "./auth"; +import { getAuthEnv } from "./env"; +import { resolveWorkspaceContext, roleFromAuth, type AuthSessionLike } from "./workspace-context"; + +describe("roleFromAuth", () => { + it("maps owner and admin to admin, everything else to member", () => { + expect(roleFromAuth("owner")).toBe("admin"); + expect(roleFromAuth("admin")).toBe("admin"); + expect(roleFromAuth("member,admin")).toBe("admin"); + expect(roleFromAuth("member")).toBe("member"); + expect(roleFromAuth(null)).toBe("member"); + expect(roleFromAuth("administrator")).toBe("member"); + }); +}); + +describe("resolveWorkspaceContext", () => { + let db: TestDb; + let auth: ReturnType; + const mails: { text: string }[] = []; + const asDb = () => db as unknown as Db; + + beforeAll(async () => { + db = await createTestDb(); + auth = createAuth({ + db: asDb(), + nextJsCookies: false, + env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123" }), + send: async (m) => void mails.push(m), + }); + }); + + async function signIn(email: string) { + mails.length = 0; + await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); + await new Promise((r) => setTimeout(r, 20)); + const otp = /(\d{6})/.exec(mails[0].text)![1]; + const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true }); + const headers = new Headers({ cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }); + return { headers, user: res.response.user }; + } + const sessionOf = (user: { id: string; email: string; name: string }, orgId: string | null): AuthSessionLike => ({ + user: { id: user.id, email: user.email, name: user.name, image: null }, + session: { activeOrganizationId: orgId }, + }); + + it("provisions user, workspace and membership on first access, and is idempotent", async () => { + const owner = await signIn("ana@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Acme Ltda", slug: "acme" } }); + + const first = await resolveWorkspaceContext(asDb(), sessionOf(owner.user, org.id)); + expect(first.kind).toBe("ok"); + const again = await resolveWorkspaceContext(asDb(), sessionOf(owner.user, org.id)); + expect(again).toEqual(first); + if (first.kind !== "ok") return; + expect(first.context.role).toBe("admin"); + expect(first.context.workspaceName).toBe("Acme Ltda"); + expect(await db.select().from(workspaces).where(eq(workspaces.authOrgId, org.id))).toHaveLength(1); + expect(await db.select().from(users).where(eq(users.email, "ana@example.com"))).toHaveLength(1); + }); + + it("returns no-org without an active organization and no-membership for a stranger", async () => { + const u = await signIn("lone@example.com"); + expect((await resolveWorkspaceContext(asDb(), sessionOf(u.user, null))).kind).toBe("no-org"); + const owner = await signIn("boss@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Closed", slug: "closed" } }); + expect((await resolveWorkspaceContext(asDb(), sessionOf(u.user, org.id))).kind).toBe("no-membership"); + }); + + it("links a pre-existing (migrated/Clerk) person by e-mail instead of duplicating", async () => { + await db.insert(users).values({ clerkId: "user_legacy", email: "Legacy@Example.com", name: "Legacy Name" }); + const u = await signIn("legacy@example.com"); + const org = await auth.api.createOrganization({ headers: u.headers, body: { name: "Legacy Org", slug: "legacy-org" } }); + const res = await resolveWorkspaceContext(asDb(), sessionOf(u.user, org.id)); + expect(res.kind).toBe("ok"); + const rows = await db.select().from(users).where(eq(users.clerkId, "user_legacy")); + expect(rows).toHaveLength(1); + expect(rows[0].authId).toBe(u.user.id); + expect(rows[0].name).toBe("Legacy Name"); + }); + + it("never resurrects a deleted person nor un-archives a workspace", async () => { + const u = await signIn("gone@example.com"); + const org = await auth.api.createOrganization({ headers: u.headers, body: { name: "Gone Org", slug: "gone-org" } }); + expect((await resolveWorkspaceContext(asDb(), sessionOf(u.user, org.id))).kind).toBe("ok"); + + await db.update(workspaces).set({ archivedAt: new Date() }).where(eq(workspaces.authOrgId, org.id)); + expect((await resolveWorkspaceContext(asDb(), sessionOf(u.user, org.id))).kind).toBe("archived"); + + await db.update(users).set({ deletedAt: new Date() }).where(eq(users.authId, u.user.id)); + expect((await resolveWorkspaceContext(asDb(), sessionOf(u.user, org.id))).kind).toBe("deleted"); + }); + + it("marks the membership as removed when Better Auth removes the member (afterRemoveMember hook)", async () => { + const owner = await signIn("owner3@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Hook Org", slug: "hook-org" } }); + const invitation = await auth.api.createInvitation({ headers: owner.headers, body: { email: "guest3@example.com", role: "member", organizationId: org.id } }); + const guest = await signIn("guest3@example.com"); + await auth.api.acceptInvitation({ headers: guest.headers, body: { invitationId: invitation.id } }); + const res = await resolveWorkspaceContext(asDb(), sessionOf(guest.user, org.id)); + expect(res.kind).toBe("ok"); + if (res.kind !== "ok") return; + expect(res.context.role).toBe("member"); + + await auth.api.removeMember({ headers: owner.headers, body: { memberIdOrEmail: "guest3@example.com", organizationId: org.id } }); + const [row] = await db + .select() + .from(workspaceMembers) + .where(and(eq(workspaceMembers.workspaceId, res.context.workspaceId), eq(workspaceMembers.userId, res.context.userId))); + expect(row.removedAt).not.toBeNull(); + expect((await resolveWorkspaceContext(asDb(), sessionOf(guest.user, org.id))).kind).toBe("no-membership"); + }); +}); diff --git a/src/server/auth/workspace-context.ts b/src/server/auth/workspace-context.ts new file mode 100644 index 0000000..2a79509 --- /dev/null +++ b/src/server/auth/workspace-context.ts @@ -0,0 +1,128 @@ +import { and, eq, sql } from "drizzle-orm"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getDb, type Db } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; +import { slugify } from "@/lib/slug"; +import type { WorkspaceRole } from "@/lib/roles"; +import type { WorkspaceContext } from "@/server/workspace-context"; +import { getAuth } from "./auth"; + +/** The plugin stores comma-separated roles; owner and admin both administer the workspace. */ +export function roleFromAuth(role: string | null | undefined): WorkspaceRole { + const roles = (role ?? "").split(",").map((r) => r.trim()); + return roles.includes("owner") || roles.includes("admin") ? "admin" : "member"; +} + +export type AuthSessionLike = { + user: { id: string; email: string; name?: string | null; image?: string | null }; + session: { activeOrganizationId?: string | null }; +}; + +export type ContextResult = + | { kind: "ok"; context: WorkspaceContext } + /** Signed in but no active organization: onboarding. */ + | { kind: "no-org" } + /** The active organization is stale: the user is no longer a member. */ + | { kind: "no-membership" } + /** The person was deleted (LGPD tombstone): never resurrected. */ + | { kind: "deleted" } + /** The workspace was archived: access blocked, data retained. */ + | { kind: "archived" }; + +/** + * Same contract as the Clerk flow (`requireWorkspaceContext`): turns a Better Auth session into our own + * user/workspace/membership rows, creating them on first access. neon-http has no transactions, so every step is + * one idempotent statement and concurrent first requests converge on the same rows. + */ +export async function resolveWorkspaceContext(db: Db, session: AuthSessionLike): Promise { + const orgId = session.session.activeOrganizationId; + if (!orgId) return { kind: "no-org" }; + + const [row] = await db + .select({ role: authSchema.member.role, orgName: authSchema.organization.name, orgSlug: authSchema.organization.slug }) + .from(authSchema.member) + .innerJoin(authSchema.organization, eq(authSchema.organization.id, authSchema.member.organizationId)) + .where(and(eq(authSchema.member.userId, session.user.id), eq(authSchema.member.organizationId, orgId))) + .limit(1); + if (!row) return { kind: "no-membership" }; + + const user = await ensureUser(db, session.user); + if (!user) return { kind: "deleted" }; + + const [workspace] = await db + .insert(workspaces) + .values({ authOrgId: orgId, name: row.orgName, slug: `${slugify(row.orgSlug, 40)}-${orgId.slice(-6).toLowerCase()}` }) + // Slug is set once on insert and never changes. An archived workspace is never un-archived here. + .onConflictDoUpdate({ target: workspaces.authOrgId, set: { name: row.orgName, updatedAt: new Date() } }) + .returning(); + if (workspace.archivedAt) return { kind: "archived" }; + + const role = roleFromAuth(row.role); + await db + .insert(workspaceMembers) + .values({ workspaceId: workspace.id, userId: user.id, role }) + .onConflictDoUpdate({ + target: [workspaceMembers.workspaceId, workspaceMembers.userId], + set: { role, removedAt: null, updatedAt: new Date() }, + }); + + return { + kind: "ok", + context: { userId: user.id, workspaceId: workspace.id, role, timezone: user.timezone, userName: user.name, workspaceName: workspace.name }, + }; +} + +/** By `auth_id`; else links an existing person by e-mail (migrated or first social login); else creates. Null = deleted. */ +async function ensureUser(db: Db, authUser: AuthSessionLike["user"]) { + const email = authUser.email.trim().toLowerCase(); + const profile = { name: authUser.name?.trim() || null, avatarUrl: authUser.image ?? null }; + + const [byAuthId] = await db.select().from(users).where(eq(users.authId, authUser.id)); + if (byAuthId) { + if (byAuthId.deletedAt) return null; + const changed = (profile.name && profile.name !== byAuthId.name) || profile.avatarUrl !== byAuthId.avatarUrl; + if (!changed) return byAuthId; + const [updated] = await db + .update(users) + .set({ name: profile.name ?? byAuthId.name, avatarUrl: profile.avatarUrl, updatedAt: new Date() }) + .where(eq(users.id, byAuthId.id)) + .returning(); + return updated; + } + + const [byEmail] = await db.select().from(users).where(sql`lower(${users.email}) = ${email}`); + if (byEmail) { + if (byEmail.deletedAt) return null; + const [linked] = await db + .update(users) + .set({ authId: authUser.id, name: byEmail.name ?? profile.name, avatarUrl: byEmail.avatarUrl ?? profile.avatarUrl, updatedAt: new Date() }) + .where(eq(users.id, byEmail.id)) + .returning(); + return linked; + } + + const [created] = await db + .insert(users) + .values({ authId: authUser.id, email, ...profile }) + .onConflictDoUpdate({ target: users.authId, set: { updatedAt: new Date() } }) + .returning(); + return created; +} + +/** Page/action entry point. Not used by the app until the login swap (PR 2). */ +export async function requireAuthWorkspaceContext(): Promise { + const session = await getAuth().api.getSession({ headers: await headers() }); + if (!session) redirect("/sign-in"); + const result = await resolveWorkspaceContext(getDb(), session); + switch (result.kind) { + case "ok": + return result.context; + case "no-org": + case "no-membership": + return redirect("/onboarding"); + default: + return redirect("/sign-in?error=unavailable"); + } +}