- 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>
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 |
|
true |
|
<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
booleanimport fromdrizzle-orm/pg-coreis present (already there — verify it's not removed) </acceptance_criteria>
-
Generate migration:
bunx drizzle-kit generateThis creates a new SQL file in
drizzle-pg/with:ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false; -
Apply migration:
bun run db:pushOR
bunx drizzle-kit pushifbun run db:pushisn'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>
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
requireAdminfunction - 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.isAdminis falsy - The function calls
next()ifuser.isAdminis true eqis imported fromdrizzle-ormusersis imported from../../db/schema.ts</acceptance_criteria>
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 ?? falsein the returned user object - No other changes to the /me handler logic </acceptance_criteria>
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 };
-
Add import (alphabetically with other route imports):
import { adminRoutes } from "./routes/admin.ts"; -
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
adminRoutesfrom "./routes/admin.ts" - src/server/index.ts registers
app.route("/api/admin", adminRoutes)</acceptance_criteria>
/**
* 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}`);
<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>