import { PRIVATE_SHARE_QUERY_PARAM } from '@wispplace/constants' import { normalizeSitePath } from '@wispplace/fs-utils' import { createLogger } from '@wispplace/observability' import { buildPrivateStorageKey, buildSessionCookie, countCookieOccurrences, type GeneratedSecret, generateRecordId, generateSessionSecret, hashSecret, hashShareTokenSync, isValidSiteId, PRIVATE_ACCESS_PAGE_STYLES, PRIVATE_GRANT_QUERY_PARAM, PRIVATE_SESSION_TTL_MINUTES, parseCookieHeader, privateFormResponseHeaders, privateResponseHeaders, sessionCookieName, } from '@wispplace/private-sites' import { lookup } from 'mime-types' import { type AuthorizedPrivateSite, exchangePrivateHandoff, exchangePrivateShareToken, loadAuthorizedPrivateSite, type PrivateSiteFile, touchShare, } from './private-sites-db' import { privateStorage } from './storage' const logger = createLogger('hosting-service') const INDEX_FILES = ['index.html', 'index.htm'] const isSecureRequest = (request: Request): boolean => { if (request.headers.get('x-forwarded-proto') === 'https') return true try { return new URL(request.url).protocol === 'https:' } catch { return false } } const mainAppOrigin = (): string => { const configured = process.env.MAIN_APP_URL || process.env.DOMAIN if (configured) return configured.replace(/\/+$/, '') const base = (process.env.BASE_HOST || 'wisp.place').split(':')[0] return `https://${base}` } const privatePageHead = (title: string): string => ` ${title} — wisp.place ` const scopedSignInPage = (siteId: string, token: string, audienceDid: string): Response => { const body = `${privatePageHead('sign in required')}
wisp.place

this link is for a specific account

it was shared with ${escapeHtml(audienceDid)}. sign in with that account to open it.

` // This page POSTs cross-origin to the main app, so it cannot send `no-referrer`. return new Response(body, { status: 200, headers: { ...privateFormResponseHeaders(), 'Content-Type': 'text/html; charset=utf-8' }, }) } const anonymousSignInPage = (siteId: string): Response => { // Keep this independent of database state so hostnames cannot be probed for existence. const body = `${privatePageHead('sign in')}
wisp.place

access required

private site

this address needs a share link, or an account with access to it.

` return new Response(body, { status: 200, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/html; charset=utf-8' }, }) } const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c) export const privateNotFound = (): Response => new Response('Not found', { status: 404, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/plain; charset=utf-8' }, }) type CredentialQuery = { kind: 'none' } | { kind: 'handoff'; token: string } | { kind: 'share'; token: string } type PresentedCredential = Exclude type PendingSession = { sessionId: string secret: GeneratedSecret expiresAt: Date } type CredentialResolution = | { kind: 'sessionCreated'; pendingSession: PendingSession; shareId: string | null } | { kind: 'audienceMismatch'; shareToken: string; audienceDid: string } | { kind: 'denied'; source: 'handoff' | 'share' } | { kind: 'unavailable'; source: 'handoff' | 'share' } type CredentialFailure = Exclude type CredentialExchangeOutcome = { kind: 'notPresent' } | { kind: 'response'; response: Response } type SessionCookieOutcome = { kind: 'missing' } | { kind: 'present'; secretHash: string } type AuthorizedSiteLoadOutcome = | { kind: 'authorized'; authorized: AuthorizedPrivateSite } | { kind: 'missing' } | { kind: 'unavailable' } type PrivateManifestOutcome = { kind: 'valid'; index: PrivateFileIndex } | { kind: 'invalid' } type PrivateStorageReadOutcome = { kind: 'found'; bytes: Uint8Array } | { kind: 'missing' } | { kind: 'unavailable' } interface PrivateFileIndex { known: Map legacyRoot: string } const credentialQueryFrom = (url: URL): CredentialQuery => { const handoffToken = url.searchParams.get(PRIVATE_GRANT_QUERY_PARAM) if (handoffToken) return { kind: 'handoff', token: handoffToken } const shareToken = url.searchParams.get(PRIVATE_SHARE_QUERY_PARAM) return shareToken ? { kind: 'share', token: shareToken } : { kind: 'none' } } const newPendingSession = (): PendingSession => { const secret = generateSessionSecret() return { sessionId: generateRecordId(), secret, expiresAt: new Date(Date.now() + PRIVATE_SESSION_TTL_MINUTES * 60_000), } } const sessionRedirect = (request: Request, url: URL, secret: GeneratedSecret): Response => { url.searchParams.delete(PRIVATE_SHARE_QUERY_PARAM) url.searchParams.delete(PRIVATE_GRANT_QUERY_PARAM) return new Response(null, { status: 302, headers: { ...privateResponseHeaders(), Location: `${url.pathname}${url.search}`, 'Set-Cookie': buildSessionCookie(secret.value, isSecureRequest(request), PRIVATE_SESSION_TTL_MINUTES * 60), }, }) } const resolveHandoffCredential = async (siteId: string, token: string): Promise => { const pendingSession = newPendingSession() const input = { siteId, secretHash: hashSecret(token), sessionId: pendingSession.sessionId, sessionSecretHash: pendingSession.secret.hash, expiresAt: pendingSession.expiresAt, } try { const exchange = await exchangePrivateHandoff(input) if (!exchange) return { kind: 'denied', source: 'handoff' } return { kind: 'sessionCreated', pendingSession, shareId: exchange.kind === 'share' ? exchange.shareId : null, } } catch { return { kind: 'unavailable', source: 'handoff' } } } const resolveShareCredential = async (siteId: string, token: string): Promise => { const pendingSession = newPendingSession() const input = { siteId, tokenHash: hashShareTokenSync(token), sessionId: pendingSession.sessionId, sessionSecretHash: pendingSession.secret.hash, expiresAt: pendingSession.expiresAt, } try { const exchange = await exchangePrivateShareToken(input) if (!exchange) return { kind: 'denied', source: 'share' } if (exchange.kind === 'audienceMismatch') { return { kind: 'audienceMismatch', shareToken: token, audienceDid: exchange.audienceDid } } return { kind: 'sessionCreated', pendingSession, shareId: exchange.shareId } } catch { return { kind: 'unavailable', source: 'share' } } } const resolveCredential = (siteId: string, credential: PresentedCredential): Promise => { return credential.kind === 'handoff' ? resolveHandoffCredential(siteId, credential.token) : resolveShareCredential(siteId, credential.token) } const touchGrantedShare = (siteId: string, shareId: string): void => { void touchShare(shareId).catch(() => { logger.warn('[PrivateSite] Could not record share use', { siteId, failureKind: 'share-touch-failed' }) }) } const credentialFailureResponse = (siteId: string, resolution: CredentialFailure): Response => { if (resolution.kind === 'audienceMismatch') { logger.info('[PrivateSite] Scoped share needs sign-in', { siteId }) return scopedSignInPage(siteId, resolution.shareToken, resolution.audienceDid) } if (resolution.kind === 'unavailable') { logger.warn('[PrivateSite] Credential exchange unavailable', { siteId, failureKind: `${resolution.source}-exchange-failed`, }) } else { logger.info('[PrivateSite] Credential exchange denied', { siteId, source: resolution.source }) } return privateNotFound() } const tryExchangeCredential = async (request: Request, siteId: string): Promise => { const url = new URL(request.url) const credential = credentialQueryFrom(url) if (credential.kind === 'none') return { kind: 'notPresent' } const resolution = await resolveCredential(siteId, credential) if (resolution.kind !== 'sessionCreated') { return { kind: 'response', response: credentialFailureResponse(siteId, resolution) } } if (resolution.shareId) touchGrantedShare(siteId, resolution.shareId) return { kind: 'response', response: sessionRedirect(request, url, resolution.pendingSession.secret) } } const sessionCookieFrom = (request: Request): SessionCookieOutcome => { const cookieHeader = request.headers.get('cookie') const name = sessionCookieName(isSecureRequest(request)) // Refuse cookie-tossing ambiguity rather than relying on first/last-wins parsing. if (countCookieOccurrences(cookieHeader, name) !== 1) return { kind: 'missing' } const raw = parseCookieHeader(cookieHeader)[name] return raw ? { kind: 'present', secretHash: hashSecret(raw) } : { kind: 'missing' } } /** * The primary-only aggregate query is the session authorization boundary. It * returns files only when the site, session, and any backing share are live. */ const loadAuthorizedSite = async (siteId: string, secretHash: string): Promise => { try { const authorized = await loadAuthorizedPrivateSite(siteId, secretHash) return authorized ? { kind: 'authorized', authorized } : { kind: 'missing' } } catch { logger.warn('[PrivateSite] Authorized site lookup failed', { siteId, failureKind: 'authorized-site-load-failed' }) return { kind: 'unavailable' } } } const invalidPrivatePathResponse = (): Response => { return new Response('Invalid path', { status: 400, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/plain; charset=utf-8' }, }) } const normalizedPrivateRequestPath = (filePath: string): { kind: 'valid'; path: string } | { kind: 'invalid' } => { const path = normalizeSitePath(filePath) return path === null ? { kind: 'invalid' } : { kind: 'valid', path } } const isCanonicalStoredPath = (path: string): boolean => { const normalized = normalizeSitePath(path) return normalized !== null && normalized.length > 0 && normalized === path } const indexPrivateFiles = (files: readonly PrivateSiteFile[]): PrivateFileIndex => { const known = new Map(files.map((file) => [file.path, file])) const firstSegments = new Set(files.map((file) => file.path.split('/')[0])) const legacyRoot = files.length > 0 && firstSegments.size === 1 && files.every((file) => file.path.includes('/')) ? `${files[0]!.path.split('/')[0]}/` : '' return { known, legacyRoot } } const validateAndIndexPrivateFiles = (files: readonly PrivateSiteFile[]): PrivateManifestOutcome => { for (const file of files) { if (!isCanonicalStoredPath(file.path)) return { kind: 'invalid' } } return { kind: 'valid', index: indexPrivateFiles(files) } } const firstKnownIndex = (directory: string, known: ReadonlyMap): string | null => { for (const indexFile of INDEX_FILES) { const candidate = `${directory}${indexFile}` if (known.has(candidate)) return candidate } return null } const targetWithinRoot = ( requested: string, root: string, known: ReadonlyMap, ): string | null => { const rootedRequest = `${root}${requested}` if (requested.length > 0 && known.has(rootedRequest)) return rootedRequest return firstKnownIndex(requested.length > 0 ? `${rootedRequest}/` : root, known) } const resolvePrivateTarget = (requested: string, index: PrivateFileIndex): string | null => { const target = targetWithinRoot(requested, '', index.known) if (target || !index.legacyRoot) return target return targetWithinRoot(requested, index.legacyRoot, index.known) } const loadPrivateFile = async (siteId: string, target: string): Promise => { const storageKey = buildPrivateStorageKey(siteId, target) try { const result = await privateStorage.get(storageKey) return result ? { kind: 'found', bytes: result as Uint8Array } : { kind: 'missing' } } catch { return { kind: 'unavailable' } } } const privateFileResponse = (target: string, bytes: Uint8Array, meta: PrivateSiteFile | undefined): Response => { return new Response(Buffer.from(bytes), { status: 200, headers: { ...privateResponseHeaders(), 'Content-Type': meta?.mimeType ?? (lookup(target) || 'application/octet-stream'), 'Content-Length': String(bytes.byteLength), }, }) } const servePrivateStoredFile = async (siteId: string, target: string, index: PrivateFileIndex): Promise => { const stored = await loadPrivateFile(siteId, target) if (stored.kind === 'missing') { logger.warn('[PrivateSite] Stored file missing', { siteId, path: target }) return privateNotFound() } if (stored.kind === 'unavailable') { logger.warn('[PrivateSite] Stored file unavailable', { siteId, path: target, failureKind: 'private-storage-read-failed', }) return privateNotFound() } return privateFileResponse(target, stored.bytes, index.known.get(target)) } export const servePrivateSite = async (request: Request, siteId: string, filePath: string): Promise => { if (!isValidSiteId(siteId)) return privateNotFound() const requested = normalizedPrivateRequestPath(filePath) if (requested.kind === 'invalid') return invalidPrivatePathResponse() const exchanged = await tryExchangeCredential(request, siteId) if (exchanged.kind === 'response') return exchanged.response const session = sessionCookieFrom(request) if (session.kind === 'missing') return anonymousSignInPage(siteId) const authorized = await loadAuthorizedSite(siteId, session.secretHash) if (authorized.kind === 'unavailable') return privateNotFound() if (authorized.kind === 'missing') return anonymousSignInPage(siteId) const manifest = validateAndIndexPrivateFiles(authorized.authorized.files) if (manifest.kind === 'invalid') return privateNotFound() const target = resolvePrivateTarget(requested.path, manifest.index) if (!target) return privateNotFound() return await servePrivateStoredFile(siteId, target, manifest.index) }