Middleware & Authentication
Request middleware pipeline, route-level auth enforcement, role system, and SEO handling
Middleware & Authentication
The Astro middleware at src/middleware.ts intercepts every request before it reaches a page or API route. It enforces authentication on protected routes, resolves user roles, handles hostname validation, injects SEO headers for bots, and provides dev mode shortcuts. The role system defined in src/lib/roles.ts controls what each user can access.
Middleware Pipeline
Every request flows through this pipeline:
Incoming Request
│
▼
1. Hostname Enforcement
│ (production only -- reject requests to unknown hosts)
│
▼
2. Route Classification
│ (public, admin, auth, api, user, dashboard)
│
▼
3. Bot/SEO Handling
│ (noindex/nofollow for admin/api/auth routes)
│
▼
4. Dev Mode Check
│ (if DEV, inject admin user and continue)
│
▼
5. Authentication Resolution
│ (CF Access headers → cookie fallback → unauthenticated)
│
▼
6. Role Resolution
│ (email → KV lookup → UserRecord)
│
▼
7. Permission Check
│ (does this role have access to this route?)
│
▼
8. Continue to Page/API Route
│ (user available via Astro.locals.user)
│
▼
Response
Hostname Enforcement
In production, the middleware validates the Host header against an allowlist to prevent the site from being served on unexpected domains:
const ALLOWED_HOSTS = [
'argobox.com',
'www.argobox.com',
// Preview deployment pattern
/\.argobox\.pages\.dev$/,
];
function isAllowedHost(host: string): boolean {
return ALLOWED_HOSTS.some(allowed =>
typeof allowed === 'string'
? host === allowed
: allowed.test(host)
);
}
Requests from unknown hosts receive a 403 response. The regex pattern allows Cloudflare Pages preview deployments (e.g., abc123.argobox.pages.dev) to pass through.
This check is skipped entirely in dev mode since the host is localhost:4321.
Route Classification
Routes are classified into categories that determine authentication requirements:
function classifyRoute(pathname: string): RouteType {
if (pathname.startsWith('/admin')) return 'admin';
if (pathname.startsWith('/auth')) return 'auth';
if (pathname.startsWith('/user')) return 'user';
if (pathname.startsWith('/dashboard')) return 'dashboard';
if (pathname.startsWith('/api/admin')) return 'admin-api';
if (pathname.startsWith('/api/user')) return 'user-api';
if (pathname.startsWith('/api/auth')) return 'auth-api';
if (pathname.startsWith('/api/dashboard')) return 'dashboard-api';
if (pathname.startsWith('/api/public')) return 'public-api';
if (pathname.startsWith('/api')) return 'api';
return 'public';
}
Route Type Permissions
| Route Type | Auth Required | Minimum Role | Notes |
|---|---|---|---|
public |
No | -- | Homepage, blog, docs, etc. |
public-api |
No | -- | Contact form, public chat |
auth |
No | -- | Login/logout handlers |
auth-api |
Varies | -- | /api/auth/me is public, others are admin |
admin |
Yes | admin |
Full admin panel |
admin-api |
Yes | admin |
Admin API endpoints |
user |
Yes | member |
User portal |
user-api |
Yes | member |
User API endpoints |
dashboard |
Yes | member |
Personalized dashboard |
dashboard-api |
Yes | member |
Dashboard data endpoints |
api |
Varies | -- | General API routes (checked individually) |
Dev Mode
When import.meta.env.DEV is true (local development via npm run dev), the middleware bypasses all authentication checks and injects a full admin user:
if (import.meta.env.DEV) {
context.locals.user = {
email: 'dev@argobox.com',
role: 'admin',
displayName: 'Dev Mode',
services: ['*'],
features: ['*'],
sites: ['*'],
dashboardProfiles: [],
};
return next();
}
This means:
- Every route is accessible without login
- All admin features work
- All API endpoints return data as if called by an admin
- No Cloudflare Access headers are needed or checked
- No KV lookups occur
Dev mode is detected by import.meta.env.DEV which Astro sets based on whether you are running npm run dev (true) or npm run build (false).
Authentication Resolution
In production, the middleware extracts the user identity from Cloudflare Access credentials:
async function resolveAuth(request: Request, env: Env): Promise<AuthResult> {
// 1. Try CF Access JWT header (primary)
const jwtHeader = request.headers.get('Cf-Access-Jwt-Assertion');
if (jwtHeader) {
const identity = await validateAccessJWT(jwtHeader, env);
if (identity) return { authenticated: true, email: identity.email };
}
// 2. Try CF_Authorization cookie (browser fallback)
const cookies = parseCookies(request.headers.get('Cookie'));
const authCookie = cookies.get('CF_Authorization');
if (authCookie) {
const identity = await validateAccessJWT(authCookie, env);
if (identity) return { authenticated: true, email: identity.email };
}
// 3. Try CF_AppSession cookie (application session)
const sessionCookie = cookies.get('CF_AppSession');
if (sessionCookie) {
const session = await validateSession(sessionCookie, env);
if (session) return { authenticated: true, email: session.email };
}
// 4. Not authenticated
return { authenticated: false };
}
The fallback chain ensures authentication works regardless of how the request arrives -- direct API calls with headers, browser navigation with cookies, or application sessions.
Network Lock (DISABLED -- 2026-03-03)
The network-lock system has been removed. It was an IP-based trusted network check that ran after authentication resolution and blocked admin mutations (POST/PUT/PATCH/DELETE to /api/admin/*) from untrusted networks. Clients whose IP did not appear on a trusted network allowlist received a 403 even if they had a valid CF Access JWT.
Why it was removed: Mobile devices on carrier networks were blocked from performing admin actions. Cloudflare sees the carrier's IPv6 address, not the Tailscale IP, so phones on cellular data always failed the network check despite being authenticated.
Current auth model: CF Access JWT validation only. Any request with a valid CF Access JWT and sufficient role is permitted regardless of source network.
Planned replacement: A device-based auth layer (mTLS client certificates or Tailscale identity verification) is planned to replace the network-origin check with a device-identity check that works across all network types.
Role System
Role Definitions (src/lib/roles.ts)
The role system defines three tiers with explicit permission sets:
// src/lib/roles.ts
export const roles = {
admin: {
permissions: ['*'],
description: 'Full access to all features and services',
level: 100,
},
member: {
permissions: [
'view:dashboard',
'use:chat',
'view:status',
'edit:content',
'view:analytics',
'use:playground',
],
description: 'Limited access to specific features',
level: 50,
},
demo: {
permissions: [
'view:dashboard',
'view:status',
],
description: 'Read-only tour of the platform',
level: 10,
},
};
Demo Mirror Interceptor
The demo mirror interceptor runs before the isDemoMode() block and works independently of DEMO_MODE. This allows the demo tour to run on production without disabling the real admin area. A fast-path check avoids the dynamic import on normal (non-demo) requests:
hasDemoSignal? (query param / header / referer check)
→ import demo-api → isDemoMirrorRequest()?
→ admin page gate: valid demo session → allow; no session → 401
→ API interceptor: handleDemoApiRequest(pathname, request)
→ POST generators checked first (email actions, etc.)
→ remaining mutations (POST/PUT/PATCH/DELETE) → 403 "view-only"
→ look up generator in registry (20 full generators, 80+ paths) → return synthetic Response
→ no generator → catch-all returns empty JSON (never falls through to real handler)
The interceptor lives in src/lib/demo-api.ts and returns synthetic data from generators in src/lib/demo-data/generators/. Real API handlers never execute for demo mirror traffic. This allows real admin pages to render with fake data when loaded inside the demo shell at /demo/admin/*.
Demo mirror requests are detected via ?demo_mirror=1 query param, x-demo-mirror: 1 header, or referer URL containing demo_mirror=1.
See Demo Mode Tour Security for the full architecture.
Permission Format
Permissions follow a verb:resource convention:
| Permission | Meaning |
|---|---|
* |
Wildcard -- access to everything |
view:dashboard |
Can view dashboard pages |
view:status |
Can view infrastructure status |
use:chat |
Can use AI chat features |
edit:content |
Can create/edit content |
view:analytics |
Can view traffic analytics |
use:playground |
Can use playground labs |
Permission Checking
export function hasPermission(user: UserRecord, permission: string): boolean {
// Admin wildcard
if (user.role === 'admin') return true;
const roleConfig = roles[user.role];
if (!roleConfig) return false;
// Check exact match or prefix match
return roleConfig.permissions.some(p =>
p === '*' ||
p === permission ||
(p.endsWith('*') && permission.startsWith(p.slice(0, -1)))
);
}
// Usage in API routes
if (!hasPermission(user, 'edit:content')) {
return new Response('Forbidden', { status: 403 });
}
Role Hierarchy
Roles have a numeric level for comparison:
export function hasMinimumRole(user: UserRecord, minimumRole: string): boolean {
const userLevel = roles[user.role]?.level ?? 0;
const requiredLevel = roles[minimumRole]?.level ?? 0;
return userLevel >= requiredLevel;
}
This allows checks like "must be member or higher" without listing every qualifying role.
SEO Handling
The middleware injects SEO-related headers for search engine bots:
function addSEOHeaders(response: Response, routeType: RouteType): Response {
const noIndexRoutes: RouteType[] = ['admin', 'admin-api', 'auth', 'auth-api', 'api', 'user', 'user-api'];
if (noIndexRoutes.includes(routeType)) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}
return response;
}
This ensures search engines never index:
- Admin pages and their API endpoints
- Authentication pages
- User portal pages
- API endpoints
Public pages, blog posts, docs, and other content pages do not receive this header and are freely indexable.
Preview Deployment Access
For Cloudflare Pages preview deployments (non-production hostnames matching *.argobox.pages.dev), the middleware supports a ?preview=true query parameter that preserves the preview hostname and skips ArgoBox's own host-level redirect/block logic:
function isPreviewAccess(url: URL): boolean {
const host = url.hostname;
const isPreview = url.searchParams.get('preview') === 'true';
const isPreviewDomain = host.endsWith('.argobox.pages.dev');
return isPreview && isPreviewDomain;
}
When preview mode is detected, the middleware sets a sticky __argobox_preview=1 cookie so subsequent requests on that pages.dev host keep using the preview deployment instead of being canonicalized back to argobox.com.
This does not inject an admin user and does not bypass edge-level Cloudflare Access. If the preview hostname itself is protected by Cloudflare Access, /admin/* and /auth/* can still be intercepted before the middleware runs. In that case, preview admin testing requires an Access policy that explicitly allows the preview host or a separate review-only domain.
That distinction matters operationally: if a preview admin route 302s to Cloudflare Access or a preview /auth/login flow fails before page code loads, that is not evidence of an ArgoBox middleware bug. It means the request likely never reached the middleware in the first place.
Preview mode is strictly limited to *.argobox.pages.dev hostnames. The production domain argobox.com ignores the ?preview=true parameter entirely.
User Context in Pages
After the middleware runs, the authenticated user (if any) is available to all pages and API routes via Astro.locals.user:
---
// In any .astro page
const user = Astro.locals.user;
if (user) {
// User is authenticated
console.log(user.email); // "argo@argobox.com"
console.log(user.role); // "admin"
console.log(user.displayName); // "Commander"
}
---
{user ? (
<p>Welcome, {user.displayName}</p>
) : (
<a href="/auth/login">Login</a>
)}
In API routes:
export async function GET({ locals }: APIContext) {
const user = locals.user;
if (!user || user.role !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
// Handle admin request
}
The locals.user property is undefined for unauthenticated requests to public routes. For protected routes, if the middleware pipeline completes without returning an error, locals.user is guaranteed to be defined with a valid UserRecord.