Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | 4x 2x 2x 1x 1x 4x 8x 8x 3x 5x 8x 8x 8x 5x 4x 1x 4x 3x 3x 1x 2x 3x 3x 3x 2x 1x 1x 4x 4x 4x 99x 99x 97x 120x 120x 99x 99x 839x 4x 120x 120x 1x 119x 119x 1x 118x 120x 120x 120x 118x 2x 2x 2x 2x 116x 116x 1x 115x 85x 30x 30x 30x 4x 6x 6x 1x 5x 5x 1x 4x 6x 6x 6x 4x 1x 1x 1x 1x 3x 3x 1x 2x 2x 2x | // Authentication and Authorization Middleware
import type { MiddlewareHandler } from "hono";
import { UserTenantRolesDal } from "../dal/user-tenant-roles.dal";
import { getDb } from "../db";
import { createDualCache, type DualCache } from "../lib/cache";
import { ForbiddenError, UnauthorizedError } from "../lib/errors";
import { handleError } from "../lib/response";
import {
getCachedUserRoles,
getProRoleFromRoles,
isPlatformAdminFromRoles,
isSuperAdminFromRoles,
} from "../lib/role-cache";
type AuthUser = {
id: string;
name: string;
email: string;
phoneNumber: string | null;
};
type AuthSession = {
id: string;
userId: string;
token: string;
expiresAt: Date;
};
type AuthVariables = {
user: AuthUser | null;
session: AuthSession | null;
db: ReturnType<typeof getDb>;
cache: DualCache;
};
/**
* Require authenticated user
* Use this middleware to protect routes that require authentication
*/
export const requireAuth: MiddlewareHandler<{
Bindings: CloudflareBindings;
Variables: AuthVariables;
}> = async (c, next) => {
const user = c.get("user");
if (!user) {
return handleError(c, new UnauthorizedError("Authentication required"));
}
await next();
};
/**
* Require platform admin role (admin or super_admin)
* Use this middleware to protect admin-only routes
*/
export const requirePlatformAdmin: MiddlewareHandler<{
Bindings: CloudflareBindings;
Variables: AuthVariables;
}> = async (c, next) => {
const user = c.get("user");
if (!user) {
return handleError(c, new UnauthorizedError("Authentication required"));
}
const db = c.get("db") || getDb(c.env.DB);
const rolesDal = new UserTenantRolesDal(db);
const cache = c.get("cache") || createDualCache(c.env.KV_CACHE);
const roles = await getCachedUserRoles(cache, rolesDal, user.id);
if (!isPlatformAdminFromRoles(roles)) {
return handleError(c, new ForbiddenError("Platform admin access required"));
}
await next();
};
/**
* Require super admin role only
*/
export const requireSuperAdmin: MiddlewareHandler<{
Bindings: CloudflareBindings;
Variables: AuthVariables;
}> = async (c, next) => {
const user = c.get("user");
if (!user) {
return handleError(c, new UnauthorizedError("Authentication required"));
}
const db = c.get("db") || getDb(c.env.DB);
const rolesDal = new UserTenantRolesDal(db);
const cache = c.get("cache") || createDualCache(c.env.KV_CACHE);
const roles = await getCachedUserRoles(cache, rolesDal, user.id);
if (!isSuperAdminFromRoles(roles)) {
return handleError(c, new ForbiddenError("Super admin access required"));
}
await next();
};
/* -------------------------------------------------------------------------- */
/* Staff write scope */
/* -------------------------------------------------------------------------- */
/**
* Staff is **write-limited, not read-only**.
*
* `requireProAccess` used to let every pro role write anything behind it, while
* the manual and the Portal's role help both said "staff is read-only". Neither
* was true. The decided scope is two write capabilities and nothing else:
*
* 1. media on an **existing** project — upload, reorder, set cover, delete
* 2. CRM **lead notes**
*
* Staff may not create/publish/archive/delete projects, change a lead's stage,
* owner, quote or order value, invite anyone, or touch team, profile, website,
* blog, studio or pipeline settings.
*
* It is an **allowlist, not a blanket non-GET reject**, because exactly two
* capabilities cross the line: a blanket rule either blocks them or lets
* everything through.
*
* It is deliberately **not** a permission system. No `role_grants` table, no
* module entitlements, no capability registry, no `requireCapability`. Two
* capabilities do not need a table — they need the list below. If a third ever
* lands, add a row.
*
* Patterns are matched against the request path *relative to the `:proId`
* segment*, so each row reads the same as the route definition it guards in
* `routes/pro/` and nothing depends on where the router is mounted.
*/
const STAFF_WRITE_ALLOWLIST: ReadonlyArray<{
methods: readonly string[];
path: RegExp;
}> = [
// 1. Media on an existing project.
// Upload — the multipart endpoint and the presigned pair behind it.
{ methods: ["POST"], path: /^\/rooms\/[^/]+\/upload-media$/ },
// These two rows allow the ROOM-MEDIA upload, not every context the pair
// accepts: `context` is in the body, and confirming `logo` / `cover` /
// `leadership` / `certification` / `testimonial` / `blog-*` writes studio
// branding and company-profile assets whose own paths are denied below.
// `routes/pro/presigned-uploads.routes.ts` pins the context for staff —
// the same body-level guard the notes row needs, for the same reason.
{ methods: ["POST"], path: /^\/uploads\/request$/ },
{ methods: ["POST"], path: /^\/uploads\/[^/]+\/confirm$/ },
// Attach, edit, reorder, set cover, delete.
{ methods: ["POST"], path: /^\/rooms\/[^/]+\/media$/ },
{ methods: ["POST"], path: /^\/rooms\/[^/]+\/media\/bulk$/ },
{ methods: ["POST"], path: /^\/rooms\/[^/]+\/media\/reorder$/ },
{ methods: ["POST"], path: /^\/rooms\/[^/]+\/cover$/ },
{ methods: ["PUT", "DELETE"], path: /^\/media\/[^/]+$/ },
// 2. CRM lead notes.
// This row allows the NOTE, not everything the handler can do: the body
// takes an optional `newStageId` that moves the lead, which is a
// capability denied everywhere else here. `routes/pro/crm/notes.routes.ts`
// refuses that field for staff — a path allowlist cannot see a body, so
// the one route whose body carries a second capability guards it itself.
{ methods: ["POST"], path: /^\/crm\/leads\/[^/]+\/notes$/ },
];
/** Methods that cannot change state, so the allowlist never applies to them. */
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* What staff are told when a write falls outside the allowlist. Names the two
* capabilities so the message is actionable rather than just a refusal.
*/
export const STAFF_WRITE_DENIED_MESSAGE =
"Staff can add and manage project photos and write lead notes. Everything else needs a manager or owner.";
/**
* Strip everything up to and including the `:proId` segment, so a request to
* `/api/pro/pro-1/rooms/9/media` is matched as `/rooms/9/media`.
*/
function proRelativePath(path: string, proId: string): string {
const at = path.indexOf(`/${proId}`);
if (at === -1) return path;
return path.slice(at + proId.length + 1) || "/";
}
/**
* True when a `staff` member is allowed to make this request.
*
* Exported for the matrix test — the allowlist is an access control, so it is
* asserted directly rather than only through the UI that hides the buttons.
*/
export function isStaffAllowedRequest(
method: string,
path: string,
proId: string,
): boolean {
const verb = method.toUpperCase();
if (READ_ONLY_METHODS.has(verb)) return true;
const relative = proRelativePath(path, proId);
return STAFF_WRITE_ALLOWLIST.some(
(entry) => entry.methods.includes(verb) && entry.path.test(relative),
);
}
/**
* Require pro access (user must have a role for the pro)
* This middleware expects proId in route params
*/
export const requireProAccess: MiddlewareHandler<{
Bindings: CloudflareBindings;
Variables: AuthVariables & { proId: string; proRole: string };
}> = async (c, next) => {
const user = c.get("user");
if (!user) {
return handleError(c, new UnauthorizedError("Authentication required"));
}
const proId = c.req.param("proId");
if (!proId) {
return handleError(c, new ForbiddenError("Pro ID required"));
}
const db = c.get("db") || getDb(c.env.DB);
const rolesDal = new UserTenantRolesDal(db);
const cache = c.get("cache") || createDualCache(c.env.KV_CACHE);
// Single cached query replaces 2 parallel DB calls
const roles = await getCachedUserRoles(cache, rolesDal, user.id);
// Platform admins have access to all pros
if (isPlatformAdminFromRoles(roles)) {
c.set("proId", proId);
c.set("proRole", "admin");
await next();
return;
}
// Check if user has a role for this pro
const proRole = getProRoleFromRoles(roles, proId);
if (!proRole) {
return handleError(
c,
new ForbiddenError("You do not have access to this pro"),
);
}
// Staff is write-limited. See STAFF_WRITE_ALLOWLIST above for the scope.
if (
proRole === "staff" &&
!isStaffAllowedRequest(c.req.method, c.req.path, proId)
) {
return handleError(c, new ForbiddenError(STAFF_WRITE_DENIED_MESSAGE));
}
c.set("proId", proId);
c.set("proRole", proRole);
await next();
};
/**
* Require pro owner or manager role
*/
export const requireProManager: MiddlewareHandler<{
Bindings: CloudflareBindings;
Variables: AuthVariables & { proId: string; proRole: string };
}> = async (c, next) => {
const user = c.get("user");
if (!user) {
return handleError(c, new UnauthorizedError("Authentication required"));
}
const proId = c.req.param("proId");
if (!proId) {
return handleError(c, new ForbiddenError("Pro ID required"));
}
const db = c.get("db") || getDb(c.env.DB);
const rolesDal = new UserTenantRolesDal(db);
const cache = c.get("cache") || createDualCache(c.env.KV_CACHE);
// Single cached query replaces 2 parallel DB calls
const roles = await getCachedUserRoles(cache, rolesDal, user.id);
// Platform admins have access
if (isPlatformAdminFromRoles(roles)) {
c.set("proId", proId);
c.set("proRole", "admin");
await next();
return;
}
// Check if user has owner or manager role
const proRole = getProRoleFromRoles(roles, proId);
if (!proRole || (proRole !== "owner" && proRole !== "manager")) {
return handleError(
c,
new ForbiddenError("Pro owner or manager access required"),
);
}
c.set("proId", proId);
c.set("proRole", proRole);
await next();
};
|