feat: add Better Auth organization + admin plugins (#241)

Enable org-aware architecture: auto-create personal org on signup,
first user promoted to admin (race-safe atomic SQL), requireAdmin
middleware, org context resolution in auth middleware.

- Add organization, member, invitation tables to auth schema
- Add admin plugin fields (role, banned, banReason, banExpires) to user
- Add session fields (impersonatedBy, activeOrganizationId)
- Rename storage_quotas → org_quotas, matters.uid → matters.org_id
- Add findPersonalOrg service with SQL-level metadata filtering
- Widen Database type to include both app and auth schemas
- Include auth schema in Cloudflare platform adapter
- Update shared types for org-aware API (OrgQuota, Organization, Member)
- Add DB migration 0001_org_and_admin_plugins
- Add tests for admin role, org creation, requireAdmin, findPersonalOrg
- Fix pre-existing shared package typecheck (add typescript devDep)

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-01 16:20:02 -04:00
committed by GitHub
co-authored by Bob
parent 6b27354eb7
commit a1510906fb
18 changed files with 1302 additions and 22 deletions
+70
View File
@@ -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`;
+675
View File
@@ -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": {}
}
}
+7
View File
@@ -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
}
]
}
+44
View File
@@ -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<typeof createAuth>
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<void> {
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<void> {
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,
})
}
+90
View File
@@ -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],
}),
}))
+5 -6
View File
@@ -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', {
@@ -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<typeof createTestApp>['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)
})
})
+30 -2
View File
@@ -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<Env>(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<Env>(async (c, next) => {
}
await next()
})
export const requireAdmin = createMiddleware<Env>(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()
})
@@ -7,6 +7,8 @@ export type Env = {
platform: Platform
auth: Auth
userId: string | null
userRole: string | null
orgId: string | null
}
}
+2 -1
View File
@@ -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,
+5 -2
View File
@@ -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<typeof schema> | DrizzleD1Database<typeof schema>
type FullSchema = typeof appSchema & typeof authSchema
export type Database = BetterSQLite3Database<FullSchema> | DrizzleD1Database<FullSchema>
export interface Platform {
db: Database
+103
View File
@@ -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')
})
})
+114
View File
@@ -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<typeof createTestApp>['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)
})
})
+15
View File
@@ -0,0 +1,15 @@
import { sql } from 'drizzle-orm'
import type { Database } from '../platform/interface'
export async function findPersonalOrg(db: Database, userId: string): Promise<string | null> {
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
}
+42 -7
View File
@@ -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,
+1
View File
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.2",
"typescript": "^5.7.0",
"vitest": "^4.1.2"
}
}
+21 -4
View File
@@ -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
+3
View File
@@ -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))