feat: add username-based personal subdomains (<username>.rnotes.online)

Implements workspace-scoped data isolation via subdomain routing:

- Schema: add workspaceSlug to Notebook model + migration
- Middleware: extract subdomain → x-workspace-slug header
- API: filter notebooks/notes/search by workspace on subdomains
- AppSwitcher: generate <username>.r*.online links when logged in
- Sessions: SubdomainSession component syncs auth across subdomains
  via .rnotes.online domain-wide cookie
- Auth: auto-migrate unscoped notebooks to user's workspace
- New /api/me endpoint for client-side auth + workspace state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 20:46:24 -08:00
parent 4eb24038b6
commit c27290ee80
15 changed files with 282 additions and 18 deletions

View File

@ -0,0 +1,5 @@
-- Add workspaceSlug to Notebook for subdomain-based data isolation
ALTER TABLE "Notebook" ADD COLUMN "workspaceSlug" TEXT NOT NULL DEFAULT '';
-- Index for efficient workspace-scoped queries
CREATE INDEX "Notebook_workspaceSlug_idx" ON "Notebook"("workspaceSlug");

View File

@ -25,23 +25,25 @@ model User {
// ─── Notebooks ──────────────────────────────────────────────────────
model Notebook {
id String @id @default(cuid())
title String
slug String @unique
description String? @db.Text
coverColor String @default("#f59e0b")
canvasSlug String?
canvasShapeId String?
isPublic Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
title String
slug String @unique
description String? @db.Text
coverColor String @default("#f59e0b")
canvasSlug String?
canvasShapeId String?
isPublic Boolean @default(false)
sortOrder Int @default(0)
workspaceSlug String @default("") // subdomain scope: "" = personal/unscoped
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collaborators NotebookCollaborator[]
notes Note[]
sharedAccess SharedAccess[]
@@index([slug])
@@index([workspaceSlug])
}
enum CollaboratorRole {

29
src/app/api/me/route.ts Normal file
View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthUser } from '@/lib/auth';
import { getWorkspaceSlug } from '@/lib/workspace';
export async function GET(request: NextRequest) {
try {
const auth = await getAuthUser(request);
const workspaceSlug = getWorkspaceSlug();
if (!auth) {
return NextResponse.json({
authenticated: false,
workspace: workspaceSlug || null,
});
}
return NextResponse.json({
authenticated: true,
user: {
id: auth.user.id,
username: auth.user.username,
did: auth.did,
},
workspace: workspaceSlug || null,
});
} catch {
return NextResponse.json({ authenticated: false, workspace: null });
}
}

View File

@ -3,12 +3,26 @@ import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
import { getWorkspaceSlug } from '@/lib/workspace';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const workspaceSlug = getWorkspaceSlug();
// Verify notebook belongs to current workspace
if (workspaceSlug) {
const notebook = await prisma.notebook.findUnique({
where: { id: params.id },
select: { workspaceSlug: true },
});
if (!notebook || notebook.workspaceSlug !== workspaceSlug) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
}
const notes = await prisma.note.findMany({
where: { notebookId: params.id, archivedAt: null },
include: {

View File

@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
import { getWorkspaceSlug } from '@/lib/workspace';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const workspaceSlug = getWorkspaceSlug();
const notebook = await prisma.notebook.findUnique({
where: { id: params.id },
include: {
@ -14,6 +17,7 @@ export async function GET(
include: {
tags: { include: { tag: true } },
},
where: { archivedAt: null },
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
},
collaborators: {
@ -27,6 +31,11 @@ export async function GET(
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
// Workspace boundary check: if on a subdomain, only show notebooks from that workspace
if (workspaceSlug && notebook.workspaceSlug !== workspaceSlug) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
return NextResponse.json(notebook);
} catch (error) {
console.error('Get notebook error:', error);

View File

@ -3,10 +3,21 @@ import { prisma } from '@/lib/prisma';
import { generateSlug } from '@/lib/slug';
import { nanoid } from 'nanoid';
import { requireAuth, isAuthed } from '@/lib/auth';
import { getWorkspaceSlug } from '@/lib/workspace';
export async function GET() {
try {
const workspaceSlug = getWorkspaceSlug();
const where: Record<string, unknown> = {};
if (workspaceSlug) {
// On a subdomain: show only that workspace's notebooks
where.workspaceSlug = workspaceSlug;
}
// On bare domain: show all notebooks (cross-workspace view)
const notebooks = await prisma.notebook.findMany({
where,
include: {
_count: { select: { notes: true } },
collaborators: {
@ -28,6 +39,7 @@ export async function POST(request: NextRequest) {
const auth = await requireAuth(request);
if (!isAuthed(auth)) return auth;
const { user } = auth;
const workspaceSlug = getWorkspaceSlug();
const body = await request.json();
const { title, description, coverColor } = body;
@ -48,6 +60,7 @@ export async function POST(request: NextRequest) {
slug: finalSlug,
description: description?.trim() || null,
coverColor: coverColor || '#f59e0b',
workspaceSlug: workspaceSlug || '',
collaborators: {
create: { userId: user.id, role: 'OWNER' },
},

View File

@ -4,6 +4,7 @@ import { stripHtml } from '@/lib/strip-html';
import { NoteType } from '@prisma/client';
import { requireAuth, isAuthed } from '@/lib/auth';
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
import { getWorkspaceSlug } from '@/lib/workspace';
export async function GET(request: NextRequest) {
try {
@ -13,6 +14,7 @@ export async function GET(request: NextRequest) {
const cardType = searchParams.get('cardType');
const tag = searchParams.get('tag');
const pinned = searchParams.get('pinned');
const workspaceSlug = getWorkspaceSlug();
const where: Record<string, unknown> = {
archivedAt: null, // exclude soft-deleted
@ -25,11 +27,19 @@ export async function GET(request: NextRequest) {
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
}
// Workspace boundary: filter notes by their notebook's workspace
if (workspaceSlug) {
where.notebook = {
...(where.notebook as object || {}),
workspaceSlug,
};
}
const notes = await prisma.note.findMany({
where,
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
notebook: { select: { id: true, title: true, slug: true, workspaceSlug: true } },
parent: { select: { id: true, title: true } },
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
attachments: { include: { file: true }, orderBy: { position: 'asc' } },

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getWorkspaceSlug } from '@/lib/workspace';
export const dynamic = 'force-dynamic';
@ -10,6 +11,7 @@ export async function GET(request: NextRequest) {
const type = searchParams.get('type');
const cardType = searchParams.get('cardType');
const notebookId = searchParams.get('notebookId');
const workspaceSlug = getWorkspaceSlug();
if (!q) {
return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 });
@ -32,6 +34,12 @@ export async function GET(request: NextRequest) {
filters.push(`n."notebookId" = $${params.length}`);
}
// Workspace boundary: only search within the current workspace's notebooks
if (workspaceSlug) {
params.push(workspaceSlug);
filters.push(`nb."workspaceSlug" = $${params.length}`);
}
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
// Full-text search — prefer bodyMarkdown over contentPlain

View File

@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
import './globals.css'
import { AuthProvider } from '@/components/AuthProvider'
import { PWAInstall } from '@/components/PWAInstall'
import { SubdomainSession } from '@/components/SubdomainSession'
const inter = Inter({
subsets: ['latin'],
@ -46,6 +47,7 @@ export default function RootLayout({
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<AuthProvider>
<SubdomainSession />
{children}
<PWAInstall />
</AuthProvider>

View File

@ -65,12 +65,23 @@ const CATEGORY_ORDER = [
'Social & Sharing',
];
/** Build the URL for a module, using username subdomain if logged in */
function getModuleUrl(m: AppModule, username: string | null): string {
if (!m.domain) return '#';
if (username) {
// Generate <username>.<domain> URL
return `https://${username}.${m.domain}`;
}
return `https://${m.domain}`;
}
interface AppSwitcherProps {
current?: string;
}
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -83,6 +94,18 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
return () => document.removeEventListener('click', handleClick);
}, []);
// Fetch current user's username for subdomain links
useEffect(() => {
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated && data.user?.username) {
setUsername(data.user.username);
}
})
.catch(() => { /* not logged in */ });
}, []);
const currentMod = MODULES.find((m) => m.id === current);
// Group modules by category
@ -140,7 +163,7 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
} transition-colors`}
>
<a
href={m.domain ? `https://${m.domain}` : '#'}
href={getModuleUrl(m, username)}
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
onClick={() => setOpen(false)}
>

View File

@ -0,0 +1,98 @@
'use client';
import { useEffect } from 'react';
const TOKEN_KEY = 'encryptid_token';
const USER_KEY = 'encryptid_user';
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
/**
* Cross-subdomain session sync.
*
* The EncryptID SDK stores tokens in localStorage (per-origin) and sets
* a cookie without a domain attribute (per-hostname). This means
* jeff.rnotes.online can't access rnotes.online's session.
*
* This component bridges that gap by:
* 1. Mirroring localStorage token to a cookie scoped to .rnotes.online
* 2. On subdomain load, restoring from the domain-wide cookie to localStorage
*/
function isRnotesDomain(): boolean {
if (typeof window === 'undefined') return false;
return window.location.hostname.endsWith('.rnotes.online') ||
window.location.hostname === 'rnotes.online';
}
function getCookieDomain(): string {
return '.rnotes.online';
}
function getDomainCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function setDomainCookie(name: string, value: string): void {
document.cookie = `${name}=${encodeURIComponent(value)};path=/;domain=${getCookieDomain()};max-age=${COOKIE_MAX_AGE};SameSite=Lax;Secure`;
}
function clearDomainCookie(name: string): void {
document.cookie = `${name}=;path=/;domain=${getCookieDomain()};max-age=0;SameSite=Lax;Secure`;
}
export function SubdomainSession() {
useEffect(() => {
if (!isRnotesDomain()) return;
// On mount: sync localStorage <-> domain cookie
const localToken = localStorage.getItem(TOKEN_KEY);
const cookieToken = getDomainCookie(TOKEN_KEY);
if (localToken && !cookieToken) {
// Have localStorage token but no domain cookie — set it
setDomainCookie(TOKEN_KEY, localToken);
} else if (!localToken && cookieToken) {
// On a subdomain with no localStorage but domain cookie exists — restore
localStorage.setItem(TOKEN_KEY, cookieToken);
// Also restore user info from cookie if available
const cookieUser = getDomainCookie(USER_KEY);
if (cookieUser) {
localStorage.setItem(USER_KEY, cookieUser);
}
// Reload to let EncryptIDProvider pick up the restored token
window.location.reload();
return;
} else if (localToken && cookieToken && localToken !== cookieToken) {
// Both exist but differ — localStorage wins (it's more recent)
setDomainCookie(TOKEN_KEY, localToken);
}
// Watch for localStorage changes (from EncryptID SDK login/logout)
function handleStorage(e: StorageEvent) {
if (e.key === TOKEN_KEY) {
if (e.newValue) {
setDomainCookie(TOKEN_KEY, e.newValue);
} else {
clearDomainCookie(TOKEN_KEY);
clearDomainCookie(USER_KEY);
}
}
if (e.key === USER_KEY && e.newValue) {
setDomainCookie(USER_KEY, e.newValue);
}
}
window.addEventListener('storage', handleStorage);
// Also sync user data to domain cookie
const localUser = localStorage.getItem(USER_KEY);
if (localUser) {
setDomainCookie(USER_KEY, localUser);
}
return () => window.removeEventListener('storage', handleStorage);
}, []);
return null; // render nothing
}

View File

@ -1,11 +1,13 @@
import { getEncryptIDSession } from '@encryptid/sdk/server/nextjs';
import { NextResponse } from 'next/server';
import { prisma } from './prisma';
import { getWorkspaceSlug } from './workspace';
import type { User } from '@prisma/client';
export interface AuthResult {
user: User;
did: string;
username: string | null;
}
const UNAUTHORIZED = NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@ -14,6 +16,7 @@ const UNAUTHORIZED = NextResponse.json({ error: 'Unauthorized' }, { status: 401
* Get authenticated user from request, or null if not authenticated.
* Upserts User in DB by DID (find-or-create).
* On first user creation, auto-claims orphaned notebooks/notes.
* Auto-migrates unscoped notebooks to user's workspace.
*/
export async function getAuthUser(request: Request): Promise<AuthResult | null> {
const claims = await getEncryptIDSession(request);
@ -55,7 +58,21 @@ export async function getAuthUser(request: Request): Promise<AuthResult | null>
});
}
return { user, did };
// Auto-migrate: assign unscoped notebooks owned by this user to their workspace
if (user.username) {
const workspaceSlug = user.username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
await prisma.notebook.updateMany({
where: {
workspaceSlug: '',
collaborators: {
some: { userId: user.id, role: 'OWNER' },
},
},
data: { workspaceSlug },
});
}
return { user, did, username: user.username };
}
/**

View File

@ -1,15 +1,23 @@
const TOKEN_KEY = 'encryptid_token';
function getCookieToken(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Authenticated fetch wrapper.
* Reads JWT from localStorage and adds Authorization header.
* Reads JWT from localStorage (primary) or domain cookie (fallback).
* On 401, redirects to signin page.
*/
export async function authFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const token = typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null;
const token = typeof window !== 'undefined'
? (localStorage.getItem(TOKEN_KEY) || getCookieToken())
: null;
const headers = new Headers(options.headers);
if (token) {

10
src/lib/workspace.ts Normal file
View File

@ -0,0 +1,10 @@
import { headers } from 'next/headers';
/**
* Get the current workspace slug from the request.
* Returns the subdomain (e.g. "jeff" from "jeff.rnotes.online") or "" for bare domain.
*/
export function getWorkspaceSlug(): string {
const h = headers();
return h.get('x-workspace-slug') || '';
}

View File

@ -9,17 +9,33 @@ export function middleware(request: NextRequest) {
if (match && !RESERVED_SUBDOMAINS.has(match[1])) {
const space = match[1];
const response = NextResponse.next();
// Clone headers and add workspace context for API routes
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-workspace-slug', space);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// Set cookie for client-side access
response.cookies.set('rnotes-space', space, {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: true,
});
return response;
}
return NextResponse.next();
// Bare domain: set empty workspace header
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-workspace-slug', '');
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {