diff --git a/migrations/0001_org_and_admin_plugins.sql b/migrations/0001_org_and_admin_plugins.sql new file mode 100644 index 00000000..68c489c7 --- /dev/null +++ b/migrations/0001_org_and_admin_plugins.sql @@ -0,0 +1,70 @@ +-- Admin plugin: add fields to user table +ALTER TABLE `user` ADD `role` text; +--> statement-breakpoint +ALTER TABLE `user` ADD `banned` integer DEFAULT false; +--> statement-breakpoint +ALTER TABLE `user` ADD `ban_reason` text; +--> statement-breakpoint +ALTER TABLE `user` ADD `ban_expires` integer; +--> statement-breakpoint +-- Admin plugin: add impersonation tracking to session +ALTER TABLE `session` ADD `impersonated_by` text; +--> statement-breakpoint +-- Organization plugin: add active org to session +ALTER TABLE `session` ADD `active_organization_id` text; +--> statement-breakpoint +-- Organization plugin: create organization table +CREATE TABLE `organization` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `logo` text, + `metadata` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`); +--> statement-breakpoint +-- Organization plugin: create member table +CREATE TABLE `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` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`); +--> statement-breakpoint +CREATE INDEX `member_userId_idx` ON `member` (`user_id`); +--> statement-breakpoint +-- Organization plugin: create invitation table +CREATE TABLE `invitation` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `email` text NOT NULL, + `role` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `expires_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `inviter_id` text NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`); +--> statement-breakpoint +CREATE INDEX `invitation_email_idx` ON `invitation` (`email`); +--> statement-breakpoint +-- Schema change: rename matters.uid to matters.org_id +ALTER TABLE `matters` RENAME COLUMN `uid` TO `org_id`; +--> statement-breakpoint +-- Schema change: rename storage_quotas to org_quotas with new structure +ALTER TABLE `storage_quotas` RENAME TO `org_quotas`; +--> statement-breakpoint +ALTER TABLE `org_quotas` RENAME COLUMN `uid` TO `org_id`; +--> statement-breakpoint +ALTER TABLE `org_quotas` DROP COLUMN `storage_id`; diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..48caf5b3 --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,675 @@ +{ + "id": "81789e49-005c-41c1-b35d-fdffbae9f767", + "prevId": "adf1fb30-c3d2-4034-8e79-56f9ac4eee97", + "version": "6", + "dialect": "sqlite", + "tables": { + "matters": { + "name": "matters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "dirtype": { + "name": "dirtype", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "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": { + "matters_alias_unique": { + "name": "matters_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_quotas": { + "name": "storage_quotas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quota": { + "name": "quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "access_key": { + "name": "access_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'$UID/$RAW_NAME'" + }, + "custom_host": { + "name": "custom_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_options": { + "name": "system_options", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "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, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "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, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "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": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "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": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index e3f4ff0d..6f15312f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775051640321, "tag": "0000_perfect_cyclops", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1775073310565, + "tag": "0001_org_and_admin_plugins", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index 27b11411..be8b9be0 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -1,5 +1,8 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { admin, organization } from 'better-auth/plugins' +import { sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' import * as authSchema from './db/auth-schema' import type { Database } from './platform/interface' @@ -18,7 +21,48 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust maxAge: 60 * 5, }, }, + plugins: [admin(), organization()], + databaseHooks: { + user: { + create: { + after: async (user) => { + await setupNewUser(db, user) + }, + }, + }, + }, }) } export type Auth = ReturnType + +async function setupNewUser(db: Database, user: { id: string; name: string }) { + await promoteFirstUserToAdmin(db, user.id) + await createPersonalOrg(db, user) +} + +async function promoteFirstUserToAdmin(db: Database, userId: string): Promise { + await db.run(sql`UPDATE user SET role = 'admin' WHERE id = ${userId} AND (SELECT COUNT(*) FROM user) = 1`) +} + +async function createPersonalOrg(db: Database, user: { id: string; name: string }): Promise { + const orgId = nanoid() + const now = new Date() + const orgName = user.name ? `${user.name}'s Space` : 'Personal Space' + + await db.insert(authSchema.organization).values({ + id: orgId, + name: orgName, + slug: `personal-${user.id}`, + metadata: JSON.stringify({ type: 'personal' }), + createdAt: now, + }) + + await db.insert(authSchema.member).values({ + id: nanoid(), + organizationId: orgId, + userId: user.id, + role: 'owner', + createdAt: now, + }) +} diff --git a/packages/server/src/db/auth-schema.ts b/packages/server/src/db/auth-schema.ts index 99e063b5..2cdcdfa8 100644 --- a/packages/server/src/db/auth-schema.ts +++ b/packages/server/src/db/auth-schema.ts @@ -7,6 +7,10 @@ export const user = sqliteTable('user', { email: text('email').notNull().unique(), emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(), image: text('image'), + role: text('role'), + banned: integer('banned', { mode: 'boolean' }).default(false), + banReason: text('ban_reason'), + banExpires: integer('ban_expires', { mode: 'timestamp_ms' }), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), @@ -33,6 +37,8 @@ export const session = sqliteTable( userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + impersonatedBy: text('impersonated_by'), + activeOrganizationId: text('active_organization_id'), }, (table) => [index('session_userId_idx').on(table.userId)], ) @@ -85,9 +91,66 @@ export const verification = sqliteTable( (table) => [index('verification_identifier_idx').on(table.identifier)], ) +export const organization = sqliteTable('organization', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + logo: text('logo'), + metadata: text('metadata'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => /* @__PURE__ */ new Date()), +}) + +export const member = sqliteTable( + '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').notNull().default('member'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [index('member_organizationId_idx').on(table.organizationId), index('member_userId_idx').on(table.userId)], +) + +export const invitation = sqliteTable( + 'invitation', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role').notNull(), + status: text('status').notNull().default('pending'), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + inviterId: text('inviter_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (table) => [ + index('invitation_organizationId_idx').on(table.organizationId), + index('invitation_email_idx').on(table.email), + ], +) + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + members: many(member), })) export const sessionRelations = relations(session, ({ one }) => ({ @@ -103,3 +166,30 @@ export const accountRelations = relations(account, ({ one }) => ({ 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], + }), + inviter: one(user, { + fields: [invitation.inviterId], + references: [user.id], + }), +})) diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 3d5eee18..fcc57006 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -2,7 +2,7 @@ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' export const matters = sqliteTable('matters', { id: text('id').primaryKey(), - uid: text('uid').notNull(), + orgId: text('org_id').notNull(), alias: text('alias').notNull().unique(), name: text('name').notNull(), type: text('type').notNull(), @@ -33,12 +33,11 @@ export const storages = sqliteTable('storages', { updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }) -export const storageQuotas = sqliteTable('storage_quotas', { +export const orgQuotas = sqliteTable('org_quotas', { id: text('id').primaryKey(), - uid: text('uid').notNull(), - storageId: text('storage_id').notNull(), - quota: integer('quota').notNull(), - used: integer('used').default(0), + orgId: text('org_id').notNull(), + quota: integer('quota').notNull().default(0), + used: integer('used').notNull().default(0), }) export const systemOptions = sqliteTable('system_options', { diff --git a/packages/server/src/middleware/auth.test.ts b/packages/server/src/middleware/auth.test.ts new file mode 100644 index 00000000..72f57f53 --- /dev/null +++ b/packages/server/src/middleware/auth.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { authedHeaders, createTestApp } from '../test/setup.js' +import { requireAdmin } from './auth.js' + +function createAdminTestApp() { + const { app, db, auth } = createTestApp() + // Add a test-only route protected by requireAdmin + app.get('/api/admin-only', requireAdmin, (c) => c.json({ ok: true })) + return { app, db, auth } +} + +// Signs up then signs in to get a session cookie that reflects the post-hook role update +async function authedHeadersWithFreshSession( + app: ReturnType['app'], + email: string, + password = 'password123456', + name = 'Test User', +) { + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }), + }) + const signInRes = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const cookies = signInRes.headers.getSetCookie() + return { Cookie: cookies.join('; ') } +} + +describe('requireAdmin middleware', () => { + it('returns 403 when user is not authenticated', async () => { + const { app } = createAdminTestApp() + const res = await app.request('/api/admin-only') + expect(res.status).toBe(401) + }) + + it('returns 403 when authenticated user does not have admin role', async () => { + const { app } = createAdminTestApp() + // First user becomes admin; second does not + await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') + const headers = await authedHeaders(app, 'regular@example.com', 'password123456') + const res = await app.request('/api/admin-only', { headers }) + expect(res.status).toBe(403) + }) + + it('returns Forbidden error body when user lacks admin role', async () => { + const { app } = createAdminTestApp() + await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') + const headers = await authedHeaders(app, 'regular@example.com', 'password123456') + const res = await app.request('/api/admin-only', { headers }) + const body = (await res.json()) as { error: string } + expect(body.error).toBe('Forbidden') + }) + + it('allows request when user has admin role', async () => { + const { app } = createAdminTestApp() + // First signup → role updated to admin by hook; sign in to get fresh session + const headers = await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') + const res = await app.request('/api/admin-only', { headers }) + expect(res.status).toBe(200) + }) + + it('returns expected body when admin accesses protected route', async () => { + const { app } = createAdminTestApp() + const headers = await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') + const res = await app.request('/api/admin-only', { headers }) + const body = (await res.json()) as { ok: boolean } + expect(body.ok).toBe(true) + }) +}) diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 85a174db..b66e7443 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,10 +1,26 @@ import { createMiddleware } from 'hono/factory' +import { findPersonalOrg } from '../services/org' import type { Env } from './platform' +type SessionWithPlugins = { + user: { id: string; role?: string } + session: { activeOrganizationId?: string } +} + export const authMiddleware = createMiddleware(async (c, next) => { const auth = c.get('auth') - const session = await auth.api.getSession({ headers: c.req.raw.headers }) - c.set('userId', session?.user?.id ?? null) + const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null + + c.set('userId', result?.user?.id ?? null) + c.set('userRole', result?.user?.role ?? null) + + if (result?.user?.id) { + const orgId = result.session?.activeOrganizationId ?? (await findPersonalOrg(c.get('platform').db, result.user.id)) + c.set('orgId', orgId) + } else { + c.set('orgId', null) + } + await next() }) @@ -15,3 +31,15 @@ export const requireAuth = createMiddleware(async (c, next) => { } await next() }) + +export const requireAdmin = createMiddleware(async (c, next) => { + const userId = c.get('userId') + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401) + } + const userRole = c.get('userRole') + if (userRole !== 'admin') { + return c.json({ error: 'Forbidden' }, 403) + } + await next() +}) diff --git a/packages/server/src/middleware/platform.ts b/packages/server/src/middleware/platform.ts index 99684146..4a008f4a 100644 --- a/packages/server/src/middleware/platform.ts +++ b/packages/server/src/middleware/platform.ts @@ -7,6 +7,8 @@ export type Env = { platform: Platform auth: Auth userId: string | null + userRole: string | null + orgId: string | null } } diff --git a/packages/server/src/platform/cloudflare.ts b/packages/server/src/platform/cloudflare.ts index e6800376..72f632af 100644 --- a/packages/server/src/platform/cloudflare.ts +++ b/packages/server/src/platform/cloudflare.ts @@ -1,4 +1,5 @@ import { drizzle } from 'drizzle-orm/d1' +import * as authSchema from '../db/auth-schema' import * as schema from '../db/schema' import type { Platform } from './interface' @@ -8,7 +9,7 @@ interface CloudflareEnv { } export function createCloudflarePlatform(env: CloudflareEnv): Platform { - const db = drizzle(env.DB, { schema }) + const db = drizzle(env.DB, { schema: { ...schema, ...authSchema } }) return { db, diff --git a/packages/server/src/platform/interface.ts b/packages/server/src/platform/interface.ts index bb30592b..35b2020c 100644 --- a/packages/server/src/platform/interface.ts +++ b/packages/server/src/platform/interface.ts @@ -1,8 +1,11 @@ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import type { DrizzleD1Database } from 'drizzle-orm/d1' -import type * as schema from '../db/schema' +import type * as authSchema from '../db/auth-schema' +import type * as appSchema from '../db/schema' -export type Database = BetterSQLite3Database | DrizzleD1Database +type FullSchema = typeof appSchema & typeof authSchema + +export type Database = BetterSQLite3Database | DrizzleD1Database export interface Platform { db: Database diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts index 5c9167a8..7a1cec94 100644 --- a/packages/server/src/routes/auth.test.ts +++ b/packages/server/src/routes/auth.test.ts @@ -1,4 +1,6 @@ +import { eq } from 'drizzle-orm' import { describe, expect, it } from 'vitest' +import * as authSchema from '../db/auth-schema.js' import { createTestApp } from '../test/setup.js' describe('Auth API', () => { @@ -46,4 +48,105 @@ describe('Auth API', () => { }) expect(res.status).not.toBe(200) }) + + it('first user gets admin role after signup', async () => { + const { app, db } = createTestApp() + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Admin User', email: 'admin@example.com', password: 'password123456' }), + }) + const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'admin@example.com')) + expect(users[0].role).toBe('admin') + }) + + it('first user gets a personal organization created after signup', async () => { + const { app, db } = createTestApp() + const signUpRes = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Admin User', email: 'admin@example.com', password: 'password123456' }), + }) + const body = (await signUpRes.json()) as { user: { id: string } } + const userId = body.user.id + + const orgs = await db + .select() + .from(authSchema.organization) + .where(eq(authSchema.organization.slug, `personal-${userId}`)) + expect(orgs).toHaveLength(1) + expect(JSON.parse(orgs[0].metadata!)).toEqual({ type: 'personal' }) + }) + + it('second user does NOT get admin role', async () => { + const { app, db } = createTestApp() + // First user + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'First', email: 'first@example.com', password: 'password123456' }), + }) + // Second user + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Second', email: 'second@example.com', password: 'password123456' }), + }) + const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'second@example.com')) + expect(users[0].role).not.toBe('admin') + }) + + it('second user also gets a personal organization created', async () => { + const { app, db } = createTestApp() + // First user + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'First', email: 'first@example.com', password: 'password123456' }), + }) + // Second user + const signUpRes = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Second', email: 'second@example.com', password: 'password123456' }), + }) + const body = (await signUpRes.json()) as { user: { id: string } } + const userId = body.user.id + + const orgs = await db + .select() + .from(authSchema.organization) + .where(eq(authSchema.organization.slug, `personal-${userId}`)) + expect(orgs).toHaveLength(1) + expect(JSON.parse(orgs[0].metadata!)).toEqual({ type: 'personal' }) + }) + + it('second user gets a member record with owner role in their personal org', async () => { + const { app, db } = createTestApp() + // First user + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'First', email: 'first@example.com', password: 'password123456' }), + }) + // Second user + const signUpRes = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Second', email: 'second@example.com', password: 'password123456' }), + }) + const body = (await signUpRes.json()) as { user: { id: string } } + const userId = body.user.id + + const orgs = await db + .select() + .from(authSchema.organization) + .where(eq(authSchema.organization.slug, `personal-${userId}`)) + const orgId = orgs[0].id + + const members = await db.select().from(authSchema.member).where(eq(authSchema.member.organizationId, orgId)) + expect(members).toHaveLength(1) + expect(members[0].userId).toBe(userId) + expect(members[0].role).toBe('owner') + }) }) diff --git a/packages/server/src/services/org.test.ts b/packages/server/src/services/org.test.ts new file mode 100644 index 00000000..4aba0d29 --- /dev/null +++ b/packages/server/src/services/org.test.ts @@ -0,0 +1,114 @@ +import { nanoid } from 'nanoid' +import { describe, expect, it } from 'vitest' +import * as authSchema from '../db/auth-schema.js' +import { createTestApp } from '../test/setup.js' +import { findPersonalOrg } from './org.js' + +type TestDb = ReturnType['db'] + +async function insertUser(db: TestDb, overrides: Partial<{ id: string; name: string; email: string }> = {}) { + const id = overrides.id ?? nanoid() + await db.insert(authSchema.user).values({ + id, + name: overrides.name ?? 'Test User', + email: overrides.email ?? `${id}@example.com`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }) + return id +} + +async function insertOrg(db: TestDb, overrides: Partial<{ id: string; slug: string; metadata: string | null }> = {}) { + const id = overrides.id ?? nanoid() + await db.insert(authSchema.organization).values({ + id, + name: 'Test Org', + slug: overrides.slug ?? nanoid(), + metadata: overrides.metadata !== undefined ? overrides.metadata : JSON.stringify({ type: 'personal' }), + createdAt: new Date(), + }) + return id +} + +async function insertMember(db: TestDb, organizationId: string, userId: string) { + await db.insert(authSchema.member).values({ + id: nanoid(), + organizationId, + userId, + role: 'owner', + createdAt: new Date(), + }) +} + +describe('findPersonalOrg', () => { + it('returns the org id when a personal org exists for the user', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + const orgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'personal' }) }) + await insertMember(db, orgId, userId) + + const result = await findPersonalOrg(db, userId) + expect(result).toBe(orgId) + }) + + it('returns null when user has no memberships', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + + const result = await findPersonalOrg(db, userId) + expect(result).toBeNull() + }) + + it('returns null when user only belongs to a non-personal org', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + const orgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'team' }) }) + await insertMember(db, orgId, userId) + + const result = await findPersonalOrg(db, userId) + expect(result).toBeNull() + }) + + it('returns null when org metadata is null', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + const orgId = await insertOrg(db, { metadata: null }) + await insertMember(db, orgId, userId) + + const result = await findPersonalOrg(db, userId) + expect(result).toBeNull() + }) + + it('returns null when org metadata is invalid JSON', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + const orgId = await insertOrg(db, { metadata: 'not-valid-json' }) + await insertMember(db, orgId, userId) + + const result = await findPersonalOrg(db, userId) + expect(result).toBeNull() + }) + + it('returns null when org metadata is valid JSON but missing type field', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + const orgId = await insertOrg(db, { metadata: JSON.stringify({ other: 'value' }) }) + await insertMember(db, orgId, userId) + + const result = await findPersonalOrg(db, userId) + expect(result).toBeNull() + }) + + it('finds the personal org among multiple memberships', async () => { + const { db } = createTestApp() + const userId = await insertUser(db) + const teamOrgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'team' }) }) + const personalOrgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'personal' }) }) + await insertMember(db, teamOrgId, userId) + await insertMember(db, personalOrgId, userId) + + const result = await findPersonalOrg(db, userId) + expect(result).toBe(personalOrgId) + }) +}) diff --git a/packages/server/src/services/org.ts b/packages/server/src/services/org.ts new file mode 100644 index 00000000..502e2579 --- /dev/null +++ b/packages/server/src/services/org.ts @@ -0,0 +1,15 @@ +import { sql } from 'drizzle-orm' +import type { Database } from '../platform/interface' + +export async function findPersonalOrg(db: Database, userId: string): Promise { + const rows = await db.all<{ org_id: string }>(sql` + SELECT m.organization_id AS org_id + FROM member m + INNER JOIN organization o ON o.id = m.organization_id + WHERE m.user_id = ${userId} + AND o.metadata LIKE '%"type":"personal"%' + LIMIT 1 + `) + + return rows[0]?.org_id ?? null +} diff --git a/packages/server/src/test/setup.ts b/packages/server/src/test/setup.ts index c54dd176..6262cc37 100644 --- a/packages/server/src/test/setup.ts +++ b/packages/server/src/test/setup.ts @@ -13,6 +13,10 @@ const AUTH_SCHEMA_SQL = ` email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL DEFAULT 0, image TEXT, + role TEXT, + banned INTEGER DEFAULT 0, + ban_reason TEXT, + ban_expires INTEGER, created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) ); @@ -24,7 +28,9 @@ const AUTH_SCHEMA_SQL = ` updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), ip_address TEXT, user_agent TEXT, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + impersonated_by TEXT, + active_organization_id TEXT ); CREATE INDEX IF NOT EXISTS session_userId_idx ON session(user_id); CREATE TABLE IF NOT EXISTS account ( @@ -52,12 +58,42 @@ const AUTH_SCHEMA_SQL = ` updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) ); CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification(identifier); + CREATE TABLE IF NOT EXISTS organization ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + logo TEXT, + metadata TEXT, + created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + updated_at INTEGER DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) + ); + CREATE TABLE IF NOT EXISTS member ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) + ); + CREATE INDEX IF NOT EXISTS member_organizationId_idx ON member(organization_id); + CREATE INDEX IF NOT EXISTS member_userId_idx ON member(user_id); + CREATE TABLE IF NOT EXISTS invitation ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + expires_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + inviter_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS invitation_organizationId_idx ON invitation(organization_id); + CREATE INDEX IF NOT EXISTS invitation_email_idx ON invitation(email); ` const APP_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS matters ( id TEXT PRIMARY KEY, - uid TEXT NOT NULL, + org_id TEXT NOT NULL, alias TEXT NOT NULL UNIQUE, name TEXT NOT NULL, type TEXT NOT NULL, @@ -86,12 +122,11 @@ const APP_SCHEMA_SQL = ` created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS storage_quotas ( + CREATE TABLE IF NOT EXISTS org_quotas ( id TEXT PRIMARY KEY, - uid TEXT NOT NULL, - storage_id TEXT NOT NULL, - quota INTEGER NOT NULL, - used INTEGER DEFAULT 0 + org_id TEXT NOT NULL, + quota INTEGER NOT NULL DEFAULT 0, + used INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS system_options ( key TEXT PRIMARY KEY, diff --git a/packages/shared/package.json b/packages/shared/package.json index c6ada8cd..df740963 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@vitest/coverage-v8": "^4.1.2", + "typescript": "^5.7.0", "vitest": "^4.1.2" } } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index f0202176..fa6ecf0b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -2,7 +2,7 @@ import type { DirType, ObjectStatus, StorageMode } from '../constants' export interface StorageObject { id: string - uid: string + orgId: string alias: string name: string type: string @@ -33,14 +33,31 @@ export interface Storage { updatedAt: string } -export interface StorageQuota { +export interface OrgQuota { id: string - uid: string - storageId: string + orgId: string quota: number used: number } +export interface Organization { + id: string + name: string + slug: string + logo: string | null + metadata: string | null + createdAt: string + updatedAt: string | null +} + +export interface Member { + id: string + organizationId: string + userId: string + role: string + createdAt: string +} + export interface SystemOption { key: string value: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2afaff63..064452d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.2 version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) + typescript: + specifier: ^5.7.0 + version: 5.9.3 vitest: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))