From 44bd789b0f15976dd5a419e0b7f3d39692cae14c Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Mon, 30 Dec 2024 14:56:53 +0100 Subject: [PATCH] Use better auth --- .../drizzle/0005_ancient_forgotten_one.sql | 94 +++ apps/api/drizzle/meta/0005_snapshot.json | 722 ++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/package.json | 2 +- apps/api/src/auth/index.ts | 26 + apps/api/src/db/schema.ts | 85 ++- apps/api/src/routes/auth/index.ts | 222 +----- bun.lockb | Bin 683960 -> 692624 bytes 8 files changed, 921 insertions(+), 237 deletions(-) create mode 100644 apps/api/drizzle/0005_ancient_forgotten_one.sql create mode 100644 apps/api/drizzle/meta/0005_snapshot.json create mode 100644 apps/api/src/auth/index.ts diff --git a/apps/api/drizzle/0005_ancient_forgotten_one.sql b/apps/api/drizzle/0005_ancient_forgotten_one.sql new file mode 100644 index 0000000..88e8176 --- /dev/null +++ b/apps/api/drizzle/0005_ancient_forgotten_one.sql @@ -0,0 +1,94 @@ +CREATE TABLE `accounts` ( + `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` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `invitations` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `email` text NOT NULL, + `role` text NOT NULL, + `status` text NOT NULL, + `expires_at` integer NOT NULL, + `inviter_id` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`inviter_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `members` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `user_id` text NOT NULL, + `role` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `organizations` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text, + `logo` text, + `created_at` integer NOT NULL, + `metadata` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `organizations_slug_unique` ON `organizations` (`slug`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint +CREATE TABLE `verifications` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +DROP TABLE `team_invites`;--> statement-breakpoint +DROP TABLE `team_members`;--> statement-breakpoint +DROP TABLE `teams`;--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_projects` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_projects`("id", "organization_id", "name", "description", "created_at", "updated_at") SELECT "id", "organization_id", "name", "description", "created_at", "updated_at" FROM `projects`;--> statement-breakpoint +DROP TABLE `projects`;--> statement-breakpoint +ALTER TABLE `__new_projects` RENAME TO `projects`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +ALTER TABLE `users` ADD `email_verified` integer NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `image` text; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..0f9cf7e --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,722 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3e6501e8-50ed-49bb-9157-be9def8f8df6", + "prevId": "0ed9ceac-2419-4c39-a4ee-e055a6f09714", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index e6711b8..539bd1b 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1735564498941, "tag": "0004_windy_mystique", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1735566706181, + "tag": "0005_ancient_forgotten_one", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 59d6424..41794f8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,9 +13,9 @@ "studio": "drizzle-kit studio" }, "dependencies": { - "@hono/oauth-providers": "^0.6.2", "@hono/zod-validator": "^0.4.2", "@scalar/hono-api-reference": "^0.5.165", + "better-auth": "^1.1.7", "drizzle-orm": "^0.38.3", "hono": "^4.6.15", "hono-openapi": "^0.3.1", diff --git a/apps/api/src/auth/index.ts b/apps/api/src/auth/index.ts new file mode 100644 index 0000000..89beca0 --- /dev/null +++ b/apps/api/src/auth/index.ts @@ -0,0 +1,26 @@ +import { db } from "@/db"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { bearer, magicLink, organization } from "better-auth/plugins"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "sqlite", + usePlural: true, + }), + plugins: [ + bearer(), + organization(), + magicLink({ + sendMagicLink: async ({ email, token, url }, request) => { + // send email to user + }, + }), + ], + // socialProviders: { + // github: { + // clientId: process.env.GITHUB_CLIENT_ID!, + // clientSecret: process.env.GITHUB_CLIENT_SECRET!, + // }, + // }, +}); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index fd43142..4e4a79d 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: text("id").primaryKey(), @@ -7,22 +7,17 @@ export const users = sqliteTable("users", { name: text("name").notNull(), avatar: text("avatar"), provider: text("provider", { enum: ["github", "google"] }).notNull(), + emailVerified: integer("email_verified", { mode: "boolean" }).notNull(), + image: text("image"), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), }); -export const teams = sqliteTable("teams", { +export const members = sqliteTable("members", { id: text("id").primaryKey(), - name: text("name").notNull(), - createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}); - -export const teamMembers = sqliteTable("team_members", { - id: text("id").primaryKey(), - teamId: text("team_id") + organizationId: text("organization_id") .notNull() - .references(() => teams.id, { onDelete: "cascade" }), + .references(() => organizations.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() .references(() => users.id), @@ -31,22 +26,27 @@ export const teamMembers = sqliteTable("team_members", { updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), }); -export const teamInvites = sqliteTable("team_invites", { +export const invitations = sqliteTable("invitations", { id: text("id").primaryKey(), - teamId: text("team_id") + organizationId: text("organization_id") .notNull() - .references(() => teams.id, { onDelete: "cascade" }), + .references(() => organizations.id, { onDelete: "cascade" }), email: text("email").notNull(), role: text("role", { enum: ["owner", "member"] }).notNull(), + status: text("status").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => users.id), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), }); export const projects = sqliteTable("projects", { id: text("id").primaryKey(), - teamId: text("team_id") + organizationId: text("organization_id") .notNull() - .references(() => teams.id, { onDelete: "cascade" }), + .references(() => organizations.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), @@ -60,3 +60,56 @@ export const apiTokens = sqliteTable("api_tokens", { createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), }); + +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + token: text("token").notNull().unique(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => users.id), +}); + +export const accounts = sqliteTable("accounts", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: integer("access_token_expires_at", { + mode: "timestamp", + }), + refreshTokenExpiresAt: integer("refresh_token_expires_at", { + mode: "timestamp", + }), + scope: text("scope"), + password: text("password"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), +}); + +export const verifications = sqliteTable("verifications", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), +}); + +export const organizations = sqliteTable("organizations", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").unique(), + logo: text("logo"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + metadata: text("metadata"), +}); diff --git a/apps/api/src/routes/auth/index.ts b/apps/api/src/routes/auth/index.ts index 218beb8..6e3cf5d 100644 --- a/apps/api/src/routes/auth/index.ts +++ b/apps/api/src/routes/auth/index.ts @@ -1,226 +1,8 @@ +import { auth } from "@/auth"; import { Hono } from "@/libs/app"; -import { githubAuth } from "@hono/oauth-providers/github"; -import { googleAuth } from "@hono/oauth-providers/google"; -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { authResponseSchema } from "./schema"; const app = new Hono(); -app.use("/github", (c, next) => { - githubAuth({ - client_id: c.env.GITHUB_CLIENT_ID, - client_secret: c.env.GITHUB_CLIENT_SECRET, - }); - - return next(); -}); - -app.get( - "/github", - describeRoute({ - description: "Handle GitHub OAuth authentication", - responses: { - 200: { - description: "Successfully authenticated with GitHub", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - 401: { - description: "Authentication failed", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.get("token"); - const profile = c.get("user-github"); - - if (!token || !profile) { - return c.json({ error: "Failed to authenticate with GitHub" }, 401); - } - - return c.json({ - data: { - token, - user: { - email: profile.email, - name: profile.name, - provider: "github", - }, - }, - }); - }, -); - -app.use("/google", (c, next) => { - googleAuth({ - client_id: c.env.GOOGLE_CLIENT_ID, - client_secret: c.env.GOOGLE_CLIENT_SECRET, - scope: ["openid", "email", "profile"], - }); - - return next(); -}); - -app.get( - "/google", - describeRoute({ - description: "Handle Google OAuth authentication", - responses: { - 200: { - description: "Successfully authenticated with Google", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - 401: { - description: "Authentication failed", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.get("token"); - const profile = c.get("user-google"); - - if (!token || !profile) { - return c.json({ error: "Failed to authenticate with Google" }, 401); - } - - return c.json({ - data: { - token, - user: { - email: profile.email, - name: profile.name, - provider: "google", - }, - }, - }); - }, -); - -app.post( - "/token", - describeRoute({ - description: "Exchange OAuth token for user info", - responses: { - 200: { - description: "Successfully exchanged token", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - 401: { - description: "Invalid token", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - // TODO: Implement token verification and user info retrieval - // This would involve: - // 1. Verifying the token's validity - // 2. Fetching associated user information - // 3. Returning the user data in the same format as the OAuth endpoints - - return c.json({ - data: { - token, - user: { - // User data would be populated from token verification - email: "", - name: "", - provider: "github", // or "google" depending on token - }, - }, - }); - } catch (error) { - return c.json({ error: "Invalid token" }, 401); - } - }, -); - -app.post( - "/revalidate", - describeRoute({ - description: "Revalidate an existing auth token", - responses: { - 200: { - description: "Successfully revalidated token", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - 401: { - description: "Invalid or expired token", - content: { - "application/json": { - schema: resolver(authResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - // TODO: Implement token revalidation logic - // This would involve: - // 1. Verifying the token hasn't expired - // 2. Checking if the token is still valid - // 3. Optionally refreshing the token if needed - // 4. Returning updated token and user info - - return c.json({ - data: { - token, - user: { - // User data would be populated from token verification - email: "", - name: "", - provider: "github", // or "google" depending on token - }, - }, - }); - } catch (error) { - return c.json({ error: "Invalid or expired token" }, 401); - } - }, -); +app.on(["POST", "GET"], "/**", (c) => auth.handler(c.req.raw)); export default app; diff --git a/bun.lockb b/bun.lockb index 8e14c20fb46a77d6d5390b98ea499249e0ad5aa8..e81fc95468d5fea94d670e389dd4148727ed4c2e 100755 GIT binary patch delta 22037 zcmeHvcU%<7)^_&*!wiamihzOwii%1Y5QZQKVgNHH%!&vCl7oth1LiC^t7TTqSy|o1 zum*HZ>l!fUylW1a6~p(OnQCG0UGMLG?{~lNuiL+Ud`>;*^y#YV>guMaw|3lg&Rfwa z&((Q>R&6xz-13@r7S`@F_wd%II}*(wBroi?vah#ol4Z&M1R>uX#0^T$5bU)MZdMxmC!&CTaw5})W@M#A3mZ|U=oMfk_{$?v zumYd{BnTGZjmT0Be1+==Id1~jfIgR($Ahb)9F>rq6pb3acrFTcQC{;x6wJW)^tICD zT;|owOo~d&O36qbnw1`%kR=H5$?3G8FgQ9Ullaw3eZ|s}Gd-z14`r&zbub;cQ(e8^ z0umz zdSEBm6ubdk1w0W9&)C3Rtp!e?q)Ban)6gM(9I&v^79GjdxG;&Zv^zgLEq#+5> zY3IsBp}qzI@t;J&7W^l;I=Jm;ed0+*g6@FnqM$o*jlbwCw3&89+-xxIml&6*>#6Z5 z(*d)?hB?w&A|5H%%s6_QE`HB$55v0v8?3Gs93akppi^hk#%4iV;dLf8y1=F zl0iL@DVLe)TAY}fo)H-p8{N4~Yoa>rUZsjm*YM1kWMmP9VTs@(MB*GU^YI_jT-DXdX0C#CiCaT z-(6~PRr7vomG#~S_Ss%F{y4VDRo_9;kAl}IbbclZopq()pVZ35Qi+KLqT1*KiXF9|WBrwFFh^3DOe-!`D_5e_{#&UU=1mV> zsW@IOwqm}wag?7S`av{i;wjOG?IbM}(-O5=)CM!P@(W5nD4`z7YE$o5`nKU(5^8C4 z;~Q&JVil%Cp}Hdm(~Y3iipe@vxsa=Jtyrj59!H6?3R*EmtyE%aq&);J+GFaAQZPy) z_Qz4r>-QfCwG~uTU1M5dw$+^pZEljn(gKA}^x1!J# zB?E17zFOHFC2yUN8j^`p*gqvO-3ysc=wI`iitqkbZV9>FKSdkOg4|jgu-D(>JS<&T zQo{gSttz$wb(yl-dr@eEUGIC-}x&a!J?CfTAozP{JyY} zf8fYDxWz>2Q{}4-?tT%qWy=DT>aY6KZJ;}qLr`jo7`ZkW2iSm;u8&yoC0t@u(tghK zH}Di>%>TX^pj?5QON1_E@E*0nN65P&8-h~MhERO8*^~U0Utv)X ziY)lPh%S`LgaPG6m4Qs?Q(l~oVq|&oDT@8di*0dBq6X@rO*SkpA-F+ zu0}H5g@wiysEvlA6sXO{#kdU?RYIoCU8YvPKuLcwqE`)?$|@QpNRfORtlNWL2nK&Hw$%U zz;m&gVY%2&DHGc1L+8Q_)0;QT&4wo5)YNiwSGj3W>03bia&u0(c>`t}eO{-^WbS!} zD(Ea-ZMT)1A7QrAhc-9;W{xj6k9{*6df9%*C6v;$+U zWz(y$k1x?lyWsTDe%|UYds2n%dWDN7#*BRg8=b+OJxu#~hQF+qIm0v)Xw=o5IyCz= zI&?YA4tn!>x!KI(n>wZ3ya2PEKGvZsb&W1Ve7U);-27B-wyO3mc4oPGvD~z;{w*L5 zW*dFpt>xysax>8KTfhXEt@N?Km78XGp5bb@Z>DlS%pl!>!&Rkth!WjxSb?#DJ9}|M zR4S}wx|tC3zDBLgK#BYq5-AuHxrCXP|o#3PX_RE4&9j*kKbN@&c2pNP5=O(K}i zagCS3M{}5oHaJ;rnO{ZXQMLw|Q$ zj#4v}jC6C#4em42MLOachUR)>oi7F@+RsQEoTN5dPd=92QEaBblTti%Q{8P;*$7Ws zz9?c^+UTP|f$Cwbn*=N2VU1>4{gl`pB|5UHw)h9Nat%s!Vb`E5SNRGhx+UPIhkH&S z9_FasO1gO}XQI>sB@?ZP)|}=(g|1{~kCOx+S_5A-i8v5{z|&LnyG_K z=!z0fnZ49-?={oDkMj9Z-rmntj$cmW3DA6&{ zM>yg^lzO2g*Y%$*o(Vglq_52cl<0{1$V(`7KuKS3Cnx>S(;mTWnAmtc!;L$ zU$G(lemt%sE1zPo`V>V@IpG6jyViW`wZvhk-R(#OdB!zNAof9e5Iv)d2c|36`>&>S95Obwd{rhE%b(S9-(@c?4lVKFcNE5_d{!ZsfF zZ?oqAHo?EKKMkWIzMn=ih{>@VOg*xP^FA=O^gNih|1fL*(+U5Qk51(UUh#@d4ZNh& zasJ7mL2(s~cLTybUcRpj5QM*DO7M{T|BmUvkKm`cCtxZ+Jkx z&vV^YfoVsPmx-w?1JkLJgXsVYFg2hOm^NaHtIYjXxZi^NiD`dJa3W1TcHBWs4hLQ) zrUTRk(`DC?`{xHg0ry3(k1R}lyDqqF*2#y{ z9YP#$9@!Q!r-Nnj(YJ>bBUzrtOj(MmiM)yr={CVP!)-fy+5K8O^vt^7osC;3dl)=b zHrsJO)oIO)eLuI)bt-n5B3s?~{f2ioRgVY0E*RkCBD-j1e!;CnUdh8g!n~E~HMTO- z;fB^7gkwWSUJQ8elpTM->z(S)&4&AWO{>vWSaZ2{$;lqadIlbP^T)h!)sOP%fE6oB zmsDzJ{a~E+>!c5De8yDWmb9>cbcO0YHe!x54STSo!wpR}>M0>T5>0DH4ye4?N>!!G zt6?!;hj*I$(tLQW7kdq-wGBNqK>PcxXzQ@fuD@Qps7^XPxcK1gAG`KyTPWYTWBZGu z5f$=2t=Mee^!g1(muM6E_YGfakha(S?vY9hcehlve%7VRkydY3UB2C=MKjg;p*D#F zHhS;>bUG|9eChO-!_$8KaOmfnd%BHkwv#1}z*#XKc6097=9hyy_spr*^2YiFUGq18 z8t$>UUf!iY`|f_7eCuh_xiKrhG_O0~+~DKEE?;N1y7O|s{i@6_i&l)iUL|&Py$aQP zUa@)|_S|-=*}u~y`%Xh=`C9orUaK^he`r^&Zxu+_3i|u+$7(cB?jk5=O*L}EV)~{76RPR;A>RI-BzHxcV>YMGF z#|3!|XcY6ig?HlFJ6l@#++27#V(^tF^{$1-?N+4PT#C4}wBe>{UXu!Yc*M>6@!q0? zZQ34C^-seKn~|vA%HM_Xg!=tbe6*>9w|l2S?^`<}>kve$k-Kl4i z9G^ROT>E3Mo)*B_HPZK_SJwRS>l=gR)grtclF_Ln)GV>@k>J&{H zN;_yB1G7_Yy7cpUJ3MS#=M`lKtS(r0_3Ut~XzhC;@rYH8Z6^l*JZ_@4WkyoB%W}>3 zT3_w6S~NHw=se+}(<5j6?D1{F`cScYn!Gj5&9a})m>5zVwD;_CwkYd< zEM@Xfk2J^I&s*B?S)PqtoR)L_Qq}lP*2lb_I?moTVSKw0tDX7=MX(v8QN6h}$C+2U zFwM3oap@1f6HgVlF4hY1_gvZuOG}%%Rhjm%-TkQ^@$J4YnY+JamG?wpiRk}2}EYWs( z$^w~_=7de^6<-?Z0;*Z=O%I+LZ-lqk5}1hrG1Q7@rdL+o~_A zFJjKPDZ|q;x>$Kyo^1Sl`qiDyBXXY@t%y`Nh^P`fwBHctqfIX7JwDm9XTsrXJ(3?a z@;zIjdY>y+Z{Ea5pX+q)-%*pQ{xxh*$41vCEa~_lz3b|)zcn!Em-S+c#&?c+b(N*f z@g-5+y*I6R60;`r;)DKqliI&JebLLn+^H3Nit4qOEp8p>y!OFdhx)c*esjdrBWvZY zIIC7a9s1|-h*NFGEE+4Mk6Lr=smab6)zgEz1sFa0T>rS(-6GV>GPHy8lvkSy)%(gU zh8ae%wPOrTS>`aqb}~`U4h+Nm-%|s{X*d)EIZGRE*h6M0XTK2}$=S#eV7Z)~AvTt? zMp@V+HWz!$$if~Y3WZ^CX9xiH^}AXk=881u~22 zpyn)&D2Npk1v8r&pcX8ZD1;p&YRR%@f?BZ>qSnlL7N`x&B?@JiiNcsF57d@TA!^5N z6SZf)`JfIgpQs~yNYsf1&jxj7^NGS)DNz>|rUi9n1w`H02cqsQ{0C4EwvH%*iE}_b zSudhqtdOWTQ_cnTVUa|A*-oN<%z~l2-!DOTXEJp609H)G+NBVj=0S*Lsq-N8Tn6Dh z2~qTRI)W(*AWWDKA)1wtu!97z1rUa?+yxL~mqYl2ggB;J2*G*Dw{$yjol`i&V1K^X0Uvs znd~9aEEc>Llvgx=t>OJ)MK5+4x{D0|E-K2YV`M9uN?)Bcz{`@#Om)R@XeH^plGmcF zsE&h?rD${oznfKN;s!(d0obr;;9)~U8CjFY*E1>_Ur+wjPoKErzjTn*taGA?qUiJq zqqzzb^D%PW!?pE%W)mw9X!Yf*ej{-p=3>l!x)(K4nO@4!3mpAkf)IrmDw9rs$Ag=It|hQiN|DG@&`Y%E{E$gpqZeMA=+M}b zxrQ%ygsoglVZm`okqQ?n^oHap3~Eq152wF|j`8peX!wu5!4Xb!EsNz-ifkT^Z&-vd z+8^5(Xw+VOZXzrLXqyO)_M{iyD@edLncYST%@m#jU#ZZiF1l?h*XUj7E&c*$8rSg4 zo^Y3I)4661Z4fkipEd&;?T4>ZgjgQ8g2&Z_)(f49qOcPGAXgQk77FXpxzvffS#CU< zwTH*sLHd#R`(Ccqh8D-QeO#*p?H%@~X76Vul;R*<)M5v~iaHQ_yHoH3C|3 z?F^4=4DA_>Hf(2EF{L=iT`rL3ao2fh)K*tuHP^25v~JJ}xOR(KBp}6IxaicVfVJFp zpNHe)6QMo7JRU)#LF5U{qJfR=F^i-W^hY0cCqAJO^0|xt(xPy0U?SIE@HjQJ7qkPm zm#mOd(4Q6*j$iYHi*V5pf6X;N*yp)M&-fIFPcMW=JjGikCL#qrUy}pj=- zi=}XyUx@UeOL5JCHPGnF`N+bNLPHOkv}Z6d7cq3@(32f$Er5Ak`^r;q;T@ts3q_vc!5u7yGKRFj8yfYctKOS={{x^z1Nzwpb_gU8`VMPUlpJXt=a zXv$sqrBE2khm053xMOUx8vF%*gibGJ=db3HRM_c=8%FEowzF+E@$rQ z42@1;4A6vYUAZ;{nmgBeuvAJB0T*>s9B>wc(-GVY8V!+nzz$tp7u=g`Lt#7eZtug2 zDMeqNJrS-8yr&27@FZw79%viLwPe^$VAlsnGK*BC7=$8qQz{UE3N`@8@bEO)LA=sK zxRwsBKPI_`;8+$(DdM;*6H+vv|Khne44Nme^iZx1hsO8}mB0!qMG{=p7g@ko?n;41 z-8mA-=UO_C8wG7K*D{%yh7==s_H4Lja#t4D#y}g*wUIn6{dScB%>|sz!YKtkpHMAw zfiXNhho=||Esclg@)YBsP378nRzN8xz(w6T0T|CyOyMaeLQCh`RA_XlNkA&srn6E? zk;lWQz%`w_@}W^*Oa+eMM7x52;BnJnALiN|7MzY0^SEmUTqn3|KG$YK3q)GFtt{Z$ zEZ8l%wvgphibdR&57%p?a0f4jMwOloJm=a{XjC&TPzH^*V+5%`-q2b#@VLjIt!ajmS(H(39%cT^X zxN9+_VyY>&&0JdodnYtCcnjB-!rsf{wz3jRQOI2daDAl?!1g0FGM7_iIA*1(>}(-yN-N^yXvSO*tA>!2@B zgo9jL5Bm@%j@IB`xwZlJTAEC-9b(0l;s{(+=}o{o?mEW9H$#i(+HYLj0?i*<82C6| zM6iD8;8W0QfVTnkB?fJOut-XApNIbh$sN_{0e-->pJA)0 zir5}PBeMu-%HtlhLQ3%jF6y0~fEBtt0{on(*abTrvliWBUT|$U#lh|kE@k3yq_^h>vzS6tf*I|eEGfnRfNAM7M(bdPz%!YRdD?ka|q2^Vej)Jna002t20-*fFC zG@4`Rmh^!YP>PS-bqLZughzqPxON!!U9NrN+7W2a`Jq0uQl!v)<*s9J9fK(<2ORrabOE?1RQM zk7G0Affrz3;5Q+2u3dz7k!u!Qy9Dhz*Q#>uGPE09tH!k}&~9<9I@hj3y9zA@Y}uKM z*HGw=uA!SIJ@rucUk6h8&NNI(y8&cDqr0UwG-~lpU=)w5$x5;?Flxa?Bm6cHff;QC zxHeC52X=3+)!`}bLOTsD3+%ugMk0kHTr}1E0lbB46u3T5@h9x}Tx-C!`_POr2xx1_ zQYnQqcRhq;$X$)N_6SoMdj_o`v>dRC zYtLbO;83~5%wiN$c)~>uDFtW%jRmWD_)FMkJgpDcUO~H##?j`>A}NI*TvWj~z+Je; zg9CW@TiExw7Ra@C(9}F_b5=+xg1GAgT>aph1Ps!3b@vXdfs3924#O40wVKfAi@_tHAzZ5k%@7)Gu`HKT#BrA$PjMWs zc&^okb{RG`gZ_-8Gg}9mALd4yOOsd$rAX%C4scb%q(n1d3N-2&M`+V9+0aCk#^dn+ zjN~q7xaa~S z*C?(vf>wi{&e2?J46O>+vRN^u7y}oz(FLw>3~icVa(TEbwD!=RKpV?7H)!5S@dDa7 zW|4ywa$MN->+ed?_5&sSMU~jou3EhDMjd4_u?iE_z2p+Z-n5BE?+p z3V^FMzoZ%00-;?4Xrq@(WH!e?)iAVZh|Oo=lwtvQ1w*2C({NhIwHB~xSkZ7=#I+FE z)Ms>Z7qbFNv4p!?L85MI2wn<}`l2;#b5xp6ZUK*L1KSK5o!sTDlv1qVE_%Ufg%juw zUI~qQwk>QMuFuESfp6bT^%6p!+vyTHgK&Y?EPHZ$hA(;Ene;j=2bctm2POcMfr-FaAQu>j{*MF(0d5$w?!b2FKLdFHeZV~%$Op7Q zKcFw*2lxZcfB+y6Xl{znWP@M?1L`7+>GHaU%qLHt(JBZP4hHDEQ$K({h^22*>5E(X zK92rcqEEx;0CNEbM4?9g!2^H@pncK9Jb4478uWE;7C@hb&j$u0F3hyZajtx>Xfz7K zqM|)aZY7F?@FSaC8~FfIbM(e4@Md5&un8ytwg78@#lTu%Ij|M@5m*Nl0_%Y#zzSdk zuoTz`tOS+;V~UQ=mk$udEzD`5{HoC&NK=c97Ri%k{f@vs4IBkd0SAE+;25wE_zn0K zI0Kvmjss_b-+}$WA>ag144ecG0|$WNz=)!fCGy9j(MZ?}ii(%X`-mnzcjzyk!KHNZ zIEqn4Vaw&|o)+}Ni{1?y0rZOfYtf1G^17nt3+!hAy*vH{(0gTiA^rq-4A8rD`atXf z&>gcc#nJ>#HfbLLdQDBQuj#e+TY%nt)AvF25}oeiRHnD)FM)2j&ekumH?~ zDu5{<2kIaJ{aTle7-N)IqSXqp2~Y`8a=S8^d}iR)7>v|AyReS~%5?xbxHUk3jMEG6 z=BTg{P@Uej(p&M>s2OHb`v2H-8S-~ZF zbBzi`cfbvB1zZ4n``H+vGa3TMGmp>$oP~6e;DJCZpg+(WXafWyWj}CVpbyX+=mqoy z=p}9spgYhFp!dC9fN(j^XlEFmfQ~>1fZ9m!ncD(kKqxQ(pte${bqA-SJQx^{aw7Rq zu>>%^JdFclfoLEd`VcU^t0kM_<6$QO$-odCUr2#56`mb7$-$>aQ8=e|_FArt$1xVpQ#(OG(tjYt&h5O7ql&0mSSZ{2@!(HmlY#G zyuz*$ZDendx0FuQa8!qoDBA6N-;*V=>tO9bQ$=sU}J#c3xBpqJ3NnXo!;3LEd! zJixre09M*%iGOvmkwxU} z{ioK7`K|ku$GfrTZfJD?tLv_KsF4;ZkrqghiPTt6993F7MOwQ-CenaVVaP~}n@Ec* z;M}{rdef@eF8grx%#?#W5VL=!!{R-?uz3ibD zq!nhQ6)tp{J?M&+)}WErz<>v5%vI$sNUPN7R>{y{nGEjo!F}?Lepwa1l1A-vkF?f} zwAMzsM_MIETBW1hBdwz&t>XbtQ~lMyw7KrSmXr6EE!tyTDO^p%;yZ^>c?r_OJJP}* zNQ6=0>V-!&R2cW-9BF9~cyO9rz1b2AV=H{-mebSNM(smG>pPL?DQP($X*m&H4rHMm zwjTfHvBPx-{eF|a(`)~tvAQPxKT50bqVL!Cd!3-GlY_3Q-ygk0KCmh(g@^5y5BiyL z-;gT{Pwkx2MBmRC$f`XbSYMT*Jv*RMR8?4(iS)}~4qI|pZpB`y6k)QKWh~G`;pf^J zE;`#5)uzt4ZvW9%mjROso;SGX)*&Ic!+hU7nb@n0t;RmK($YoFtjdG^Z>?)$9}iqR z7&}>I?30JW+E!ZZsM4?@n)5fmG)9Cs#t=s2>@w!=i3n+#q&w!z+bvvwFdGqk{ySI3 z274-6%08B{^_~iQS^OvVyQjiK_UEU$O%?5BZ9lV)O;K$8g^g{hxJ3;a?4_``la@(Z zJ0ZieN#Tetx=Nv1c-9eQtiVgr&Hzg+vFBcj`5I|yq<~{i7ekijM;VGO(OA4R`L=k{ z$vgf7#-$y)TkesTSK3ihvwc)<+JJJ8v;dP35;wo5S8l^{kF-QnVW2GhL72fZ?!f~@ z4pw7grfO7bpplIAQ7cpiSci$tf+EY57hO{;F34;fV<`o5#KzgR_1<==tOc6n>FPzj z=x@eO`YEivrL~r-toeOaIJ-Vb*Jv*~6Vif9mQ})fN5x+BgQuyhZcf;3R%Gk1m@JcB zuEACZpre1WVow4TFJz(C>_(s>M#ijJ>*k8>vYxfrr{*|S(xOE*e>r_GZ~5s@NTot8 zP>(0In0pY`CiqZ`Eo%Wbwq@2J$^$G*1GnoK9{%3^Dr?c4(q`h#K>sJX%44q9DKcR5s~a!Y7YcB~_O zw$cJm<|9%ISGTIu3OoDg(@(TxTU#U2)t2>d^(~9FY_1(UOl@^ma-0)-ri->VMa144RLBwBkS7$jb6o$6aT2;z}KW)C+_ug@Y z<8;t%MN0Vn2&vLiKl4h;q-N{&>a25!!tftNYuP{twl@SVk`~P}*b-BHxYG|N=HE^R zkC0BI?e|X`KcKC&p4GyX!yW9V3=1sp-?ok{i26@j3G3xA*DpU1a(#c321-N8w6Fu&j8TI46j#+^pR;KS;OCv~966tt<4JjYyl7DDDHk!r~Z!=Hz&vE^n z*cZfk|8t*6m%~4v8&ng+ng)k-N%6}@WK`=I5Hn%@3bNnQl*-w@QNv!!IWOqkmb9{;#mWnUfy1RTMpHtGx7( zU2JO7pwgh|jEv|sm&nYF*rG2@O*9Hk1;x$9twPC#p8V9V8AC<}RZ!h@D-uddOms$6 ztefuNprm9hkLMN@ml7MDmd;DG2wrr0Pq^H8cj2_0J6S?3i zoaK&FIpP26i$$Y0)II+}|+yPdDBYf2B%@DM>NS*NI5=Wp7J2wl6Wn^>e)QCIU1 zt<<*$VS2+wKM-~O85=z!a!4{dA~HSNyJ=8D^su<}sMyS?p>7#*Nm(vYgObyNQlg_W z6XGJ%+~|?WB{FGnT5{are^8^NqRJI_U0_;zWVxhMkU2W>AH@+~?!K&~lhT$ICM#|5 zzi`|hr>N)t*JDx)s;^5zwu|nu-iun0GAtt<1A#dNo7mOhg@kkjf3sa!Nwi5l3)(rJ z9D2jWlLeJ1>ss(9Vcib80mXtnOq?40ZOx@B=-WY+V9ORL>e~OcvS_yy2=jYU=rAb? z=C`PZf6c`^TZ-hxZ_$o_cfJt(?c8D1gilh|tsV5&^NbMv3D;d&9;_r>VPE$<*9OJv zuM!s(6U|MWto4_d{(^#6Zy*zk+@f@>_1CKpetsR|ieToZ%6iu2*S-Dy zv0DN7KNBVdnB<8?kZ`A+ zzG|wwQo6D2p>u6YgWJ43&G((&`?GRid++9jZ0k| z+!bD)E*sHQtD8|dqcpgY@n==3ttiEJE~!c#=rrq;%<(yeN?Th{N1vj$zo-hkU41m%&qcipMipgbb=@2W}wv~Z51G=jRL&0G~twdQ8$b|@^Eo|8X0 zJwHu}{zFxoAl?GX9W+6kLNDDzom4o--~>XaW~XJQW-3a~eP<76=1fh^K!<)sn>!Nm zz4ta0v{wV*>GkBFvdjy=wrT2N?B%}75h9ZjkLrrOcwYP!ukxuh*uJ#0!PpyNJKgd^e zoJyC`=2I#Lao%dzM$Wu&X$M02RE$L3Kxktq9~c#iGpeXKpt0sSw@;uDpEB=2`IIQ~ zcNWTo@&@9dT%bLa3)F)8LR&X+6e_-oI()8|V|^Zmc1@k@mqYmsm<8n{>L+}6Ag^yw ziZ=!B(>nL)xGX(H&v>xrM8kG}r0fexzpd(XhPUeb_D`Ol%x*o=-g=_CXPVg)*Gmu7 zFxV=NSd4?v>Sarw6{W_~sBhJt<`P_nF$mYORpnaLcjz?38-Sew-E0*nEapzQV!LyT zf+AImnn$b8sXdKHLA%<@*GC!e!HZx|4W&`%)t>4$S`9H@{#8}_V?D)Ib=G1IzoIIM z+@h*FTBQE4W!<1!jBdDy2icM*MVXV}ahhhc&b63#p*8SfD*%^xn4=Xm#i9$dy zpP&_q93xo=pi4%a>;r89E2Ff`>vRU`#^tzfQ*Gr(qRjO%1&rfzWNoHK>S%G7-E38p zEappSIl4?$?NFvOW`*9iF}Z*eW>^;QoTiMenA&JZlk6HRxPu*<=$3pV>Wa* zzXKZLsK|qG9j#cjOty+i7PrVds_pBX?#5|%a9nN4%c9)gyra^XD{6Oh2quaV&MmxQ zahrEnrLTdFUjcWuoz00d*TsauW2M=u@+`(Uv?6V1=SP_r!Q&&WlJ$yeF{fZ6NvUbR zf##T+W}`n<#ah#R3eDu2=1DYphn%I{y52|Ms?mvAm}|P*lF^CR;PI%t*{mBZ#@lH1 zu$|3}GWWtP#GOFrDlFz_(Q+Pn4656)2iTWuYIm)nLVmwv+zK!?@xd}sm8L}+(89Tx z6=nXx#cfqN7A-&}zw0=1!&FTfOaa%`DC5g;6P#{!joU+eNEg<)r{KmrbDO(qN*o2; zR=XLT9@jeHbZ`7`u%E$^$??(}_jHZh%=j>2GTeU74eYIP@6@=xOb-+0!R_PB{kX>U z!t5(__(Qk#5xApyC^7#&x@-mK`Q~)2cb&fPP5r|5W~Q&!NGHF$7^Hrfd&6wWd!x)V z;qmEbvRU_9v~4=AL0oHYrZb$k6mxc*EqQ5_cFIhn?}2!_^YA9$!!D=5O>nw9YTP?D zZr|Du6Krtfow?t_jdQxK>O6FFYuv*%uIBMDA-=|4UgMsvaa%sdByZ|<9=M@df_JJOfV0J+lx;>Tg4j|eKT4-(NR@hw-DRsnY#H32+yjR`id>) zP%md3XMClbenZfq>eis7p!HoVh+hbi)R#-uXytzw0ffDZmY<( z7;mA~muDfpdwtC@vs3j}Lx?SLm01UYX*j!DEZuNqU(>Sjxx8)aTA z+(;FDFqNSd;p_$`p60kM@x)`aRiMH|v^ZvPOmolTUN{LY58?^j{1cj8&@|aFHJfMP z2Fe|&MOBF?iWX14&iSz(Zo=JoMO)BHi#ZJ~zRVjs_InRn@o3@1TV^reLyPy@%rm<= z9yjUUXt~?0=*(iYIEKMP>tnR~pk;Kly!u>E4R zMj?h7dS#@J#$ZPxhkD~y&l_|mu0SgREoZmCLyIS|x{e~1HUxylF;oOmgKN+B{;SW{r zxf#kGr~~EYQRNMI3-`7s_10qkJ?mijc=`9N!?O^dasQrm{yppb&&@jXPEXTdZuZmD z40CEx`SXT&+B(bNK{w!OmyFbVo?$qRECSgy59G3ut})SzL59sY#3%g9vz;u4A&VCl zRc*^?GH+()SFahp3|&!$+DNY~0P%ee#N!2!-;Ff- z1&|Mzyw2o?k=z%8%$N<5wGiZ%kzQredJagyB9J>qnzRVy6q8Sw+%uBjVvzZBL7rU< z@~4qLWD@y2NQWgL4~+EG5|E2b&Vr~awOMKyL96BgEM5xWMyCN(1BI4CjI@BoL{%)c zs7o1=w=O_(c^Q(;bcw;p7XT8L0o10|%K-F+0Dm&@pg0@AAqG2b0CnjOgN#K0$;$z} zXxnlC-^BnPD*)=#s1?|lH&wDUAoEH{L$b2?&|wx|@*+qh%4G4Qqb!Z7(JF{P<*@|N zF_tD2v>MWsX0im*ah4!zvj)EnsO$RV=Nj%ZrfKRL0VVE3#jOMFNEIw0bcZFBl3s#@(KeQFQrAN|(I}SA zRLK%S<_d^~tSpgqn57GOZGd#8OqM7*%F>M*ZG?2EJeD4GjHM?9y$tC^Gg+ePI7@G8 zvk4MIB`mRYnxzkgZie)w1uXrjilskw*#e29GL`{!i6x$*w?YQeYL*24BXfj0h~lE#FIGJ}qMv#?dBpqfMMP550qbZYR3>{^8 zk{a!TBvT%QBDY|?zuJZIPNAUP02dj|-3?%+;|x}91?ap7AeBn?0QBDm@B@Q%3atdV z&0s|(z(lHIu=N#yK6?Q&scbL6$n5|(7)++}sZ_~e#%_QHc7W+*wF9)?1Mn_`ndEf{;1qy; z>LJ6|(^ljf4feoLqp#}W`gUMHp7+%u^=HFm+7o8fjU{+ySKD54#86u`yB^R_r06hX zM?8Tr*h5Yjd^Psw)rm0L>qMA-?P^avXI!V(9+_cs%y;8vnAJcZ6O)$cwCZT{%Up9=XCATNoM$~XLT{1+aUq(@diBWwtjaEYM^ z@#_2mv!8@UNIIAN)!9L1q%i*M{+qB-bev0!mJq(aQ1)K2;o2e(n$GK($fHVCp=;Ok;^y(j90@q2}&G0STZROC5rM8;uQXyc_VWy z1(J@hij>6;q9}#J@LhqUG~=nl_)oA(`Pev37@pB7`-M#x<_i`M#@(9%hV>rfFA0<` zlDLGtim_Wu5##k6!(PEq@VIRT<9^~>B4wux_*SVK0Jc}yHnMVwS0vOFp<7s=kI8m0 zK4yWi1u|N@q-+qF4<89Ed#I92?3EJDff`C^pRmWl68X<0Ec>NI3;08Y9VE2`CG3*k z3ZWqqIwY($*eGF#g|z`2F6>oG;u5b*s2xIA_-w=ShOqYV%Y_{g)&Xplus5lKOMHM3 z?{`O7g@is576SINuusAG(1gOa3j2bpxx`meJshE>d@f@-0mh@)3AR|+DPj2LMJW?@ zhN7QCiLWJOLFlTC{#hxDCrHX2Vc!bFqd4Ux82@#4j>@>ic?m@U{VeHK!tgv!xgzX4 zVR#g;oD%juRdI<666%T27MvG+<@_M57yR{djs7Ss8f+!57`}3TqR`p6VlGK22B=&@ zKZEfQ$HLYL3tZwvkuB@~5oZAO2Wj_noYWAM+_) z1wN3(0buXL91He=I};Dv*s?gMgE-VO=CI9juM8uB1MX65SEvLp%`{BB5TAo&nZDShSSQ1p6m;t{F6jlDI^yq)!HF zCL`TP*c7ljm@*%S_66fjWx-C!u*Xpamlz=FISBEPwS>kC%Z2}zOl1SXc+B!(-O;mF z&_t@{5=oL?05lfqJSz?nRtUc?(%V3v5H=Nlx|AJC(eqGZn1rSyMCddx!@>AmnE~4@ zY^1Q6V0(m(rZO&(ETN|nDwj|S81MHpuzkYDgYi}LENqFeRI1_t2Sba%cq|sdK9qCdImufL_KD=prl18V zF-Jl?(Zr%UPZ@KCmBPP@biVOCFRTpybz$=;k4r2-h_`5i-IFaYlJwZ3uDEEwEM6i|vxW6>PJx9m2MO zwZsZB(4CaTC3Z<@JJ43X2(avyvOD0vBCJx_POxpl_E7~&6z!K1yAg^)C=Pl+()Yms z0LC{myW~~EXC4nbBy2Bye*DijsKa3V*>fMP9~dv6Nxl8>J-`y7$DQjwrW`;c1iLjH z`UOQV!fyRj^6fyKB=nUtUpWNUnTHL_2{0a{!!REl4ZaJVrZO&ZM$%v75PW=9r+h8! zAMpA9$;&swUWfk)jv3#D&QcYZ_*OzkfU+^=jfI{Q_9px+VdsS%1)B=SOBIDKMv3nv z^fnM*!+eAKUf4VE=L@?a>|L-6D3J>Nfl5%K=tl{?5A>T<{|StTfRD`)ur%l;$vX!B z7+5;=XJH@0uK=3>y)5h_`0IuJBJ5)@{xCKX%6}~3bSP3jL1PsXGoV)_@l*Kgg#8A_ zo%sybrVc)2K&vJ1bNDB5V0gJE>^OY>STF_pyRa|dN5bdjI+W}E6Bc2@)SV6IrX+p| zzmp{1lEkmT-jW65>JO^z$$cbKZ2Q5sc69 zD%c3!dGFMzl1rGS#P@IMn)2aG44A7D4Zo`!moT8a{0lKvA=EgW}V z>Iu8Z4KTjd))#gOzCrT5DTzxoK!`hd8P*@2=EYaie}Nw_tdX!^!P*P+qY5t3SVF%c z6o%?0P=8@p;fKo>1HgDks$o1<&q0HznoBg3^xuKbVjp>FF6=t|DzG`w$HBO_H(=ij zYemszDA8KdZy{un4z>|?8!R8}d1zZ*``vUd~SU<|+ z5^)l$56pAX9@qe3-e3dd1{E)?0oWvA1L-K2NRW_^l;C^KAYr~>Je+TW4Hni&*n41! zSeUWN$s2Wuqk31IO^7f!5V{ILWvA1+W{;I*AmY! zlc|DBOp)}C2t^>B=ZGv}Az)rezXp~K##;yl`vyKw1O-&hB?=`y9HC3-AkPO=!T9R! z1oj&aDqmXDC9gC5!(hD3py-t-@sxxt2z`nWUn@@wi-dm?jIWhvgmr<>kDmDCf0oL) z#4HK%$1d0RMMc88fo()OpZvwby2I})S9A$gaf#<7)DxllIF0xen=PyteBSRS&^f}Q z;hz;YmqH08o|jOJr1Qa@CoERdnavm02R@G(AMgcK!X*|;s2@;Wbea$DB4PdE2ZHgz zT`Y_rclzKc@zGjB$GOBZ3B?1QhtCJiCTt-5pRq+gTFZqcz(0Z^<|DI$f>xo#N(l`H zI)xA)1rnABpUd)?@RK-*k|d0Gd^Nt}L5VdIdIE?qY~G=@!iK_cf=+YiUlcYBep6xP zbQC29t&`9Qgt!Fv<|Sbx;ornq@UmVQ{}Spg@Ov zf%>mDbu+$#$!WWN!D>^yR>Z#wIRZNhdk^+5?0wiYbLb%jO2LSda@-C=F* z;$4>y<&`w;vi*vGKXV4uVI7bQQzeuiC!{Q~1(oA6Is_-8WwE%!kfKlfTh z9V$#IMT6k>fsMvcjfN({M#6@{hQmg|M!=qc4S|ir=vrapVI490Au#?G)?C;;n3Z#2 zsj%^|G*|~%d)ObirtZP+!1#%pyN6T)fvY{ zpeEQy9WoV!d+~!Se&ENCZF$x_Yd?G0)LbpP2%jH`^J8Cr9L&!RFTd zF?br(h6}D9)C=|pa+ZPfz}|y@7sii(Z^8J2{SDR>>+`o*&m!k4+Am`!u7>^&yKclE zO4p=uLxQ)Vn{lLvA@NnLEpR+wyccz$ZD6fo{P49UtOe|GSaVo27(eR`gf)dV zf$`&G{=2v_%nv`=H`PyS0M{GV0oESIpAp(YXJXs&P=4Cj5!N3T0tj1bUq1mu37#HRCqn&YWI3Jwz z9^L3)+nmF-AI+J8wrg8c_^4nQ96g!}oetv-@KZ@w#~$7IQ{XdU+_4YN;2$iX_owV? zQ_Fd)s!jG))wSLk6yE?7kn63XX6o3G;E3Qb+?_5t-@nWAj##y@iFvUj(upIk7wO(w zUUoRF@M4Z5(m6OZqVpBn(n}9l@6gu}&G<8UM(bgXU2o{QUoLy~%lmb{)zqfslI#q-kg<;*-Z`n-`37L0KK&Ox z<88?BR(o_0adbQ#5!_RXnHDjxVp-+#FmwLH3{qR z!+Slvu^kC~V2V?oTv}`6=PfvaAG+c$OuwN*#2HEOOh}>XA?u>N)wYf>COwI4xSbth z_`ol#CStfoJ^ErSJSeX(_TG;cLK?Tl3y^iOsnrgF9n#z2SUcvFeBN*;n3*T;k|RNHB)U>jv#WRe#qjm$yH4Z@YTX z!4sMq5*!}f2|bFsLFbX7S#QwYL3*qwuX9Q3nuzOt&&nQJ z(#1Hp^%B8lUW_+8Y2y%d3a@nH?9fBq@J=VSctVfYG{v6(gnmMcxQMrUJdv9jFd$;z zse8RKdf~yH`Koihb(zwC{l{~B-foHrMipnaQpZfQhGSe^?`bx8EN*ma#>uY8=oD<> zb!V9Edxz`uHEpFQbsMF}>ND|L3+{@=bai7bKiV)#|6DWHqr%ZR2Zq+8Z$|6;{ax>G z&Rz6clbjo#ske~iaj z64{WR;Y`SD^>@9*8#ljS&W(`u zm_x=!^3@O-E@Q398h<|)Ja`KF`6kztG5Jr&+}GdrO0YH{E9|?v#$;@j&u5$i(l0H> zhn}?7)YM$ParOJZd}RLBw$N`G$G_h*$JOFGRm|!DnvPb2+;;H4;-8-zWd| zSzX?gX7ReNm#x?4zLyZNU|J97apj}sdM&%bK+(}ha_0e z51`yMy_fTInquFRrkAQ+lW_0gyR&w8)5}4(}>aT9qn7Dx^MAy-#Uf=+`z+}DKv74 zdvhvY;vP~vFv|&hWxMmw4E^k1Epd-g-E*^@cxyK2wxs^0?vb^x3dH|q!jVgxO5HF2 EFHHO@0RR91