diff --git a/src/env.d.ts b/src/env.d.ts
index e16c13c..2b7470a 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -1 +1,11 @@
///
+
+import type { SessionUser } from './lib/auth/session-manager.js';
+
+declare global {
+ namespace App {
+ interface Locals {
+ user?: SessionUser;
+ }
+ }
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..6306719
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,65 @@
+import { defineMiddleware } from 'astro:middleware';
+import { getSessionManager } from './lib/auth/session-manager.js';
+
+// Public routes — must be reachable WITHOUT a valid session so the auth
+// flow itself can happen. Everything else requires authentication.
+// The dashboard at `/` is intentionally NOT public: it renders real
+// machine + session data. Add explicit landing/marketing paths here if
+// one is added later.
+const PUBLIC_API_PREFIX = '/api/auth/';
+const PUBLIC_PAGE_PATHS = new Set([
+ '/login',
+ '/login/',
+]);
+const PUBLIC_ASSET_PREFIXES = ['/assets/', '/_astro/'];
+const PUBLIC_ASSET_FILES = new Set([
+ '/favicon.svg',
+ '/favicon.ico',
+ '/robots.txt',
+]);
+
+function isPublicPath(pathname: string): boolean {
+ if (pathname.startsWith(PUBLIC_API_PREFIX)) return true;
+ if (PUBLIC_PAGE_PATHS.has(pathname)) return true;
+ if (PUBLIC_ASSET_FILES.has(pathname)) return true;
+ for (const prefix of PUBLIC_ASSET_PREFIXES) {
+ if (pathname.startsWith(prefix)) return true;
+ }
+ return false;
+}
+
+export const onRequest = defineMiddleware(async (context, next) => {
+ const { pathname } = context.url;
+
+ if (isPublicPath(pathname)) {
+ return next();
+ }
+
+ const sessionMgr = getSessionManager();
+ const result = await sessionMgr.validateSession(context);
+
+ if (result.valid && result.user) {
+ context.locals.user = result.user;
+ return next();
+ }
+
+ // Unauthenticated: JSON for /api/*, redirect for pages.
+ if (pathname.startsWith('/api/')) {
+ return new Response(
+ JSON.stringify({
+ authenticated: false,
+ error: 'authentication_required',
+ reason: result.reason ?? 'missing_cookie',
+ login_url: '/api/auth/login',
+ }),
+ {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ );
+ }
+
+ const loginUrl = new URL('/api/auth/login', context.url);
+ loginUrl.searchParams.set('return_to', pathname + context.url.search);
+ return context.redirect(loginUrl.pathname + loginUrl.search, 302);
+});
diff --git a/src/pages/acls.astro b/src/pages/acls.astro
index 9ae10f1..a2340ff 100644
--- a/src/pages/acls.astro
+++ b/src/pages/acls.astro
@@ -10,6 +10,9 @@ import ACLEditor from '../components/acls/ACLEditor';
import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
import Layout from '../layouts/Layout.astro';
+// SSR: gated by role (network_admin), must run auth middleware every request.
+export const prerender = false;
+
// In a real deployment this would come from Headscale's /v1/policy endpoint.
// For now, ship a sensible example that demonstrates every modeled key.
const samplePolicy = {
diff --git a/src/pages/api/acls.ts b/src/pages/api/acls.ts
index 45407dc..daef450 100644
--- a/src/pages/api/acls.ts
+++ b/src/pages/api/acls.ts
@@ -1,6 +1,9 @@
// Heady ACL API - Alpine.js/Astro ACL Management Endpoint 🤠
import type { APIRoute } from 'astro';
+// Force SSR — auth middleware must run on every request.
+export const prerender = false;
+
// Mock ACL data storage (in production, this would interface with Headscale API)
let mockAclPolicy = `{
// ACL Policy for Heady Network
diff --git a/src/pages/api/dns/magic.ts b/src/pages/api/dns/magic.ts
index eb0e8b6..5325369 100644
--- a/src/pages/api/dns/magic.ts
+++ b/src/pages/api/dns/magic.ts
@@ -1,6 +1,9 @@
// Heady DNS Magic API - Alpine.js/Astro Magic DNS Toggle 🤠
import type { APIRoute } from 'astro';
+// Force SSR — auth middleware must run on every request.
+export const prerender = false;
+
export const POST: APIRoute = async ({ request }) => {
try {
const { enabled } = await request.json();
diff --git a/src/pages/api/dns/tailnet.ts b/src/pages/api/dns/tailnet.ts
index 38247a4..05058ec 100644
--- a/src/pages/api/dns/tailnet.ts
+++ b/src/pages/api/dns/tailnet.ts
@@ -1,6 +1,9 @@
// Heady DNS Tailnet API - Alpine.js/Astro Tailnet Name Management 🤠
import type { APIRoute } from 'astro';
+// Force SSR — auth middleware must run on every request.
+export const prerender = false;
+
export const POST: APIRoute = async ({ request }) => {
try {
const { baseDomain } = await request.json();
diff --git a/src/pages/api/settings/auth-keys.ts b/src/pages/api/settings/auth-keys.ts
index f752a49..a0c53dd 100644
--- a/src/pages/api/settings/auth-keys.ts
+++ b/src/pages/api/settings/auth-keys.ts
@@ -1,6 +1,9 @@
// Heady Settings Auth Keys API - Alpine.js/Astro Auth Key Management 🤠
import type { APIRoute } from 'astro';
+// Force SSR — auth middleware must run on every request.
+export const prerender = false;
+
// Mock auth keys storage (in production, this would interface with Headscale API)
const mockAuthKeys = [
{
diff --git a/src/pages/api/users.ts b/src/pages/api/users.ts
index 93bd2ca..a208799 100644
--- a/src/pages/api/users.ts
+++ b/src/pages/api/users.ts
@@ -1,6 +1,9 @@
// Heady Users API - Alpine.js/Astro User Management Endpoint 🤠
import type { APIRoute } from 'astro';
+// Force SSR — auth middleware must run on every request.
+export const prerender = false;
+
// Mock users storage (in production, this would interface with Headscale API and OIDC)
const mockUsers = [
{
diff --git a/src/pages/dns.astro b/src/pages/dns.astro
index 6122f18..5fa82d2 100644
--- a/src/pages/dns.astro
+++ b/src/pages/dns.astro
@@ -4,6 +4,9 @@
import { DnsPage } from '@/components/dns/DnsPage';
import Layout from '@/layouts/Layout.astro';
+// SSR: gated by role, must run auth middleware every request.
+export const prerender = false;
+
// Sample DNS config until /api/dns is wired to Headscale.
const initial = {
prefixes: ['100.64.0.0/10', 'fd7a:115c:a1e0::/48'],
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 400172c..b2c6bfc 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -8,6 +8,9 @@ import { getCollection } from 'astro:content';
import { DashboardPage } from '@/components/dashboard/Dashboard';
import Layout from '@/layouts/Layout.astro';
+// SSR: renders per-user dashboard, must run auth middleware on every request.
+export const prerender = false;
+
const allMachines = await getCollection('machines');
const allSessions = await getCollection(
'sessions',
diff --git a/src/pages/machines.astro b/src/pages/machines.astro
index 106d97b..ab7b186 100644
--- a/src/pages/machines.astro
+++ b/src/pages/machines.astro
@@ -5,6 +5,9 @@ import { getCollection } from 'astro:content';
import Layout from '@/layouts/Layout.astro';
import { MachinesPage } from '@/components/machines/MachinesPage';
+// SSR: renders per-user machine list, must run auth middleware on every request.
+export const prerender = false;
+
const allMachines = await getCollection('machines');
const machines = allMachines.map((m) => ({
id: m.data.id,
diff --git a/src/pages/users.astro b/src/pages/users.astro
index c32bad3..de743f1 100644
--- a/src/pages/users.astro
+++ b/src/pages/users.astro
@@ -5,6 +5,9 @@ import { getCollection } from 'astro:content';
import Layout from '@/layouts/Layout.astro';
import { UsersPage } from '@/components/users/UsersPage';
+// SSR: renders per-user list with role visibility, must run auth middleware.
+export const prerender = false;
+
const allUsers = await getCollection('users');
const users = allUsers.map((u) => ({
id: u.data.id,