From cf00a77da23e8138ca0eb1dff37784e488d09ef0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 13:19:29 -0800 Subject: [PATCH] feat: add space subdomain routing and ownership support - Traefik wildcard HostRegexp for .r*.online subdomains - Middleware subdomain extraction and path rewriting - Provision endpoint with owner_did acknowledgement - Registry enforces space ownership via EncryptID JWT Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 2 +- src/app/api/internal/provision/route.ts | 6 +++- src/middleware.ts | 45 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/middleware.ts diff --git a/docker-compose.yml b/docker-compose.yml index 2f0a2d1..fc25edc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: - /tmp labels: - "traefik.enable=true" - - "traefik.http.routers.rcal.rule=Host(`rcal.jeffemmett.com`) || Host(`rcal.online`) || Host(`www.rcal.online`) || Host(`booking.xhiva.art`)" + - "traefik.http.routers.rcal.rule=Host(`rcal.jeffemmett.com`) || Host(`rcal.online`) || Host(`www.rcal.online`) || Host(`booking.xhiva.art`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rcal.online`)" - "traefik.http.routers.rcal.entrypoints=web" - "traefik.http.services.rcal.loadbalancer.server.port=3000" networks: diff --git a/src/app/api/internal/provision/route.ts b/src/app/api/internal/provision/route.ts index 5f2901a..358cfa3 100644 --- a/src/app/api/internal/provision/route.ts +++ b/src/app/api/internal/provision/route.ts @@ -4,6 +4,9 @@ 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. + * * rcal will associate calendars with space slugs in future. * Acknowledges provisioning for now. */ @@ -13,5 +16,6 @@ export async function POST(request: Request) { if (!space) { return NextResponse.json({ error: "Missing space name" }, { status: 400 }); } - return NextResponse.json({ status: "ok", space, message: "rcal space acknowledged" }); + const ownerDid: string = body.owner_did || ""; + return NextResponse.json({ status: "ok", space, owner_did: ownerDid, message: "rcal space acknowledged" }); } diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..bb76e70 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * Middleware to handle subdomain-based space routing. + * + * Routes: + * - rcal.online -> home/landing page + * - www.rcal.online -> home/landing page + * - .rcal.online -> rewrite to /s/ + * + * Also handles localhost for development. + */ +export function middleware(request: NextRequest) { + const url = request.nextUrl.clone(); + const hostname = request.headers.get('host') || ''; + + let subdomain: string | null = null; + + // Match production: .rcal.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: .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 && url.pathname === '/') { + url.pathname = `/s/${subdomain}`; + return NextResponse.rewrite(url); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|.*\\..*|api).*)', + ], +};