Skip to content

Commit c6ccc29

Browse files
Merge pull request #747 from shepherd-001/feat/add_pagination_metadata_envelope
feat: add pagination metadata envelope on /api/admin
2 parents c3158da + 28c368b commit c6ccc29

6 files changed

Lines changed: 387 additions & 0 deletions

File tree

docs/admin-api.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
`GET /api/admin` returns the admin endpoint catalog for authenticated operators.
2+
3+
Authentication:
4+
- Requires a bearer token with `role: "admin"`.
5+
6+
Query parameters:
7+
- `cursor` optional opaque pagination cursor from the previous response.
8+
- `limit` optional positive integer page size.
9+
10+
Success response:
11+
12+
```json
13+
{
14+
"items": [
15+
{
16+
"id": "GET /api/admin/audit",
17+
"method": "GET",
18+
"path": "/api/admin/audit",
19+
"summary": "List audit log entries"
20+
}
21+
],
22+
"next_cursor": null,
23+
"total": 1
24+
}
25+
```
26+
27+
Notes:
28+
- The success envelope is always `{ items, next_cursor, total }`.
29+
- `next_cursor` is `null` on the last page.
30+
- `total` is the full count of admin catalog entries matching the request.
31+
32+
Validation and errors:
33+
- Invalid query parameters return the standard error envelope.
34+
- Unknown query parameters are rejected.

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { dependenciesRouter } from "./routes/health/dependencies";
1919
import { versionRouter } from "./routes/health/version";
2020
import { redisConnection } from "./queue";
2121
import { authRouter } from "./routes/auth";
22+
import { adminRouter } from "./routes/admin";
2223
import { recommendationsRouter } from "./routes/recommendations";
2324
import { recommendationsHealthRouter } from "./routes/recommendations/health";
2425
import { tagsRouter } from "./routes/tags";

src/openapi/registry.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2741,6 +2741,60 @@ const AdminUserView = z
27412741
})
27422742
.openapi("AdminUserView");
27432743

2744+
const AdminRouteItem = z
2745+
.object({
2746+
id: z.string(),
2747+
method: z.enum(["DELETE", "GET", "PATCH", "POST"]),
2748+
path: z.string(),
2749+
summary: z.string(),
2750+
})
2751+
.openapi("AdminRouteItem");
2752+
2753+
const AdminRouteListResponse = z
2754+
.object({
2755+
items: z.array(AdminRouteItem),
2756+
next_cursor: z.string().nullable(),
2757+
total: z.number().int(),
2758+
})
2759+
.openapi("AdminRouteListResponse");
2760+
2761+
registry.registerPath({
2762+
method: "get",
2763+
path: "/api/admin",
2764+
operationId: "listAdminEndpoints",
2765+
tags: ["Admin"],
2766+
summary: "List available admin endpoints",
2767+
security: [{ bearerAuth: [] }],
2768+
request: {
2769+
query: z.object({
2770+
cursor: z.string().min(1).optional(),
2771+
limit: z.coerce.number().int().positive().optional(),
2772+
}),
2773+
},
2774+
responses: {
2775+
200: {
2776+
description: "Paginated admin endpoint catalog",
2777+
content: {
2778+
"application/json": {
2779+
schema: AdminRouteListResponse,
2780+
},
2781+
},
2782+
},
2783+
403: {
2784+
description: "Forbidden",
2785+
content: { "application/json": { schema: ErrorBody } },
2786+
},
2787+
422: {
2788+
description: "Validation error",
2789+
content: { "application/json": { schema: ValidationErrorBody } },
2790+
},
2791+
429: {
2792+
description: "Rate limit exceeded",
2793+
content: { "application/json": { schema: ErrorBody } },
2794+
},
2795+
},
2796+
});
2797+
27442798
registry.registerPath({
27452799
method: "get",
27462800
path: "/api/admin/users/{address}",

src/routes/admin.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Router } from "express";
2+
import { rateLimit } from "express-rate-limit";
3+
import { z } from "zod";
4+
import { logger } from "../config/logger";
5+
import { RouteErrorFactory } from "../errors";
6+
import { getRequestId } from "../lib/requestContext";
7+
import { getCorrelationId } from "../middleware/correlation";
8+
import { requireAdmin } from "../middleware/requireAdmin";
9+
import { paginate } from "../utils/cursor";
10+
11+
export interface AdminRouteItem {
12+
id: string;
13+
method: "DELETE" | "GET" | "PATCH" | "POST";
14+
path: string;
15+
summary: string;
16+
}
17+
18+
export interface AdminRouterOptions {
19+
items?: readonly AdminRouteItem[];
20+
rateLimitPerMinute?: number;
21+
}
22+
23+
const adminQuerySchema = z.object({
24+
cursor: z.string().min(1, "cursor must not be empty when provided").optional(),
25+
limit: z
26+
.string()
27+
.regex(/^\d+$/, { message: "limit must be a positive integer" })
28+
.optional(),
29+
}).strict();
30+
31+
export const defaultAdminRouteItems: readonly AdminRouteItem[] = [
32+
{ id: "GET /api/admin", method: "GET", path: "/api/admin", summary: "List admin endpoints" },
33+
{ id: "GET /api/admin/audit", method: "GET", path: "/api/admin/audit", summary: "List audit log entries" },
34+
{ id: "GET /api/admin/audit/export", method: "GET", path: "/api/admin/audit/export", summary: "Export audit logs" },
35+
{ id: "GET /api/admin/feature-flags", method: "GET", path: "/api/admin/feature-flags", summary: "List feature flags" },
36+
{ id: "POST /api/admin/feature-flags", method: "POST", path: "/api/admin/feature-flags", summary: "Create a feature flag" },
37+
{ id: "GET /api/admin/health/detail", method: "GET", path: "/api/admin/health/detail", summary: "Read runtime health details" },
38+
{ id: "POST /api/admin/markets/disable", method: "POST", path: "/api/admin/markets/disable", summary: "Disable a market" },
39+
{ id: "POST /api/admin/markets/{id}/feature", method: "POST", path: "/api/admin/markets/{id}/feature", summary: "Feature a market" },
40+
{ id: "DELETE /api/admin/markets/{id}/feature", method: "DELETE", path: "/api/admin/markets/{id}/feature", summary: "Remove a featured market" },
41+
{ id: "POST /api/admin/markets/{id}/force-finalize", method: "POST", path: "/api/admin/markets/{id}/force-finalize", summary: "Force finalize a market" },
42+
{ id: "GET /api/admin/plugins", method: "GET", path: "/api/admin/plugins", summary: "List plugins" },
43+
{ id: "POST /api/admin/plugins", method: "POST", path: "/api/admin/plugins", summary: "Create a plugin" },
44+
{ id: "GET /api/admin/rate-limit/inspect/{address}", method: "GET", path: "/api/admin/rate-limit/inspect/{address}", summary: "Inspect rate limit usage" },
45+
{ id: "POST /api/admin/reindex", method: "POST", path: "/api/admin/reindex", summary: "Trigger a reindex run" },
46+
{ id: "GET /api/admin/recon/markets/{id}", method: "GET", path: "/api/admin/recon/markets/{id}", summary: "Read reconciliation details" },
47+
{ id: "GET /api/admin/schema-versions", method: "GET", path: "/api/admin/schema-versions", summary: "List schema versions" },
48+
{ id: "GET /api/admin/schema-versions/latest", method: "GET", path: "/api/admin/schema-versions/latest", summary: "Read the latest schema version" },
49+
{ id: "GET /api/admin/users/{address}", method: "GET", path: "/api/admin/users/{address}", summary: "Read an admin user view" },
50+
{ id: "GET /api/admin/users/{address}/freeze", method: "GET", path: "/api/admin/users/{address}/freeze", summary: "Read freeze status" },
51+
{ id: "POST /api/admin/users/{address}/freeze", method: "POST", path: "/api/admin/users/{address}/freeze", summary: "Freeze a user" },
52+
{ id: "POST /api/admin/users/{address}/impersonate", method: "POST", path: "/api/admin/users/{address}/impersonate", summary: "Create an impersonation token" },
53+
{ id: "GET /api/admin/users/{address}/notes", method: "GET", path: "/api/admin/users/{address}/notes", summary: "List admin notes for a user" },
54+
{ id: "POST /api/admin/users/{address}/notes", method: "POST", path: "/api/admin/users/{address}/notes", summary: "Create an admin note for a user" },
55+
{ id: "GET /api/admin/webhooks/dlq", method: "GET", path: "/api/admin/webhooks/dlq", summary: "List dead-lettered webhooks" },
56+
{ id: "POST /api/admin/webhooks/dlq/{id}/replay", method: "POST", path: "/api/admin/webhooks/dlq/{id}/replay", summary: "Replay a dead-lettered webhook" },
57+
];
58+
59+
function sortItemsDesc(items: readonly AdminRouteItem[]): AdminRouteItem[] {
60+
return [...items].sort((left, right) => right.id.localeCompare(left.id));
61+
}
62+
63+
export function createAdminRouter(opts: AdminRouterOptions = {}): Router {
64+
const router = Router();
65+
const limit = opts.rateLimitPerMinute ?? 60;
66+
const items = sortItemsDesc(opts.items ?? defaultAdminRouteItems);
67+
68+
router.use(
69+
rateLimit({
70+
windowMs: 60_000,
71+
limit,
72+
keyGenerator: (req) =>
73+
(req.headers.authorization as string | undefined) ?? req.ip ?? "unknown",
74+
standardHeaders: "draft-6",
75+
legacyHeaders: false,
76+
message: { error: { code: "rate_limit_exceeded" } },
77+
}),
78+
);
79+
80+
router.use(requireAdmin);
81+
82+
router.get("/", (req, res, next) => {
83+
try {
84+
const parsed = adminQuerySchema.safeParse(req.query);
85+
if (!parsed.success) {
86+
throw RouteErrorFactory.validation(
87+
parsed.error.issues[0]?.message ?? "invalid query parameters",
88+
);
89+
}
90+
91+
const requestId = getRequestId();
92+
const correlationId = getCorrelationId() ?? res.locals.correlationId;
93+
94+
logger.info(
95+
{
96+
event: "admin_index_requested",
97+
requestId,
98+
correlationId,
99+
adminAddress: req.adminAddress,
100+
cursor: parsed.data.cursor ?? null,
101+
limit: parsed.data.limit ?? null,
102+
},
103+
"admin_index_requested",
104+
);
105+
106+
const page = paginate(
107+
items,
108+
(item) => ({ sortValue: item.id, id: item.id }),
109+
parsed.data.cursor,
110+
parsed.data.limit,
111+
);
112+
113+
logger.info(
114+
{
115+
event: "admin_index_returned",
116+
requestId,
117+
correlationId,
118+
adminAddress: req.adminAddress,
119+
count: page.data.length,
120+
nextCursor: page.nextCursor,
121+
total: items.length,
122+
},
123+
"admin_index_returned",
124+
);
125+
126+
res.json({
127+
items: page.data,
128+
next_cursor: page.nextCursor,
129+
total: items.length,
130+
});
131+
} catch (error) {
132+
next(error);
133+
}
134+
});
135+
136+
return router;
137+
}
138+
139+
export const adminRouter = createAdminRouter();

tests/adminRoot.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import express from "express";
2+
import jwt from "jsonwebtoken";
3+
import request from "supertest";
4+
import { logger } from "../src/config/logger";
5+
import { errorHandler } from "../src/middleware/errorHandler";
6+
import { correlationMiddleware } from "../src/middleware/correlation";
7+
import {
8+
createAdminRouter,
9+
defaultAdminRouteItems,
10+
type AdminRouteItem,
11+
} from "../src/routes/admin";
12+
13+
const SECRET = process.env.JWT_SECRET || "test-jwt-secret-at-least-32-bytes-long-000000";
14+
const ISSUER = process.env.JWT_ISSUER || "predictify";
15+
const AUDIENCE = process.env.JWT_AUDIENCE || "predictify-app";
16+
17+
const ADMIN_ADDRESS = "GADMIN7777777777777777777777777777777777777777777777777777";
18+
const USER_ADDRESS = "GUSER88888888888888888888888888888888888888888888888888888";
19+
20+
function signJwt(payload: object): string {
21+
return jwt.sign(payload, SECRET, { issuer: ISSUER, audience: AUDIENCE, expiresIn: "1h" });
22+
}
23+
24+
const adminJwt = signJwt({ sub: ADMIN_ADDRESS, role: "admin" });
25+
const userJwt = signJwt({ sub: USER_ADDRESS, role: "user" });
26+
27+
function makeApp(items?: readonly AdminRouteItem[]): express.Express {
28+
const app = express();
29+
app.use(express.json());
30+
app.use(correlationMiddleware);
31+
app.use("/api/admin", createAdminRouter({ items, rateLimitPerMinute: 60 }));
32+
app.use(errorHandler);
33+
return app;
34+
}
35+
36+
describe("GET /api/admin", () => {
37+
beforeEach(() => {
38+
jest.restoreAllMocks();
39+
});
40+
41+
it("returns 403 with no Authorization header", async () => {
42+
const res = await request(makeApp()).get("/api/admin");
43+
44+
expect(res.status).toBe(403);
45+
expect(res.body).toEqual({ error: { code: "forbidden" } });
46+
});
47+
48+
it("returns 403 with a non-admin JWT", async () => {
49+
const res = await request(makeApp())
50+
.get("/api/admin")
51+
.set("Authorization", `Bearer ${userJwt}`);
52+
53+
expect(res.status).toBe(403);
54+
expect(res.body).toEqual({ error: { code: "forbidden" } });
55+
});
56+
57+
it("returns the paginated admin envelope", async () => {
58+
const res = await request(makeApp())
59+
.get("/api/admin")
60+
.set("Authorization", `Bearer ${adminJwt}`);
61+
62+
expect(res.status).toBe(200);
63+
expect(Array.isArray(res.body.items)).toBe(true);
64+
expect(res.body).toHaveProperty("next_cursor");
65+
expect(res.body.total).toBe(defaultAdminRouteItems.length);
66+
expect(res.body.items[0]).toEqual(
67+
expect.objectContaining({
68+
id: expect.any(String),
69+
method: expect.any(String),
70+
path: expect.any(String),
71+
summary: expect.any(String),
72+
}),
73+
);
74+
});
75+
76+
it("returns 422 for an invalid limit", async () => {
77+
const res = await request(makeApp())
78+
.get("/api/admin?limit=abc")
79+
.set("Authorization", `Bearer ${adminJwt}`)
80+
.set("x-correlation-id", "corr-invalid-limit");
81+
82+
expect(res.status).toBe(422);
83+
expect(res.body.error.code).toBe("validation_error");
84+
expect(res.body.error.message).toContain("limit must be a positive integer");
85+
expect(res.body.error.correlationId).toBe("corr-invalid-limit");
86+
});
87+
88+
it("returns 422 for unknown query parameters", async () => {
89+
const res = await request(makeApp())
90+
.get("/api/admin?unexpected=true")
91+
.set("Authorization", `Bearer ${adminJwt}`);
92+
93+
expect(res.status).toBe(422);
94+
expect(res.body.error.code).toBe("validation_error");
95+
});
96+
97+
it("paginates with next_cursor and total", async () => {
98+
const items: readonly AdminRouteItem[] = [
99+
{ id: "GET /api/admin/a", method: "GET", path: "/api/admin/a", summary: "A" },
100+
{ id: "GET /api/admin/b", method: "GET", path: "/api/admin/b", summary: "B" },
101+
{ id: "GET /api/admin/c", method: "GET", path: "/api/admin/c", summary: "C" },
102+
];
103+
104+
const first = await request(makeApp(items))
105+
.get("/api/admin?limit=2")
106+
.set("Authorization", `Bearer ${adminJwt}`);
107+
108+
expect(first.status).toBe(200);
109+
expect(first.body.items.map((item: AdminRouteItem) => item.id)).toEqual([
110+
"GET /api/admin/c",
111+
"GET /api/admin/b",
112+
]);
113+
expect(first.body.next_cursor).toEqual(expect.any(String));
114+
expect(first.body.total).toBe(3);
115+
116+
const second = await request(makeApp(items))
117+
.get(`/api/admin?limit=2&cursor=${encodeURIComponent(first.body.next_cursor)}`)
118+
.set("Authorization", `Bearer ${adminJwt}`);
119+
120+
expect(second.status).toBe(200);
121+
expect(second.body.items.map((item: AdminRouteItem) => item.id)).toEqual([
122+
"GET /api/admin/a",
123+
]);
124+
expect(second.body.next_cursor).toBeNull();
125+
expect(second.body.total).toBe(3);
126+
});
127+
128+
it("logs correlation-aware structured events", async () => {
129+
const infoSpy = jest.spyOn(logger, "info").mockImplementation();
130+
131+
const res = await request(makeApp())
132+
.get("/api/admin")
133+
.set("Authorization", `Bearer ${adminJwt}`)
134+
.set("x-correlation-id", "corr-admin-root");
135+
136+
expect(res.status).toBe(200);
137+
expect(infoSpy).toHaveBeenCalledWith(
138+
expect.objectContaining({
139+
event: "admin_index_requested",
140+
correlationId: "corr-admin-root",
141+
adminAddress: ADMIN_ADDRESS,
142+
}),
143+
"admin_index_requested",
144+
);
145+
expect(infoSpy).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
event: "admin_index_returned",
148+
correlationId: "corr-admin-root",
149+
adminAddress: ADMIN_ADDRESS,
150+
total: defaultAdminRouteItems.length,
151+
}),
152+
"admin_index_returned",
153+
);
154+
});
155+
});

0 commit comments

Comments
 (0)