Files
GearBox/.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
Jean-Luc Makiola 94e2a8c019 plan(36): admin role & panel foundation — 2 plans ready
- 36-RESEARCH.md: schema migration, requireAdmin middleware, /api/auth/me
  surface, client routing patterns, grant script, wave breakdown
- 36-UI-SPEC.md: admin shell layout, sidebar disabled nav items, UserMenu
  admin link, palette and responsive notes
- 36-01-PLAN.md (wave 1): isAdmin schema column + Drizzle migration,
  requireAdmin middleware, /api/auth/me isAdmin field, /api/admin placeholder
  route, scripts/grant-admin.ts
- 36-02-PLAN.md (wave 2): AuthState isAdmin type, /admin client route with
  sidebar shell, admin/index.tsx placeholder, UserMenu admin link
- STATE.md: updated to Phase 36, ready to execute, 2 plans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:43:12 +02:00

12 KiB

phase, plan, title, type, wave, depends_on, files_modified, autonomous, requirements
phase plan title type wave depends_on files_modified autonomous requirements
36 01 isAdmin schema, requireAdmin middleware, /api/auth/me surface, grant script execute 1
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)
true
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.

<schema_push_requirement> [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. </schema_push_requirement>

<threat_model> 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. </threat_model>

execute 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:

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). <acceptance_criteria>

  • 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) </acceptance_criteria>
execute 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:

    bunx drizzle-kit generate
    

    This creates a new SQL file in drizzle-pg/ with:

    ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;
    
  2. Apply migration:

    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. <acceptance_criteria>

  • 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 </acceptance_criteria>
execute 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):

import { eq } from "drizzle-orm";
import { users } from "../../db/schema.ts";

Add the requireAdmin function after requireAuth:

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. <acceptance_criteria>

  • 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 </acceptance_criteria>
execute 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:

return c.json({
  user: {
    id: user.id,
    email: auth.email,
    createdAt: fullUser?.createdAt?.toISOString() ?? null,
  },
  authenticated: true,
});

Updated return:

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. <acceptance_criteria>

  • 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 </acceptance_criteria>
execute 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`:
import { Hono } from "hono";
import { requireAdmin, requireAuth } from "../middleware/auth.ts";

type Env = { Variables: { db?: any; userId?: number } };

const app = new Hono<Env>();

// 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 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):

    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):

    app.route("/api/admin", adminRoutes);
    

The db injection middleware app.use("/api/*", ...) already covers /api/admin/*, so no additional db setup is needed. <acceptance_criteria>

  • src/server/index.ts imports adminRoutes from "./routes/admin.ts"
  • src/server/index.ts registers app.route("/api/admin", adminRoutes) </acceptance_criteria>
execute 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`:
/**
 * Grant or revoke admin status for a GearBox user.
 * 
 * Usage:
 *   bun scripts/grant-admin.ts <logto-sub>           # grant admin
 *   bun scripts/grant-admin.ts <logto-sub> --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 <logto-sub> [--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

<must_haves>

  • 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 </must_haves>

<success_criteria>

  • 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 </success_criteria>