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>
This commit is contained in:
376
.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
Normal file
376
.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
Normal file
@@ -0,0 +1,376 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<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>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task id="36-01-T1">
|
||||
<type>execute</type>
|
||||
<title>Add isAdmin column to users table in schema.ts</title>
|
||||
<files>
|
||||
src/db/schema.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- src/db/schema.ts — read the full users table definition to see the exact structure before modifying
|
||||
</read_first>
|
||||
<action>
|
||||
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`).
|
||||
</action>
|
||||
<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>
|
||||
</task>
|
||||
|
||||
<task id="36-01-T2">
|
||||
<type>execute</type>
|
||||
<title>[BLOCKING] Generate and apply Drizzle migration for isAdmin column</title>
|
||||
<files>
|
||||
drizzle-pg/
|
||||
</files>
|
||||
<read_first>
|
||||
- drizzle.config.ts — verify the out directory is drizzle-pg/ and dialect is postgresql
|
||||
- drizzle-pg/ — list existing migration files to understand numbering
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<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>
|
||||
</task>
|
||||
|
||||
<task id="36-01-T3">
|
||||
<type>execute</type>
|
||||
<title>Add requireAdmin middleware to auth.ts</title>
|
||||
<files>
|
||||
src/server/middleware/auth.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<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>
|
||||
</task>
|
||||
|
||||
<task id="36-01-T4">
|
||||
<type>execute</type>
|
||||
<title>Add isAdmin to /api/auth/me response</title>
|
||||
<files>
|
||||
src/server/routes/auth.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<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>
|
||||
</task>
|
||||
|
||||
<task id="36-01-T5">
|
||||
<type>execute</type>
|
||||
<title>Create /api/admin placeholder route</title>
|
||||
<files>
|
||||
src/server/routes/admin.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
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<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 };
|
||||
```
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/routes/admin.ts exists and exports `adminRoutes`
|
||||
- The file applies `requireAuth` and `requireAdmin` as middleware on `/*`
|
||||
- `GET /` returns `{ ok: true }`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task id="36-01-T6">
|
||||
<type>execute</type>
|
||||
<title>Register adminRoutes in server index.ts</title>
|
||||
<files>
|
||||
src/server/index.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- src/server/index.ts — read the route registration section to find where to insert the new import and route
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/index.ts imports `adminRoutes` from "./routes/admin.ts"
|
||||
- src/server/index.ts registers `app.route("/api/admin", adminRoutes)`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task id="36-01-T7">
|
||||
<type>execute</type>
|
||||
<title>Create scripts/grant-admin.ts for admin status management</title>
|
||||
<files>
|
||||
scripts/grant-admin.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/grant-admin.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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}`);
|
||||
```
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user