Files
makelore/electron/services/preview-data-session.ts

452 lines
15 KiB
TypeScript

import { randomBytes, timingSafeEqual } from 'node:crypto';
import path from 'node:path';
import type { IncomingMessage } from 'node:http';
import type {
AgentBrowserLifecycleEvent,
} from '../agent-browser/module';
import type { CodingProjectService } from '../coding-projects/project-service';
import {
getWorksSquareAccountBinding,
getWorksSquareSessionSnapshot,
isCurrentWorksSquareAccountBinding,
subscribeWorksSquareSession,
type WorksSquareAccountBinding,
type WorksSquareSessionListener,
} from './works-square-session';
import { getPort } from '../utils/config';
export const PREVIEW_DATA_ROUTE_ROOT = '/api/runtime/data/v1';
export const PREVIEW_DATA_CONTRACT_VERSION = 1 as const;
export const PREVIEW_DATA_MAX_REQUEST_BYTES = 98_304;
export const PREVIEW_DATA_MAX_RESPONSE_BYTES = 1_310_720;
export const PREVIEW_DATA_RATE = 5;
export const PREVIEW_DATA_BUCKET_CAPACITY = 30;
const PREVIEW_DATA_METHODS = new Set(['GET', 'PUT', 'DELETE', 'OPTIONS']);
const PREVIEW_DATA_HEADERS = new Set(['authorization', 'content-type', 'if-match']);
const PREVIEW_DATA_TRANSPORT_HEADERS = new Set([
'accept',
'accept-encoding',
'accept-language',
'cache-control',
'connection',
'content-length',
'host',
'origin',
'priority',
'pragma',
'referer',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'sec-fetch-dest',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-user',
'transfer-encoding',
'upgrade-insecure-requests',
'user-agent',
]);
export type PreviewDataSessionInvalidationReason =
| 'preview_closed'
| 'project_deactivated'
| 'project_identity_changed'
| 'session_cleared'
| 'account_changed'
| 'cross_origin_navigation'
| 'browser_generation_replaced'
| 'browser_detached'
| 'browser_crashed'
| 'main_shutdown'
| 'replaced'
| 'manual';
export type PreviewDataSessionInvalidationListener = (
reason: PreviewDataSessionInvalidationReason,
) => void;
export type PreviewDataSessionOpenInput = {
projectPath: string;
origin: string;
browserGeneration: number;
};
export type PreviewDataSessionSnapshot = Readonly<{
projectPath: string;
projectId: string;
origin: string;
browserGeneration: number;
createdAt: number;
}>;
export type PreviewDataInjectionValue = Readonly<{
endpoint: string;
token: string;
contractVersion: typeof PREVIEW_DATA_CONTRACT_VERSION;
}>;
export type PreviewDataAuthorizationSuccess = Readonly<{
ok: true;
method: string;
session: PreviewDataSessionSnapshot;
}>;
export type PreviewDataAuthorizationFailure = Readonly<{
ok: false;
status: number;
code: string;
message: string;
retryable: boolean;
retryAfterSeconds?: number;
}>;
export type PreviewDataAuthorizationResult =
| PreviewDataAuthorizationSuccess
| PreviewDataAuthorizationFailure;
export class PreviewDataSessionError extends Error {
constructor(
readonly code: 'invalid_origin' | 'invalid_generation' | 'runtime_unavailable',
message: string,
) {
super(message);
this.name = 'PreviewDataSessionError';
}
}
export type PreviewDataSessionManagerOptions = {
projects: Pick<CodingProjectService, 'requireActiveRealProjectWithIdentity'>;
hostPort?: number;
now?: () => number;
randomBytes?: (size: number) => Buffer;
subscribeWorksSquareSession?: typeof subscribeWorksSquareSession;
getWorksSquareSessionSnapshot?: typeof getWorksSquareSessionSnapshot;
getWorksSquareAccountBinding?: typeof getWorksSquareAccountBinding;
isCurrentWorksSquareAccountBinding?: typeof isCurrentWorksSquareAccountBinding;
};
type InternalSession = {
snapshot: PreviewDataSessionSnapshot;
token: string;
tokenBytes: Buffer;
tokens: number;
lastRefillMs: number;
};
function normalizePath(value: string): string {
const resolved = path.resolve(value);
return process.platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved;
}
function samePath(left: string, right: string): boolean {
return normalizePath(left) === normalizePath(right);
}
function normalizeOrigin(value: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid');
}
const hostname = parsed.hostname.toLowerCase();
const isLoopback = hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '[::1]';
if (
!isLoopback
|| (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|| parsed.username
|| parsed.password
|| parsed.pathname !== '/'
|| parsed.search
|| parsed.hash
) {
throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid');
}
return parsed.origin;
}
function normalizeGeneration(value: number): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new PreviewDataSessionError('invalid_generation', 'Browser generation is invalid');
}
return value;
}
function headerValue(req: IncomingMessage, name: string): string | undefined {
const value = req.headers[name];
return typeof value === 'string' ? value : undefined;
}
function failure(
status: number,
code: string,
message: string,
retryable = false,
retryAfterSeconds?: number,
): PreviewDataAuthorizationFailure {
return {
ok: false,
status,
code,
message,
retryable,
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
};
}
function requestHeadersAllowed(req: IncomingMessage): boolean {
for (const [name, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) return false;
if (PREVIEW_DATA_HEADERS.has(name) || PREVIEW_DATA_TRANSPORT_HEADERS.has(name)) continue;
if (name === 'access-control-request-method') {
if (value !== undefined && !PREVIEW_DATA_METHODS.has(value.toUpperCase())) return false;
continue;
}
if (name === 'access-control-request-headers') {
if (value === undefined) continue;
const requested = value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
if (requested.some((item) => !PREVIEW_DATA_HEADERS.has(item))) return false;
continue;
}
return false;
}
return true;
}
function isExactToken(candidate: string, expected: Buffer): boolean {
if (!candidate || !/^[A-Za-z0-9_-]+$/.test(candidate)) return false;
const candidateBytes = Buffer.from(candidate, 'base64url');
return candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected);
}
function retryAfterSeconds(tokens: number): number {
return Math.max(1, Math.ceil((1 - tokens) / PREVIEW_DATA_RATE));
}
export class PreviewDataSessionManager {
private readonly projects: Pick<CodingProjectService, 'requireActiveRealProjectWithIdentity'>;
private readonly hostPort: number;
private readonly now: () => number;
private readonly createRandomBytes: (size: number) => Buffer;
private readonly getSessionSnapshot: typeof getWorksSquareSessionSnapshot;
private readonly getAccountBinding: typeof getWorksSquareAccountBinding;
private readonly isCurrentAccountBinding: typeof isCurrentWorksSquareAccountBinding;
private readonly unsubscribeWorksSession: () => void;
private readonly invalidationListeners = new Set<PreviewDataSessionInvalidationListener>();
private session: InternalSession | null = null;
private boundAccountBinding: WorksSquareAccountBinding | null;
private disposed = false;
constructor(options: PreviewDataSessionManagerOptions) {
this.projects = options.projects;
this.hostPort = options.hostPort ?? getPort('NIANCODE_HOST_API');
this.now = options.now ?? Date.now;
this.createRandomBytes = options.randomBytes ?? randomBytes;
this.getSessionSnapshot = options.getWorksSquareSessionSnapshot ?? getWorksSquareSessionSnapshot;
this.getAccountBinding = options.getWorksSquareAccountBinding ?? getWorksSquareAccountBinding;
this.isCurrentAccountBinding = options.isCurrentWorksSquareAccountBinding
?? isCurrentWorksSquareAccountBinding;
// The Works observer intentionally has no initial callback. Read the
// current snapshot once, then rely on the unsubscribe-able observer for
// subsequent logout/account transitions.
const initialSession = this.getSessionSnapshot();
this.boundAccountBinding = initialSession ? this.getAccountBinding() : null;
const subscribe = options.subscribeWorksSquareSession ?? subscribeWorksSquareSession;
const listener: WorksSquareSessionListener = (nextSession) => {
if (!nextSession) {
this.invalidate('session_cleared');
return;
}
if (!this.boundAccountBinding) {
this.invalidate('account_changed');
return;
}
if (!this.isCurrentAccountBinding(this.boundAccountBinding)) {
this.invalidate('account_changed');
}
};
this.unsubscribeWorksSession = subscribe(listener);
}
async open(input: PreviewDataSessionOpenInput): Promise<PreviewDataSessionSnapshot> {
if (this.disposed) {
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
}
const origin = normalizeOrigin(input.origin);
const browserGeneration = normalizeGeneration(input.browserGeneration);
const active = await this.projects.requireActiveRealProjectWithIdentity(input.projectPath);
if (this.disposed) {
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
}
const now = this.now();
const tokenBytes = this.createRandomBytes(32);
if (tokenBytes.length !== 32) {
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
}
const token = tokenBytes.toString('base64url');
this.invalidate('replaced');
const snapshot: PreviewDataSessionSnapshot = Object.freeze({
projectPath: active.path,
projectId: active.projectId,
origin,
browserGeneration,
createdAt: now,
});
this.session = {
snapshot,
token,
tokenBytes,
tokens: PREVIEW_DATA_BUCKET_CAPACITY,
lastRefillMs: now,
};
this.boundAccountBinding = this.getAccountBinding();
return snapshot;
}
getSnapshot(): PreviewDataSessionSnapshot | null {
return this.session?.snapshot ?? null;
}
subscribeInvalidation(listener: PreviewDataSessionInvalidationListener): () => void {
if (this.disposed) return () => undefined;
this.invalidationListeners.add(listener);
return () => {
this.invalidationListeners.delete(listener);
};
}
getInjectionValue(hostPort = this.hostPort): PreviewDataInjectionValue | null {
const session = this.session;
if (!session || !Number.isSafeInteger(hostPort) || hostPort < 1 || hostPort > 65_535) {
return null;
}
return Object.freeze({
endpoint: `http://127.0.0.1:${hostPort}${PREVIEW_DATA_ROUTE_ROOT}`,
token: session.token,
contractVersion: PREVIEW_DATA_CONTRACT_VERSION,
});
}
private accountBindingIsCurrent(): boolean {
const currentSession = this.getSessionSnapshot();
const currentBinding = this.getAccountBinding();
if (!this.boundAccountBinding) return !currentSession && !currentBinding;
return Boolean(currentBinding && this.isCurrentAccountBinding(this.boundAccountBinding));
}
corsOrigin(req: IncomingMessage): string | null {
const origin = headerValue(req, 'origin');
const session = this.session;
return session && origin === session.snapshot.origin ? origin : null;
}
authorizeRequest(req: IncomingMessage): PreviewDataAuthorizationResult {
const session = this.session;
if (!session) return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true);
if (!this.accountBindingIsCurrent()) {
this.invalidate('account_changed');
return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true);
}
const origin = headerValue(req, 'origin');
if (origin !== session.snapshot.origin) {
return failure(403, 'origin_not_allowed', 'Preview Origin is not allowed');
}
if (!requestHeadersAllowed(req)) {
return failure(400, 'invalid_request', 'Preview request headers are not allowed');
}
const method = (req.method ?? '').toUpperCase();
if (method === 'OPTIONS') {
return { ok: true, method, session: session.snapshot };
}
const authorization = headerValue(req, 'authorization') ?? '';
if (!authorization.startsWith('Bearer ') || !isExactToken(authorization.slice(7), session.tokenBytes)) {
return failure(401, 'authentication_required', 'Preview data authorization is required');
}
if (!PREVIEW_DATA_METHODS.has(method)) {
return failure(405, 'method_not_allowed', 'Preview data method is not allowed');
}
const now = this.now();
const elapsed = Math.max(0, now - session.lastRefillMs);
session.tokens = Math.min(
PREVIEW_DATA_BUCKET_CAPACITY,
session.tokens + elapsed / 1_000 * PREVIEW_DATA_RATE,
);
session.lastRefillMs = now;
if (session.tokens < 1) {
return failure(
429,
'rate_limited',
'Preview data request rate limit exceeded',
true,
retryAfterSeconds(session.tokens),
);
}
session.tokens -= 1;
return { ok: true, method, session: session.snapshot };
}
isCurrentSession(snapshot: PreviewDataSessionSnapshot): boolean {
return this.session?.snapshot === snapshot;
}
handleAgentBrowserLifecycle(event: AgentBrowserLifecycleEvent): void {
const session = this.session;
if (!session || !samePath(session.snapshot.projectPath, event.projectPath)) return;
if (event.generation !== session.snapshot.browserGeneration) return;
switch (event.type) {
case 'closed':
this.invalidate('preview_closed');
break;
case 'cross-origin-navigation':
this.invalidate('cross_origin_navigation');
break;
case 'generation-replaced':
this.invalidate('browser_generation_replaced');
break;
case 'detached':
this.invalidate('browser_detached');
break;
case 'crashed':
this.invalidate('browser_crashed');
break;
default:
break;
}
}
invalidate(reason: PreviewDataSessionInvalidationReason = 'manual'): void {
if (!this.session) return;
this.session = null;
for (const listener of this.invalidationListeners) {
try {
listener(reason);
} catch {
// A browser cleanup observer must not interrupt token invalidation.
}
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.invalidate('main_shutdown');
this.unsubscribeWorksSession();
this.invalidationListeners.clear();
}
}
export function createPreviewDataSessionManager(
options: PreviewDataSessionManagerOptions,
): PreviewDataSessionManager {
return new PreviewDataSessionManager(options);
}