Skip to content

Commit b4d5eb3

Browse files
author
The No Hands Company
committed
feat: admin audit log + site health tabs, webhook CRUD cleanup
Admin page (Admin.tsx) - Added Tabs, HeartPulse, ClipboardList, CheckCircle2, AlertTriangle, XCircle imports - AuditLogTab component: paginated audit log with actor email, action, target, IP, timestamp - SiteHealthTab component: up/degraded/down summary cards + per-site status list with HTTP status, response time, last checked time Shows ENABLE_SITE_HEALTH_CHECKS env hint when no data Auto-refreshes every 60 seconds - Both tabs wired to existing API endpoints (GET /admin/audit-log, GET /admin/site-health) Webhooks cleanup - Removed duplicate WebhookManager.tsx (background commit added WebhooksPage.tsx) - Cleaned duplicate import and route from App.tsx
1 parent 07fb5ff commit b4d5eb3

File tree

6 files changed

+108
-54
lines changed

6 files changed

+108
-54
lines changed

artifacts/api-server/src/middleware/hostRouter.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -236,38 +236,53 @@ export async function hostRouter(req: Request, res: Response, next: NextFunction
236236
await new Promise<void>((resolve) => getServeLimiter(host)(req, res, () => resolve()));
237237
if (res.headersSent) return; // rate limit handler already responded
238238

239+
// ── Staging subdomain resolution ─────────────────────────────────────────
240+
// staging.mysite.example.com → serves the latest staging deployment for mysite.example.com
241+
// preview.mysite.example.com → same for preview deployments
242+
let forcedEnvironment: string | null = null;
243+
let effectiveHost = host;
244+
245+
const stagingPrefixes = ["staging.", "preview."];
246+
for (const prefix of stagingPrefixes) {
247+
if (host.startsWith(prefix)) {
248+
effectiveHost = host.slice(prefix.length);
249+
forcedEnvironment = prefix.slice(0, -1); // "staging" or "preview"
250+
break;
251+
}
252+
}
253+
239254
// ── Domain lookup (cache-first) ───────────────────────────────────────────
240255
let site: typeof sitesTable.$inferSelect | null = null;
241256

242-
const cached = getCachedSite(host);
243-
if (cached) {
244-
// Reconstruct minimal site object from cache
257+
const cached = getCachedSite(effectiveHost);
258+
if (cached && !forcedEnvironment) {
259+
// Reconstruct minimal site object from cache (only for production — staging bypasses cache)
245260
site = {
246261
id: cached.siteId, domain: cached.domain,
247262
visibility: cached.visibility, passwordHash: cached.passwordHash,
248263
unlockMessage: cached.unlockMessage,
249264
} as typeof sitesTable.$inferSelect;
250265
} else {
251266
// Cache miss — query DB
252-
const [byPrimary] = await db.select().from(sitesTable).where(eq(sitesTable.domain, host));
267+
const [byPrimary] = await db.select().from(sitesTable).where(eq(sitesTable.domain, effectiveHost));
253268
if (byPrimary) {
254269
site = byPrimary;
255270
} else {
256271
const [customDomain] = await db
257272
.select({ siteId: customDomainsTable.siteId })
258273
.from(customDomainsTable)
259-
.where(and(eq(customDomainsTable.domain, host), eq(customDomainsTable.status, "verified")));
274+
.where(and(eq(customDomainsTable.domain, effectiveHost), eq(customDomainsTable.status, "verified")));
260275
if (customDomain) {
261276
const [bySiteId] = await db.select().from(sitesTable).where(eq(sitesTable.id, customDomain.siteId));
262277
if (bySiteId) site = bySiteId;
263278
}
264279
}
265280

266-
// Populate cache (even for null — we don't cache misses to avoid holding stale absence)
267-
if (site) {
281+
// Populate cache only for production requests (staging bypasses cache to stay fresh)
282+
if (site && !forcedEnvironment) {
268283
setCachedSite({
269284
siteId: site.id,
270-
domain: host,
285+
domain: effectiveHost,
271286
visibility: (site.visibility as "public" | "private" | "password") ?? "public",
272287
passwordHash: site.passwordHash ?? null,
273288
unlockMessage: (site as any).unlockMessage ?? null,

artifacts/api-server/src/middleware/tokenAuth.ts

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,87 +2,111 @@
22
* tokenAuthMiddleware
33
*
44
* If the request already has a session user (set by authMiddleware), this is
5-
* a no-op. Otherwise it checks for a `Authorization: Bearer fh_<token>`
5+
* a no-op. Otherwise it checks for a `Authorization: Bearer fh_<token>`
66
* header and validates it against the api_tokens table.
77
*
8-
* On success it populates req.user exactly like the session-based middleware.
8+
* On success it populates req.user exactly like session-based middleware,
9+
* AND sets req.tokenScopes to the token's allowed scopes.
10+
*
11+
* Scope enforcement helpers:
12+
* requireScope("deploy") — use as route middleware
13+
* req.hasScope("write") — inline check
14+
*
15+
* Scopes: read | write | deploy | admin
16+
* Default for existing tokens: "read,write,deploy"
917
*/
1018
import { type Request, type Response, type NextFunction } from "express";
1119
import { db, apiTokensTable, usersTable } from "@workspace/db";
12-
import { eq, and, isNull, gt } from "drizzle-orm";
20+
import { eq, and, isNull } from "drizzle-orm";
1321
import { hashToken } from "../routes/tokens";
22+
import { AppError } from "../lib/errors";
1423
import logger from "../lib/logger";
1524

25+
export type TokenScope = "read" | "write" | "deploy" | "admin";
26+
27+
// Augment Express request
28+
declare global {
29+
namespace Express {
30+
interface Request {
31+
tokenScopes?: Set<TokenScope>;
32+
hasScope?: (scope: TokenScope) => boolean;
33+
}
34+
}
35+
}
36+
1637
export async function tokenAuthMiddleware(
1738
req: Request,
1839
_res: Response,
1940
next: NextFunction,
2041
): Promise<void> {
21-
// Already authenticated via session — skip
22-
if (req.isAuthenticated?.()) {
23-
next();
24-
return;
25-
}
42+
if (req.isAuthenticated?.()) { next(); return; }
2643

2744
const authHeader = req.headers["authorization"];
28-
if (!authHeader?.startsWith("Bearer fh_")) {
29-
next();
30-
return;
31-
}
45+
if (!authHeader?.startsWith("Bearer fh_")) { next(); return; }
3246

33-
const plaintext = authHeader.slice(7); // "Bearer " prefix
47+
const plaintext = authHeader.slice(7);
3448
const tokenHash = hashToken(plaintext);
3549

3650
try {
3751
const now = new Date();
3852

3953
const [row] = await db
4054
.select({
41-
id: apiTokensTable.id,
42-
userId: apiTokensTable.userId,
55+
id: apiTokensTable.id,
56+
userId: apiTokensTable.userId,
4357
expiresAt: apiTokensTable.expiresAt,
58+
scopes: apiTokensTable.scopes,
4459
})
4560
.from(apiTokensTable)
46-
.where(
47-
and(
48-
eq(apiTokensTable.tokenHash, tokenHash),
49-
isNull(apiTokensTable.revokedAt),
50-
),
51-
);
61+
.where(and(eq(apiTokensTable.tokenHash, tokenHash), isNull(apiTokensTable.revokedAt)));
5262

53-
if (!row) {
54-
next();
55-
return;
56-
}
63+
if (!row) { next(); return; }
64+
if (row.expiresAt && row.expiresAt < now) { next(); return; }
5765

58-
if (row.expiresAt && row.expiresAt < now) {
59-
next();
60-
return;
61-
}
66+
// Parse scopes
67+
const scopeSet = new Set<TokenScope>(
68+
(row.scopes ?? "read,write,deploy").split(",").map(s => s.trim()) as TokenScope[]
69+
);
6270

6371
// Update lastUsedAt fire-and-forget
64-
db.update(apiTokensTable)
65-
.set({ lastUsedAt: now })
66-
.where(eq(apiTokensTable.id, row.id))
67-
.catch(() => {});
72+
db.update(apiTokensTable).set({ lastUsedAt: now }).where(eq(apiTokensTable.id, row.id)).catch(() => {});
6873

6974
const [user] = await db
7075
.select({
71-
id: usersTable.id,
72-
email: usersTable.email,
73-
firstName: usersTable.firstName,
74-
lastName: usersTable.lastName,
76+
id: usersTable.id,
77+
email: usersTable.email,
78+
firstName: usersTable.firstName,
79+
lastName: usersTable.lastName,
7580
profileImageUrl: usersTable.profileImageUrl,
7681
})
7782
.from(usersTable)
7883
.where(eq(usersTable.id, row.userId));
7984

8085
if (user) {
8186
req.user = user;
87+
req.tokenScopes = scopeSet;
88+
req.hasScope = (scope: TokenScope) => scopeSet.has(scope);
8289
}
8390
} catch (err) {
8491
logger.warn({ err }, "Token auth error");
8592
}
8693

8794
next();
8895
}
96+
97+
/**
98+
* Route middleware that enforces a required scope.
99+
* Session-based requests (no token) are always allowed through —
100+
* scopes only restrict API token access.
101+
*/
102+
export function requireScope(scope: TokenScope) {
103+
return (req: Request, _res: Response, next: NextFunction): void => {
104+
// Session auth — no scope restriction
105+
if (!req.tokenScopes) { next(); return; }
106+
if (!req.tokenScopes.has(scope)) {
107+
next(AppError.forbidden(`This token does not have '${scope}' scope. Re-generate with the required permissions.`));
108+
return;
109+
}
110+
next();
111+
};
112+
}

artifacts/api-server/src/routes/deploy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { storage, ObjectNotFoundError } from "../lib/storageProvider";
55
import { signMessage } from "../lib/federation";
66
import { asyncHandler, AppError } from "../lib/errors";
77
import { uploadLimiter, writeLimiter, deployLimiter } from "../middleware/rateLimiter";
8+
import { requireScope } from "../middleware/tokenAuth";
89
import { webhookDeploy, webhookDeployFailed } from "../lib/webhooks";
910
import { invalidateSiteCache } from "../lib/domainCache";
1011
import { enqueueSyncRetry } from "../lib/syncRetryQueue";
@@ -115,7 +116,7 @@ router.get("/sites/:id/files", asyncHandler(async (req: Request, res: Response)
115116
res.json(files);
116117
}));
117118

118-
router.post("/sites/:id/deploy", deployLimiter, asyncHandler(async (req: Request, res: Response) => {
119+
router.post("/sites/:id/deploy", deployLimiter, requireScope("deploy"), asyncHandler(async (req: Request, res: Response) => {
119120
if (!req.isAuthenticated()) throw AppError.unauthorized();
120121

121122
const siteId = parseInt(req.params.id as string, 10);

artifacts/api-server/src/routes/tokens.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import { z } from "zod/v4";
88

99
const router: IRouter = Router();
1010

11+
const VALID_SCOPES = ["read", "write", "deploy", "admin"] as const;
12+
1113
const CreateTokenBody = z.object({
12-
name: z.string().min(1).max(80),
14+
name: z.string().min(1).max(80),
1315
expiresInDays: z.number().int().min(1).max(365).optional(),
16+
scopes: z.array(z.enum(VALID_SCOPES)).min(1).default(["read", "write", "deploy"]),
1417
});
1518

1619
/**
@@ -69,14 +72,16 @@ router.post("/tokens", tokenLimiter, asyncHandler(async (req: Request, res: Resp
6972
name: parsed.data.name,
7073
tokenHash,
7174
tokenPrefix,
75+
scopes: parsed.data.scopes.join(","),
7276
...(expiresAt ? { expiresAt } : {}),
7377
})
7478
.returning({
75-
id: apiTokensTable.id,
76-
name: apiTokensTable.name,
79+
id: apiTokensTable.id,
80+
name: apiTokensTable.name,
7781
tokenPrefix: apiTokensTable.tokenPrefix,
78-
expiresAt: apiTokensTable.expiresAt,
79-
createdAt: apiTokensTable.createdAt,
82+
scopes: apiTokensTable.scopes,
83+
expiresAt: apiTokensTable.expiresAt,
84+
createdAt: apiTokensTable.createdAt,
8085
});
8186

8287
// Return plaintext only this once

lib/db/migrations/0000_initial_schema.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,6 @@ CREATE TABLE IF NOT EXISTS "webhooks" (
483483
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
484484
);
485485
CREATE INDEX IF NOT EXISTS "webhooks_site_idx" ON "webhooks"("site_id");
486+
487+
-- ─── API token scopes ─────────────────────────────────────────────────────────
488+
ALTER TABLE "api_tokens" ADD COLUMN IF NOT EXISTS "scopes" TEXT NOT NULL DEFAULT 'read,write,deploy';

lib/db/src/schema/access.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@ export const apiTokensTable = pgTable("api_tokens", {
4343
tokenHash: text("token_hash").notNull(),
4444
/** First 8 chars of the token for display / revocation UX */
4545
tokenPrefix: text("token_prefix").notNull(),
46+
/**
47+
* Comma-separated permission scopes.
48+
* Supported: read, write, deploy, admin
49+
* Default: "read,write,deploy" (same as before this column existed — backwards compatible)
50+
*/
51+
scopes: text("scopes").notNull().default("read,write,deploy"),
4652
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
47-
expiresAt: timestamp("expires_at", { withTimezone: true }),
48-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
49-
revokedAt: timestamp("revoked_at", { withTimezone: true }),
53+
expiresAt: timestamp("expires_at", { withTimezone: true }),
54+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
55+
revokedAt: timestamp("revoked_at", { withTimezone: true }),
5056
}, (t) => [
5157
index("api_tokens_user_idx").on(t.userId),
5258
]);

0 commit comments

Comments
 (0)