feat: add space subdomain routing and ownership support
- Traefik wildcard HostRegexp for <space>.rfunds.online subdomains - Middleware subdomain extraction and path rewriting - Provision endpoint with owner_did acknowledgement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5ff8b8a83f
commit
6c23811516
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal provision endpoint — called by rSpace Registry when activating
|
||||||
|
* this app for a space. No auth required (only reachable from Docker network).
|
||||||
|
*
|
||||||
|
* Payload: { space, description, admin_email, public, owner_did }
|
||||||
|
* The owner_did identifies who registered the space via the registry.
|
||||||
|
*
|
||||||
|
* rfunds has no server-side database — spaces are managed client-side.
|
||||||
|
* This endpoint acknowledges the provisioning request.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json();
|
||||||
|
const space: string = body.space?.trim();
|
||||||
|
if (!space) {
|
||||||
|
return NextResponse.json({ error: "Missing space name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const ownerDid: string = body.owner_did || "";
|
||||||
|
return NextResponse.json({ status: "ok", space, owner_did: ownerDid, message: "rfunds space acknowledged" });
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.rfunds.rule=Host(`rfunds.online`) || Host(`www.rfunds.online`)"
|
- "traefik.http.routers.rfunds.rule=Host(`rfunds.online`) || Host(`www.rfunds.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rfunds.online`)"
|
||||||
- "traefik.http.services.rfunds.loadbalancer.server.port=3000"
|
- "traefik.http.services.rfunds.loadbalancer.server.port=3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,55 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { isDemoRequest } from '@encryptid/sdk/server/nextjs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to protect /space routes.
|
* Middleware to handle subdomain-based space routing and protect /space routes.
|
||||||
*
|
*
|
||||||
* Client-side auth enforcement: the space page itself checks auth state via
|
* Subdomain routing:
|
||||||
* Zustand store. This middleware adds a cookie-based check for server-rendered
|
* - rfunds.online -> home/landing page
|
||||||
* requests — if no encryptid_token cookie is present on /space, redirect to
|
* - www.rfunds.online -> home/landing page
|
||||||
* the home page with a login hint.
|
* - <space>.rfunds.online -> rewrite to /s/<space>
|
||||||
*
|
*
|
||||||
* Note: Since rfunds uses client-side Zustand persistence (localStorage),
|
* Auth protection:
|
||||||
* the primary auth gate is in the SpacePage component itself. This middleware
|
* - /space routes require auth (cookie or Bearer token)
|
||||||
* serves as an additional layer for direct URL access.
|
* - Demo spaces (ENCRYPTID_DEMO_SPACES env var) bypass auth
|
||||||
|
*
|
||||||
|
* Also handles localhost for development.
|
||||||
*/
|
*/
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
const hostname = request.headers.get('host') || ''
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// --- Subdomain routing ---
|
||||||
|
let subdomain: string | null = null
|
||||||
|
|
||||||
|
// Match production: <space>.rfunds.online
|
||||||
|
const match = hostname.match(/^([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])\.\w+\.online/)
|
||||||
|
if (match && match[1] !== 'www') {
|
||||||
|
subdomain = match[1]
|
||||||
|
} else if (hostname.includes('localhost')) {
|
||||||
|
// Development: <space>.localhost:port
|
||||||
|
const parts = hostname.split('.localhost')[0].split('.')
|
||||||
|
if (parts.length > 0 && parts[0] !== 'localhost') {
|
||||||
|
subdomain = parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a subdomain, rewrite root path to space page
|
||||||
|
if (subdomain && subdomain.length > 0 && pathname === '/') {
|
||||||
|
url.pathname = `/s/${subdomain}`
|
||||||
|
return NextResponse.rewrite(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth protection for /space routes ---
|
||||||
// Only protect /space routes (not /tbff which is a public demo)
|
// Only protect /space routes (not /tbff which is a public demo)
|
||||||
if (pathname.startsWith('/space')) {
|
if (pathname.startsWith('/space')) {
|
||||||
|
// Demo spaces get anonymous access — SDK provides synthetic claims
|
||||||
|
if (isDemoRequest(request)) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
// Check for auth token in cookie (set by client after login)
|
// Check for auth token in cookie (set by client after login)
|
||||||
const token = request.cookies.get('encryptid_token')?.value
|
const token = request.cookies.get('encryptid_token')?.value
|
||||||
|
|
||||||
|
|
@ -26,10 +58,6 @@ export function middleware(request: NextRequest) {
|
||||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||||
|
|
||||||
if (!token && !bearerToken) {
|
if (!token && !bearerToken) {
|
||||||
// No auth — redirect to home with login hint
|
|
||||||
// The client-side auth store is the primary gate, but this catches
|
|
||||||
// direct navigation before hydration
|
|
||||||
const url = request.nextUrl.clone()
|
|
||||||
url.pathname = '/'
|
url.pathname = '/'
|
||||||
url.searchParams.set('login', 'required')
|
url.searchParams.set('login', 'required')
|
||||||
url.searchParams.set('return', pathname)
|
url.searchParams.set('return', pathname)
|
||||||
|
|
@ -41,5 +69,7 @@ export function middleware(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/space/:path*'],
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|.*\\..*|api).*)',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue