--- phase: 36 plan: 01 title: "isAdmin schema, requireAdmin middleware, /api/auth/me surface, grant script" type: execute wave: 1 depends_on: [] files_modified: - src/db/schema.ts - src/server/middleware/auth.ts - src/server/routes/auth.ts - src/server/routes/admin.ts - src/server/index.ts - scripts/grant-admin.ts - drizzle-pg/ (generated migration) autonomous: true requirements: - ROLE-01 - ROLE-02 - ADMN-01 --- Add `isAdmin` boolean to the `users` table, create `requireAdmin` middleware, surface `isAdmin` in `/api/auth/me`, create a placeholder `/api/admin/` route, and provide a `scripts/grant-admin.ts` for granting admin status. This is the server-side foundation for Phase 36. **[BLOCKING] Schema Push Required** This plan modifies `src/db/schema.ts` (Drizzle ORM). After all schema file changes are complete and BEFORE verification, run: - Generate migration: `bunx drizzle-kit generate` - Apply migration: `bun run db:push` If the database is not running, flag for manual intervention (`autonomous: false` for that task). This task is mandatory — the phase CANNOT pass verification without it. **Threat:** An unauthenticated or non-admin user calls `/api/admin/*` endpoints directly. **Mitigation:** `requireAuth` + `requireAdmin` middleware chain returns 401/403 before handler executes. Both middleware layers are applied to all `/api/admin/*` routes. **Threat:** `isAdmin` defaults to `true` for new users. **Mitigation:** Column is `NOT NULL DEFAULT false` — new users are never admins by default. **Threat:** Direct SQL grant bypasses application validation. **Mitigation:** The grant script is a developer-only tool; no public endpoint exposes admin promotion. The only mutation path is authenticated developer access to the database. execute Add isAdmin column to users table in schema.ts src/db/schema.ts - src/db/schema.ts — read the full users table definition to see the exact structure before modifying In `src/db/schema.ts`, add `isAdmin: boolean("is_admin").notNull().default(false)` to the `users` pgTable definition. The updated users table should look like: ```typescript export const users = pgTable("users", { id: serial("id").primaryKey(), logtoSub: text("logto_sub").notNull().unique(), displayName: text("display_name"), avatarUrl: text("avatar_url"), bio: text("bio"), isAdmin: boolean("is_admin").notNull().default(false), createdAt: timestamp("created_at").defaultNow().notNull(), }); ``` The `boolean` import is already present in the file (used by `manufacturers.active`). - src/db/schema.ts contains `isAdmin: boolean("is_admin").notNull().default(false)` in the users pgTable - The `boolean` import from `drizzle-orm/pg-core` is present (already there — verify it's not removed) execute [BLOCKING] Generate and apply Drizzle migration for isAdmin column drizzle-pg/ - drizzle.config.ts — verify the out directory is drizzle-pg/ and dialect is postgresql - drizzle-pg/ — list existing migration files to understand numbering Run the following commands in sequence: 1. Generate migration: ```bash bunx drizzle-kit generate ``` This creates a new SQL file in `drizzle-pg/` with: ```sql ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false; ``` 2. Apply migration: ```bash bun run db:push ``` OR `bunx drizzle-kit push` if `bun run db:push` isn't available. If the database is not reachable, mark as requiring manual intervention and continue with remaining tasks that don't need the live DB. - A new SQL file exists in drizzle-pg/ containing `ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false` - `bun run db:push` (or equivalent) exits with code 0 execute Add requireAdmin middleware to auth.ts src/server/middleware/auth.ts - src/server/middleware/auth.ts — read the full file to understand the existing requireAuth pattern, imports, and Context type - src/db/schema.ts — verify the users table export name and isAdmin field name after task T1 Add a `requireAdmin` middleware function to `src/server/middleware/auth.ts`. Add the following imports at the top (if not already present): ```typescript import { eq } from "drizzle-orm"; import { users } from "../../db/schema.ts"; ``` Add the `requireAdmin` function after `requireAuth`: ```typescript export async function requireAdmin(c: Context, next: Next) { const db = c.get("db"); const userId = c.get("userId"); if (!userId) { return c.json({ error: "Authentication required" }, 401); } const [user] = await db .select({ isAdmin: users.isAdmin }) .from(users) .where(eq(users.id, userId)); if (!user?.isAdmin) { return c.json({ error: "Forbidden" }, 403); } return next(); } ``` `requireAdmin` is designed to be called AFTER `requireAuth` has already set `c.get("userId")`. It reads `userId` from context, queries the users table, and returns 403 if the user is not an admin. - src/server/middleware/auth.ts exports `requireAdmin` function - The function signature is `async function requireAdmin(c: Context, next: Next)` - The function returns 401 if userId is not set on context - The function returns 403 if `user.isAdmin` is falsy - The function calls `next()` if `user.isAdmin` is true - `eq` is imported from `drizzle-orm` - `users` is imported from `../../db/schema.ts` execute Add isAdmin to /api/auth/me response src/server/routes/auth.ts - src/server/routes/auth.ts — read the full /me handler to understand what fullUser contains and what is returned - src/db/schema.ts — verify that users.isAdmin is now a valid field In `src/server/routes/auth.ts`, update the `app.get("/me", ...)` handler to include `isAdmin` in the returned user object. Current return: ```typescript return c.json({ user: { id: user.id, email: auth.email, createdAt: fullUser?.createdAt?.toISOString() ?? null, }, authenticated: true, }); ``` Updated return: ```typescript return c.json({ user: { id: user.id, email: auth.email, createdAt: fullUser?.createdAt?.toISOString() ?? null, isAdmin: fullUser?.isAdmin ?? false, }, authenticated: true, }); ``` The `fullUser` variable already queries the full row from `users` table (`db.select().from(users).where(eq(users.id, user.id))`), so `fullUser.isAdmin` is available after the schema change. - src/server/routes/auth.ts /me handler includes `isAdmin: fullUser?.isAdmin ?? false` in the returned user object - No other changes to the /me handler logic execute Create /api/admin placeholder route src/server/routes/admin.ts - src/server/routes/tags.ts — use as the minimal route template (same Hono app pattern) - src/server/middleware/auth.ts — verify requireAuth and requireAdmin exports are available Create `src/server/routes/admin.ts`: ```typescript import { Hono } from "hono"; import { requireAdmin, requireAuth } from "../middleware/auth.ts"; type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); // All /api/admin/* routes require authentication + admin role app.use("/*", requireAuth, requireAdmin); // Health check / ping for admin access verification app.get("/", async (c) => { return c.json({ ok: true }); }); export { app as adminRoutes }; ``` - src/server/routes/admin.ts exists and exports `adminRoutes` - The file applies `requireAuth` and `requireAdmin` as middleware on `/*` - `GET /` returns `{ ok: true }` execute Register adminRoutes in server index.ts src/server/index.ts - src/server/index.ts — read the route registration section to find where to insert the new import and route In `src/server/index.ts`: 1. Add import (alphabetically with other route imports): ```typescript import { adminRoutes } from "./routes/admin.ts"; ``` 2. Register the route after the existing route registrations (look for the block where `app.route("/api/...")` calls are grouped): ```typescript app.route("/api/admin", adminRoutes); ``` The db injection middleware `app.use("/api/*", ...)` already covers `/api/admin/*`, so no additional db setup is needed. - src/server/index.ts imports `adminRoutes` from "./routes/admin.ts" - src/server/index.ts registers `app.route("/api/admin", adminRoutes)` execute Create scripts/grant-admin.ts for admin status management scripts/grant-admin.ts - src/db/index.ts — read how the db instance is exported to use the correct import path - src/db/schema.ts — verify the users table and isAdmin/logtoSub field names Create `scripts/grant-admin.ts`: ```typescript /** * Grant or revoke admin status for a GearBox user. * * Usage: * bun scripts/grant-admin.ts # grant admin * bun scripts/grant-admin.ts --revoke # revoke admin */ import { eq } from "drizzle-orm"; import { db } from "../src/db/index.ts"; import { users } from "../src/db/schema.ts"; const sub = process.argv[2]; const revoke = process.argv.includes("--revoke"); if (!sub) { console.error("Usage: bun scripts/grant-admin.ts [--revoke]"); process.exit(1); } const [user] = await db .update(users) .set({ isAdmin: !revoke }) .where(eq(users.logtoSub, sub)) .returning({ id: users.id, logtoSub: users.logtoSub, isAdmin: users.isAdmin }); if (!user) { console.error(`User not found with logto_sub: ${sub}`); process.exit(1); } const action = revoke ? "Revoked admin from" : "Granted admin to"; console.log(`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`); ``` - scripts/grant-admin.ts exists - The script accepts a logto-sub argument as `process.argv[2]` - The script accepts an optional `--revoke` flag - The script updates `users.isAdmin` to `true` (grant) or `false` (revoke) - The script exits with code 1 if no sub is provided - The script exits with code 1 if the user is not found 1. `bun run build` exits 0 — TypeScript compiles without errors 2. `src/db/schema.ts` contains `isAdmin: boolean("is_admin").notNull().default(false)` in the users table 3. `drizzle-pg/` contains a new migration file with `ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false` 4. `src/server/middleware/auth.ts` exports `requireAdmin` 5. `src/server/routes/auth.ts` `/me` response includes `isAdmin` field 6. `src/server/routes/admin.ts` exists and exports `adminRoutes` 7. `src/server/index.ts` registers `app.route("/api/admin", adminRoutes)` 8. `scripts/grant-admin.ts` exists - isAdmin boolean column exists in users table schema - requireAdmin middleware exported from auth.ts middleware file - isAdmin returned in /api/auth/me response - /api/admin route exists and is protected by requireAuth + requireAdmin - grant-admin script exists and handles grant + revoke - [ ] users table schema has isAdmin column with NOT NULL DEFAULT false - [ ] Drizzle migration generated and applied successfully - [ ] requireAdmin middleware returns 403 for non-admin users - [ ] /api/auth/me includes isAdmin in user object - [ ] GET /api/admin/ returns 403 for non-admin, 200 for admin - [ ] scripts/grant-admin.ts can set isAdmin=true for a user by logto_sub - [ ] bun run build exits 0