From 867afd1087bed3a560432d7ad4cd98c8f2bbb7d4 Mon Sep 17 00:00:00 2001 From: emmzy4tech Date: Thu, 22 Oct 2026 04:05:41 +0100 Subject: [PATCH] feat: HSM relayer signer, bundle splitting, virtualized audit logs, MFE sandbox - BE-002 (#1574): AWS KMS / Vault Transit Ed25519 signing engine with hash-chained audit records carrying the IAM/Vault request id; software signer restricted to development/test; relayer fee-bump service. - FE-050 (#1573): next/dynamic boundaries for Monaco, ReactFlow and chart modules, async-only cache groups, deferred layout widgets, next/font, hover/focus prefetch for course links. - FE-048 (#1571): react-window audit log grid with column resizing, multi-column sort, debounced search, JSON inspector and CSV export. - FE-047 (#1570): sandboxed micro-frontend container with a typed, token-gated postMessage RPC bridge for signing and progress events. Closes #1574 Closes #1573 Closes #1571 Closes #1570 Co-Authored-By: Claude Opus 5.5 --- backend/.env.example | 23 + backend/src/config/vault.ts | 239 ++++++++ backend/src/services/kmsSignerService.ts | 446 ++++++++++++++ backend/src/services/stellarService.ts | 164 +++++ backend/tests/kmsSignerService.test.ts | 279 +++++++++ frontend/next.config.ts | 113 +++- frontend/src/app/admin/audit-logs/page.tsx | 27 + frontend/src/app/brainstorm/page.tsx | 8 +- frontend/src/app/collaborative-lab/page.tsx | 10 +- frontend/src/app/courses/page.tsx | 8 + frontend/src/app/globals.css | 2 +- frontend/src/app/layout.tsx | 46 +- frontend/src/app/yield-calculator/page.tsx | 7 +- .../src/components/admin/LogDetailModal.tsx | 150 +++++ .../components/admin/VirtualizedLogTable.tsx | 278 +++++++++ .../layout/DeferredLayoutWidgets.tsx | 25 + .../src/components/lesson/LessonWorkspace.tsx | 8 +- .../review/CollaborativeReviewDashboard.tsx | 7 +- .../sandbox/MicroFrontendContainer.tsx | 349 +++++++++++ .../components/snippets/SnippetLibrary.tsx | 5 +- .../src/hooks/__tests__/useAuditLogs.test.ts | 42 ++ frontend/src/hooks/useAuditLogs.ts | 344 +++++++++++ .../sandbox/__tests__/rpcBridge.test.ts | 204 +++++++ frontend/src/services/sandbox/rpcBridge.ts | 571 ++++++++++++++++++ frontend/src/types/microFrontend.ts | 190 ++++++ 25 files changed, 3488 insertions(+), 57 deletions(-) create mode 100644 backend/src/config/vault.ts create mode 100644 backend/src/services/kmsSignerService.ts create mode 100644 backend/src/services/stellarService.ts create mode 100644 backend/tests/kmsSignerService.test.ts create mode 100644 frontend/src/app/admin/audit-logs/page.tsx create mode 100644 frontend/src/components/admin/LogDetailModal.tsx create mode 100644 frontend/src/components/admin/VirtualizedLogTable.tsx create mode 100644 frontend/src/components/layout/DeferredLayoutWidgets.tsx create mode 100644 frontend/src/components/sandbox/MicroFrontendContainer.tsx create mode 100644 frontend/src/hooks/__tests__/useAuditLogs.test.ts create mode 100644 frontend/src/hooks/useAuditLogs.ts create mode 100644 frontend/src/services/sandbox/__tests__/rpcBridge.test.ts create mode 100644 frontend/src/services/sandbox/rpcBridge.ts create mode 100644 frontend/src/types/microFrontend.ts diff --git a/backend/.env.example b/backend/.env.example index b7dec20dc..1470cb613 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -119,6 +119,29 @@ AWS_KMS_KEY_ID="" PLATFORM_MASTER_PUBLIC_KEY="" KMS_SIGNING_ALGORITHM=ED25519_SHA_512 +# ------------------------------------------ +# Custodial Relayer HSM Signer (BE-002) +# ------------------------------------------ +# aws-kms | vault-transit | software-dev. When unset: AWS KMS if AWS_KMS_KEY_ID is +# set, else Vault if VAULT_ADDR is set, else the software signer (development/test only). +HSM_SIGNER_PROVIDER="" +# Optional fixed secret for the dev-only software signer (random key when empty) +HSM_DEV_SIGNER_SECRET="" +HSM_ALLOW_SOFTWARE_SIGNER=true +# HashiCorp Vault Transit (key type ed25519) +VAULT_ADDR="" +VAULT_NAMESPACE="" +# token | kubernetes | approle +VAULT_AUTH_METHOD=token +VAULT_TOKEN="" +VAULT_K8S_ROLE="" +VAULT_APPROLE_ROLE_ID="" +VAULT_APPROLE_SECRET_ID="" +VAULT_TRANSIT_MOUNT=transit +VAULT_TRANSIT_KEY=stellar-relayer +VAULT_TRANSIT_KEY_VERSION="" +VAULT_REQUEST_TIMEOUT_MS=5000 + # ------------------------------------------ # Certificate Configuration # ------------------------------------------ diff --git a/backend/src/config/vault.ts b/backend/src/config/vault.ts new file mode 100644 index 000000000..429988ef3 --- /dev/null +++ b/backend/src/config/vault.ts @@ -0,0 +1,239 @@ +import { readFile } from 'fs/promises'; + +/** + * HashiCorp Vault configuration for the custodial relayer HSM signer (BE-002). + * + * The relayer key lives in Vault's Transit secrets engine (optionally backed + * by a PKCS#11 HSM via Vault Enterprise "managed keys"). The application only + * ever holds a short-lived Vault token — never the Ed25519 private key. All + * signatures are produced by `POST /v1//sign/` inside Vault. + */ + +export type VaultAuthMethod = 'token' | 'kubernetes' | 'approle'; + +export interface VaultConfig { + /** Vault server address, e.g. https://vault.internal:8200 */ + address: string; + /** Vault Enterprise namespace (sent as `X-Vault-Namespace`). */ + namespace?: string; + authMethod: VaultAuthMethod; + /** Static token — only for `token` auth. Prefer kubernetes/approle in production. */ + token?: string; + /** Kubernetes auth role and the projected service-account JWT path. */ + kubernetesRole?: string; + kubernetesJwtPath: string; + kubernetesMountPath: string; + /** AppRole credentials. The secret id should be delivered via a wrapped token / file mount. */ + appRoleId?: string; + appRoleSecretId?: string; + appRoleMountPath: string; + /** Transit engine mount and the Ed25519 key name holding the relayer key. */ + transitMountPath: string; + transitKeyName: string; + /** Pin a key version; omit to sign with the latest version. */ + transitKeyVersion?: number; + /** Per-request timeout in milliseconds. */ + requestTimeoutMs: number; +} + +const DEFAULT_K8S_JWT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'; + +function parseOptionalInt(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseAuthMethod(value: string | undefined): VaultAuthMethod { + if (value === 'kubernetes' || value === 'approle') return value; + return 'token'; +} + +/** Read the Vault configuration from the environment without validating it. */ +export function loadVaultConfig(env: NodeJS.ProcessEnv = process.env): VaultConfig { + return { + address: (env.VAULT_ADDR || '').replace(/\/+$/, ''), + namespace: env.VAULT_NAMESPACE || undefined, + authMethod: parseAuthMethod(env.VAULT_AUTH_METHOD), + token: env.VAULT_TOKEN || undefined, + kubernetesRole: env.VAULT_K8S_ROLE || undefined, + kubernetesJwtPath: env.VAULT_K8S_JWT_PATH || DEFAULT_K8S_JWT_PATH, + kubernetesMountPath: env.VAULT_K8S_MOUNT_PATH || 'kubernetes', + appRoleId: env.VAULT_APPROLE_ROLE_ID || undefined, + appRoleSecretId: env.VAULT_APPROLE_SECRET_ID || undefined, + appRoleMountPath: env.VAULT_APPROLE_MOUNT_PATH || 'approle', + transitMountPath: env.VAULT_TRANSIT_MOUNT || 'transit', + transitKeyName: env.VAULT_TRANSIT_KEY || 'stellar-relayer', + transitKeyVersion: parseOptionalInt(env.VAULT_TRANSIT_KEY_VERSION), + requestTimeoutMs: parseOptionalInt(env.VAULT_REQUEST_TIMEOUT_MS) ?? 5000, + }; +} + +/** Throws a descriptive error when the config cannot be used to sign. */ +export function assertVaultConfig(config: VaultConfig): void { + const missing: string[] = []; + if (!config.address) missing.push('VAULT_ADDR'); + if (!config.transitKeyName) missing.push('VAULT_TRANSIT_KEY'); + + if (config.authMethod === 'token' && !config.token) missing.push('VAULT_TOKEN'); + if (config.authMethod === 'kubernetes' && !config.kubernetesRole) missing.push('VAULT_K8S_ROLE'); + if (config.authMethod === 'approle') { + if (!config.appRoleId) missing.push('VAULT_APPROLE_ROLE_ID'); + if (!config.appRoleSecretId) missing.push('VAULT_APPROLE_SECRET_ID'); + } + + if (missing.length > 0) { + throw new Error(`Vault HSM signer is misconfigured; missing: ${missing.join(', ')}`); + } + + if (process.env.NODE_ENV === 'production' && !config.address.startsWith('https://')) { + throw new Error('VAULT_ADDR must use https:// in production'); + } +} + +export interface VaultResponse { + request_id: string; + lease_id?: string; + renewable?: boolean; + lease_duration?: number; + data: TData; + auth?: { + client_token: string; + lease_duration: number; + renewable: boolean; + } | null; + warnings?: string[] | null; +} + +/** + * Minimal Vault HTTP client built on the global `fetch` (Node 18+), so no + * additional dependency is required. It handles login, token caching with + * early renewal, and request timeouts. + */ +export class VaultClient { + private cachedToken: string | null = null; + private tokenExpiresAt = 0; + + constructor( + private readonly config: VaultConfig, + private readonly fetchImpl: typeof fetch = globalThis.fetch, + ) { + if (!this.fetchImpl) { + throw new Error('Global fetch is unavailable; Node.js 18+ is required for the Vault signer'); + } + } + + public getConfig(): Readonly { + return this.config; + } + + public async request( + method: 'GET' | 'POST', + path: string, + body?: unknown, + options: { authenticated?: boolean } = {}, + ): Promise> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.config.namespace) headers['X-Vault-Namespace'] = this.config.namespace; + if (options.authenticated !== false) headers['X-Vault-Token'] = await this.getToken(); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); + + try { + const response = await this.fetchImpl(`${this.config.address}/v1/${path.replace(/^\/+/, '')}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + + const text = await response.text(); + const json = text ? (JSON.parse(text) as VaultResponse & { errors?: string[] }) : null; + + if (!response.ok || !json) { + if (response.status === 403) this.invalidateToken(); + const detail = json?.errors?.join('; ') || response.statusText || 'empty response'; + throw new Error(`Vault ${method} ${path} failed (${response.status}): ${detail}`); + } + + return json; + } catch (error) { + if ((error as Error).name === 'AbortError') { + throw new Error(`Vault ${method} ${path} timed out after ${this.config.requestTimeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + public invalidateToken(): void { + this.cachedToken = null; + this.tokenExpiresAt = 0; + } + + private async getToken(): Promise { + if (this.config.authMethod === 'token') { + if (!this.config.token) throw new Error('VAULT_TOKEN is not configured'); + return this.config.token; + } + + // Renew 30s before expiry so an in-flight sign never races token expiry. + if (this.cachedToken && Date.now() < this.tokenExpiresAt - 30_000) { + return this.cachedToken; + } + + const login = await this.login(); + if (!login.auth?.client_token) { + throw new Error(`Vault ${this.config.authMethod} login did not return a client token`); + } + + this.cachedToken = login.auth.client_token; + this.tokenExpiresAt = Date.now() + Math.max(login.auth.lease_duration, 60) * 1000; + return this.cachedToken; + } + + private async login(): Promise> { + if (this.config.authMethod === 'kubernetes') { + const jwt = (await readFile(this.config.kubernetesJwtPath, 'utf8')).trim(); + return this.request( + 'POST', + `auth/${this.config.kubernetesMountPath}/login`, + { role: this.config.kubernetesRole, jwt }, + { authenticated: false }, + ); + } + + return this.request( + 'POST', + `auth/${this.config.appRoleMountPath}/login`, + { role_id: this.config.appRoleId, secret_id: this.config.appRoleSecretId }, + { authenticated: false }, + ); + } +} + +/** + * Vault policy granting the relayer only sign + read-public-key on its + * transit key. Export, backup, and key-config endpoints stay denied, so the + * private key can never leave the Vault/HSM boundary. + */ +export const getVaultRelayerPolicy = (transitMount = 'transit', keyName = 'stellar-relayer'): string => + [ + `path "${transitMount}/sign/${keyName}" {`, + ' capabilities = ["update"]', + '}', + `path "${transitMount}/sign/${keyName}/*" {`, + ' capabilities = ["update"]', + '}', + `path "${transitMount}/keys/${keyName}" {`, + ' capabilities = ["read"]', + '}', + `path "${transitMount}/export/*" {`, + ' capabilities = ["deny"]', + '}', + `path "${transitMount}/backup/*" {`, + ' capabilities = ["deny"]', + '}', + ].join('\n'); diff --git a/backend/src/services/kmsSignerService.ts b/backend/src/services/kmsSignerService.ts new file mode 100644 index 000000000..3788c4039 --- /dev/null +++ b/backend/src/services/kmsSignerService.ts @@ -0,0 +1,446 @@ +import { + GetPublicKeyCommand, + KMSClient, + MessageType, + SignCommand, + SigningAlgorithmSpec, +} from '@aws-sdk/client-kms'; +import { Keypair, StrKey } from '@stellar/stellar-sdk'; +import { createHash, randomUUID } from 'crypto'; +import { VaultClient, assertVaultConfig, loadVaultConfig, type VaultConfig } from '../config/vault.js'; +import logger, { auditLogger, getTraceId } from '../utils/logger.js'; + +/** + * HSM custodial relayer signing engine (BE-002 / #1574). + * + * Every Ed25519 signature for a Stellar envelope is produced inside a + * hardware/secure boundary — AWS KMS (`ECC_NIST_EDWARDS25519` key) or a + * HashiCorp Vault Transit Ed25519 key. The application only sends the 32-byte + * transaction hash and receives the detached 64-byte signature; the private + * key never enters process memory. + * + * Every signing attempt (success or failure) emits a hash-chained audit + * record carrying the provider's request id (the IAM/CloudTrail request id for + * KMS, the Vault `request_id` for Vault) so a signature can be traced back to + * the authorising principal. + * + * A software signer exists strictly for local development and tests and is + * refused outright when `NODE_ENV=production`. + */ + +export type HsmProviderName = 'aws-kms' | 'vault-transit' | 'software-dev'; + +/** Stellar signatures are Ed25519: exactly 64 bytes. */ +export const ED25519_SIGNATURE_BYTES = 64; +const ED25519_PUBLIC_KEY_BYTES = 32; +/** DER SubjectPublicKeyInfo prefix for an Ed25519 key (RFC 8410). */ +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +export interface HsmSignResult { + signature: Buffer; + /** Provider request id: AWS IAM/CloudTrail request id, Vault request_id, or a local id. */ + requestId: string; + /** Identifier of the key that produced the signature (ARN/alias, transit key + version). */ + keyRef: string; +} + +export interface HsmSigner { + readonly provider: HsmProviderName; + /** Stellar `G...` address of the custodial key. */ + getPublicKey(): Promise; + /** Produce a raw Ed25519 signature over `payload` inside the secure boundary. */ + sign(payload: Buffer): Promise; +} + +// ─── AWS KMS ───────────────────────────────────────────────────────────────── + +export interface AwsKmsHsmSignerConfig { + keyId: string; + region?: string; + /** Optional pinned public key; otherwise fetched once via kms:GetPublicKey. */ + publicKey?: string; + client?: Pick; +} + +export class AwsKmsHsmSigner implements HsmSigner { + public readonly provider = 'aws-kms' as const; + private readonly client: Pick; + private readonly keyId: string; + private publicKey?: string; + + constructor(config: AwsKmsHsmSignerConfig) { + if (!config.keyId) throw new Error('AWS KMS signer requires a key id (AWS_KMS_KEY_ID)'); + this.keyId = config.keyId; + this.publicKey = config.publicKey; + this.client = config.client ?? new KMSClient({ region: config.region || 'us-east-1' }); + } + + public async getPublicKey(): Promise { + if (this.publicKey) return this.publicKey; + + const response = await this.client.send(new GetPublicKeyCommand({ KeyId: this.keyId })); + if (!response.PublicKey) { + throw new Error('KMS GetPublicKey response did not contain public key bytes'); + } + + this.publicKey = StrKey.encodeEd25519PublicKey(extractEd25519FromSpki(Buffer.from(response.PublicKey))); + return this.publicKey; + } + + public async sign(payload: Buffer): Promise { + const response = await this.client.send( + new SignCommand({ + KeyId: this.keyId, + Message: payload, + // RAW + ED25519_SHA_512 is pure Ed25519 (RFC 8032), which is what Stellar expects. + MessageType: MessageType.RAW, + SigningAlgorithm: SigningAlgorithmSpec.ED25519_SHA_512, + }), + ); + + if (!response.Signature) { + throw new Error('KMS Sign response did not contain signature bytes'); + } + + return { + signature: Buffer.from(response.Signature), + requestId: response.$metadata?.requestId || 'unknown', + keyRef: response.KeyId || this.keyId, + }; + } +} + +/** Strip the RFC 8410 SPKI wrapper from a DER-encoded Ed25519 public key. */ +export function extractEd25519FromSpki(der: Buffer): Buffer { + if (der.length === ED25519_PUBLIC_KEY_BYTES) return der; + if ( + der.length !== ED25519_SPKI_PREFIX.length + ED25519_PUBLIC_KEY_BYTES || + !der.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + throw new Error('KMS key is not an Ed25519 key; use key spec ECC_NIST_EDWARDS25519'); + } + return der.subarray(ED25519_SPKI_PREFIX.length); +} + +// ─── HashiCorp Vault Transit ───────────────────────────────────────────────── + +interface VaultTransitKeyData { + type: string; + latest_version: number; + keys: Record; +} + +export class VaultTransitHsmSigner implements HsmSigner { + public readonly provider = 'vault-transit' as const; + private readonly client: VaultClient; + private readonly config: VaultConfig; + private publicKey?: string; + private resolvedVersion?: number; + + constructor(config: VaultConfig = loadVaultConfig(), client?: VaultClient) { + assertVaultConfig(config); + this.config = config; + this.client = client ?? new VaultClient(config); + } + + public async getPublicKey(): Promise { + if (this.publicKey) return this.publicKey; + + const { transitMountPath, transitKeyName, transitKeyVersion } = this.config; + const response = await this.client.request( + 'GET', + `${transitMountPath}/keys/${encodeURIComponent(transitKeyName)}`, + ); + + if (response.data.type !== 'ed25519') { + throw new Error(`Vault transit key "${transitKeyName}" is ${response.data.type}; Stellar requires ed25519`); + } + + const version = transitKeyVersion ?? response.data.latest_version; + const entry = response.data.keys[String(version)]; + const rawPublicKey = typeof entry === 'object' ? entry.public_key : undefined; + if (!rawPublicKey) { + throw new Error(`Vault transit key "${transitKeyName}" v${version} has no public key`); + } + + this.resolvedVersion = version; + this.publicKey = StrKey.encodeEd25519PublicKey(Buffer.from(rawPublicKey, 'base64')); + return this.publicKey; + } + + public async sign(payload: Buffer): Promise { + const { transitMountPath, transitKeyName } = this.config; + // Resolve the version the public key was derived from so signature and key always match. + await this.getPublicKey(); + + const response = await this.client.request<{ signature: string; key_version?: number }>( + 'POST', + `${transitMountPath}/sign/${encodeURIComponent(transitKeyName)}`, + { input: payload.toString('base64'), key_version: this.resolvedVersion }, + ); + + const match = /^vault:v(\d+):(.+)$/.exec(response.data.signature || ''); + if (!match?.[2]) { + throw new Error('Vault transit sign response had an unexpected signature format'); + } + + return { + signature: Buffer.from(match[2], 'base64'), + requestId: response.request_id, + keyRef: `vault:${transitMountPath}/${transitKeyName}@v${match[1]}`, + }; + } +} + +// ─── Development software signer ───────────────────────────────────────────── + +const DEV_ENVIRONMENTS = new Set(['development', 'test']); + +/** True only for local development/testing; the software signer is refused everywhere else. */ +export function isSoftwareSignerPermitted(env: NodeJS.ProcessEnv = process.env): boolean { + return DEV_ENVIRONMENTS.has(env.NODE_ENV ?? '') && env.HSM_ALLOW_SOFTWARE_SIGNER !== 'false'; +} + +export class SoftwareDevSigner implements HsmSigner { + public readonly provider = 'software-dev' as const; + private readonly keypair: Keypair; + + constructor(secretKey?: string) { + if (!isSoftwareSignerPermitted()) { + throw new Error( + `Software signer is restricted to NODE_ENV=development|test (current: ${process.env.NODE_ENV ?? 'unset'})`, + ); + } + const secret = secretKey ?? process.env.HSM_DEV_SIGNER_SECRET; + this.keypair = secret ? Keypair.fromSecret(secret) : Keypair.random(); + logger.warn('HSM signer: using in-memory software key — local development/testing only'); + } + + public async getPublicKey(): Promise { + return this.keypair.publicKey(); + } + + public async sign(payload: Buffer): Promise { + return { + signature: this.keypair.sign(payload), + requestId: `local-${randomUUID()}`, + keyRef: 'software-dev', + }; + } +} + +// ─── Audit trail ───────────────────────────────────────────────────────────── + +export interface SignatureAuditRecord { + event: 'hsm.sign'; + outcome: 'success' | 'failure'; + provider: HsmProviderName; + keyRef: string | null; + publicKey: string | null; + /** IAM / Vault / local request id for the signing call. */ + iamRequestId: string | null; + purpose: string; + /** SHA-256 of the signed payload — never the payload itself. */ + payloadSha256: string; + signatureSha256: string | null; + durationMs: number; + traceId: string | null; + error: string | null; + timestamp: string; + /** Hash of the previous record, making the log tamper-evident. */ + prevHash: string; + /** SHA-256 over this record (including prevHash). */ + hash: string; +} + +export type SignatureAuditSink = (record: SignatureAuditRecord) => void | Promise; + +const GENESIS_HASH = '0'.repeat(64); +const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); + +// ─── Service ───────────────────────────────────────────────────────────────── + +export interface SignContext { + /** Free-form label for the audit trail, e.g. 'relayer-fee-bump', 'faucet'. */ + purpose?: string; +} + +export interface SignedHashResult extends HsmSignResult { + publicKey: string; + provider: HsmProviderName; +} + +/** Structural shape of a Stellar `Transaction` / `FeeBumpTransaction`. */ +export interface HsmSignableTransaction { + hash(): Buffer; + addSignature(publicKey: string, signature: string): void; +} + +export interface KmsSignerServiceOptions { + signer?: HsmSigner; + /** Additional audit destinations (DB, SIEM). The append-only audit file is always written. */ + auditSinks?: SignatureAuditSink[]; +} + +export class KmsSignerService { + private readonly signer: HsmSigner; + private readonly auditSinks: SignatureAuditSink[]; + private lastAuditHash = GENESIS_HASH; + + constructor(options: KmsSignerServiceOptions = {}) { + this.signer = options.signer ?? createHsmSignerFromEnv(); + this.auditSinks = options.auditSinks ?? []; + } + + public get provider(): HsmProviderName { + return this.signer.provider; + } + + public isHardwareBacked(): boolean { + return this.signer.provider !== 'software-dev'; + } + + public getPublicKey(): Promise { + return this.signer.getPublicKey(); + } + + /** + * Sign a payload (a Stellar transaction hash) inside the HSM boundary, + * verify the result against the custodial public key, and audit it. + */ + public async signHash(payload: Buffer, context: SignContext = {}): Promise { + const purpose = context.purpose ?? 'transaction'; + const startedAt = Date.now(); + let publicKey: string | null = null; + let result: HsmSignResult | null = null; + + try { + publicKey = await this.signer.getPublicKey(); + result = await this.signer.sign(payload); + + if (result.signature.length !== ED25519_SIGNATURE_BYTES) { + throw new Error( + `${this.signer.provider} returned a ${result.signature.length}-byte signature; ` + + `Stellar requires ${ED25519_SIGNATURE_BYTES}-byte Ed25519 signatures`, + ); + } + // Defends against a misrouted key id or a compromised/misbehaving backend. + if (!Keypair.fromPublicKey(publicKey).verify(payload, result.signature)) { + throw new Error(`${this.signer.provider} signature failed verification against ${publicKey}`); + } + + await this.audit('success', purpose, payload, publicKey, result, startedAt, null); + return { ...result, publicKey, provider: this.signer.provider }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.audit('failure', purpose, payload, publicKey, result, startedAt, extractRequestId(error, message)); + logger.error(`HSM signing failed via ${this.signer.provider}: ${message}`); + throw new Error(`HSM transaction signing failed (${this.signer.provider}): ${message}`); + } + } + + /** Sign a Stellar transaction or fee-bump envelope in place. */ + public async signTransaction( + transaction: T, + context: SignContext = {}, + ): Promise { + const { signature, publicKey } = await this.signHash(transaction.hash(), context); + transaction.addSignature(publicKey, signature.toString('base64')); + return transaction; + } + + private async audit( + outcome: SignatureAuditRecord['outcome'], + purpose: string, + payload: Buffer, + publicKey: string | null, + result: HsmSignResult | null, + startedAt: number, + failure: { message: string; requestId: string | null } | null, + ): Promise { + const body: Omit = { + event: 'hsm.sign', + outcome, + provider: this.signer.provider, + keyRef: result?.keyRef ?? null, + publicKey, + iamRequestId: result?.requestId ?? failure?.requestId ?? null, + purpose, + payloadSha256: sha256(payload), + signatureSha256: result ? sha256(result.signature) : null, + durationMs: Date.now() - startedAt, + traceId: getTraceId() ?? null, + error: failure?.message ?? null, + timestamp: new Date().toISOString(), + prevHash: this.lastAuditHash, + }; + const record: SignatureAuditRecord = { ...body, hash: sha256(JSON.stringify(body)) }; + this.lastAuditHash = record.hash; + + auditLogger.info('HSM signature operation', record); + + for (const sink of this.auditSinks) { + try { + await sink(record); + } catch (error) { + // An audit sink outage must not mask the signing outcome; the file log above already holds it. + logger.error(`HSM audit sink failed: ${(error as Error).message}`); + } + } + } +} + +/** Pull the AWS request id off an SDK error (`$metadata.requestId`) if present. */ +function extractRequestId(error: unknown, message: string): { message: string; requestId: string | null } { + const metadata = (error as { $metadata?: { requestId?: string } } | null)?.$metadata; + return { message, requestId: metadata?.requestId ?? null }; +} + +// ─── Factory ───────────────────────────────────────────────────────────────── + +/** + * Select the HSM backend from `HSM_SIGNER_PROVIDER` (`aws-kms` | `vault-transit` + * | `software-dev`). When unset, AWS KMS is used if `AWS_KMS_KEY_ID` is set, + * then Vault if `VAULT_ADDR` is set, and the software signer only in + * development/test. Production without a configured HSM fails closed. + */ +export function createHsmSignerFromEnv(env: NodeJS.ProcessEnv = process.env): HsmSigner { + const requested = env.HSM_SIGNER_PROVIDER as HsmProviderName | undefined; + + if (requested === 'aws-kms' || (!requested && env.AWS_KMS_KEY_ID)) { + return new AwsKmsHsmSigner({ + keyId: env.AWS_KMS_KEY_ID || '', + region: env.AWS_REGION, + publicKey: env.PLATFORM_MASTER_PUBLIC_KEY || undefined, + }); + } + + if (requested === 'vault-transit' || (!requested && env.VAULT_ADDR)) { + return new VaultTransitHsmSigner(loadVaultConfig(env)); + } + + if (requested === 'software-dev' || !requested) { + if (!isSoftwareSignerPermitted(env)) { + throw new Error( + 'No HSM signer configured. Set HSM_SIGNER_PROVIDER=aws-kms (AWS_KMS_KEY_ID) or ' + + 'HSM_SIGNER_PROVIDER=vault-transit (VAULT_ADDR, VAULT_TRANSIT_KEY); the software signer is ' + + 'only available when NODE_ENV is development or test.', + ); + } + return new SoftwareDevSigner(); + } + + throw new Error(`Unknown HSM_SIGNER_PROVIDER "${requested}"`); +} + +let instance: KmsSignerService | null = null; + +/** Application-wide signer, constructed lazily so imports never force HSM configuration. */ +export function getKmsSignerService(options?: KmsSignerServiceOptions): KmsSignerService { + if (!instance) instance = new KmsSignerService(options); + return instance; +} + +export function resetKmsSignerService(): void { + instance = null; +} diff --git a/backend/src/services/stellarService.ts b/backend/src/services/stellarService.ts new file mode 100644 index 000000000..62cd4fe90 --- /dev/null +++ b/backend/src/services/stellarService.ts @@ -0,0 +1,164 @@ +import { + FeeBumpTransaction, + Horizon, + Networks, + Transaction, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import logger from '../utils/logger.js'; +import { + getKmsSignerService, + type HsmProviderName, + type KmsSignerService, +} from './kmsSignerService.js'; + +/** + * Custodial relayer Stellar service (BE-002 / #1574). + * + * Parses, fee-bumps, signs and submits Stellar transaction envelopes on behalf + * of the platform relayer. It never holds a secret key: every signature is + * delegated to {@link KmsSignerService}, which signs inside AWS KMS or + * HashiCorp Vault and writes an audit record per operation. + */ + +export interface SignedEnvelope { + /** Base64 XDR of the signed envelope. */ + xdr: string; + /** Hex transaction hash that was signed. */ + hash: string; + signerPublicKey: string; + provider: HsmProviderName; + /** Provider request id recorded in the signature audit log. */ + requestId: string; +} + +export interface RelayerStellarServiceOptions { + signer?: KmsSignerService; + horizonUrl?: string; + networkPassphrase?: string; + /** Upper bound on the fee-bump base fee, in stroops. */ + maxFeeStroops?: number; +} + +export function resolveNetworkPassphrase(network = process.env.STELLAR_NETWORK || 'testnet'): string { + switch (network.toLowerCase()) { + case 'mainnet': + case 'public': + return Networks.PUBLIC; + case 'futurenet': + return Networks.FUTURENET; + default: + return Networks.TESTNET; + } +} + +export class RelayerStellarService { + private readonly signer: KmsSignerService; + private readonly networkPassphrase: string; + private readonly horizonUrl: string; + private readonly maxFeeStroops: number; + private server: Horizon.Server | null = null; + + constructor(options: RelayerStellarServiceOptions = {}) { + this.signer = options.signer ?? getKmsSignerService(); + this.networkPassphrase = options.networkPassphrase ?? resolveNetworkPassphrase(); + this.horizonUrl = + options.horizonUrl ?? process.env.STELLAR_HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + this.maxFeeStroops = + options.maxFeeStroops ?? Number.parseInt(process.env.RELAYER_MAX_FEE_STROOPS || '10000000', 10); + } + + public getRelayerPublicKey(): Promise { + return this.signer.getPublicKey(); + } + + public getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + /** Decode an envelope, refusing anything bound to a different network. */ + public parseEnvelope(envelopeXdr: string): Transaction | FeeBumpTransaction { + try { + return TransactionBuilder.fromXDR(envelopeXdr, this.networkPassphrase); + } catch { + throw new Error('Invalid Stellar transaction envelope XDR'); + } + } + + /** Sign an envelope with the custodial relayer key inside the HSM boundary. */ + public async signEnvelope(envelopeXdr: string, purpose = 'relayer-envelope'): Promise { + return this.sign(this.parseEnvelope(envelopeXdr), purpose); + } + + /** + * Wrap a user-signed inner transaction in a fee-bump paid by the relayer + * account and sign the outer envelope via the HSM. + */ + public async sponsorWithFeeBump( + innerEnvelopeXdr: string, + baseFeeStroops = this.maxFeeStroops, + ): Promise { + const inner = this.parseEnvelope(innerEnvelopeXdr); + if (inner instanceof FeeBumpTransaction) { + throw new Error('Cannot wrap an existing fee-bump transaction'); + } + if (inner.signatures.length === 0) { + throw new Error('Inner transaction must be signed by its source before sponsorship'); + } + if (!Number.isFinite(baseFeeStroops) || baseFeeStroops <= 0 || baseFeeStroops > this.maxFeeStroops) { + throw new Error(`Fee-bump base fee must be between 1 and ${this.maxFeeStroops} stroops`); + } + + const feeBump = TransactionBuilder.buildFeeBumpTransaction( + await this.signer.getPublicKey(), + String(baseFeeStroops), + inner, + this.networkPassphrase, + ); + + return this.sign(feeBump, 'relayer-fee-bump'); + } + + /** Submit a signed envelope to Horizon. */ + public async submit(signedEnvelopeXdr: string): Promise { + const transaction = this.parseEnvelope(signedEnvelopeXdr); + if (transaction.signatures.length === 0) { + throw new Error('Refusing to submit an unsigned envelope'); + } + return this.getServer().submitTransaction(transaction); + } + + private async sign(transaction: Transaction | FeeBumpTransaction, purpose: string): Promise { + const hash = transaction.hash(); + const result = await this.signer.signHash(hash, { purpose }); + transaction.addSignature(result.publicKey, result.signature.toString('base64')); + + logger.info( + `Relayer envelope signed via ${result.provider} (purpose=${purpose}, requestId=${result.requestId})`, + ); + + return { + xdr: transaction.toXDR(), + hash: hash.toString('hex'), + signerPublicKey: result.publicKey, + provider: result.provider, + requestId: result.requestId, + }; + } + + private getServer(): Horizon.Server { + if (!this.server) this.server = new Horizon.Server(this.horizonUrl); + return this.server; + } +} + +let instance: RelayerStellarService | null = null; + +export function getRelayerStellarService(options?: RelayerStellarServiceOptions): RelayerStellarService { + if (!instance) instance = new RelayerStellarService(options); + return instance; +} + +export function resetRelayerStellarService(): void { + instance = null; +} diff --git a/backend/tests/kmsSignerService.test.ts b/backend/tests/kmsSignerService.test.ts new file mode 100644 index 000000000..5053a1c27 --- /dev/null +++ b/backend/tests/kmsSignerService.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +jest.mock('../src/utils/logger.js', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + auditLogger: { + info: jest.fn(), + }, + getTraceId: () => 'trace-test', +})); + +import { GetPublicKeyCommand, SignCommand } from '@aws-sdk/client-kms'; +import { Account, Keypair, Networks, Operation, Asset, TransactionBuilder } from '@stellar/stellar-sdk'; +import { VaultClient, loadVaultConfig } from '../src/config/vault.js'; +import { + AwsKmsHsmSigner, + KmsSignerService, + SoftwareDevSigner, + VaultTransitHsmSigner, + createHsmSignerFromEnv, + extractEd25519FromSpki, + isSoftwareSignerPermitted, + type HsmSigner, + type SignatureAuditRecord, +} from '../src/services/kmsSignerService.js'; +import { RelayerStellarService } from '../src/services/stellarService.js'; + +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +/** Stand-in for the KMS HSM: holds the key "inside" the fake and returns only signatures. */ +function createFakeKmsClient(keypair: Keypair, requestId = 'iam-req-123') { + const send = jest.fn(async (command: unknown) => { + if (command instanceof GetPublicKeyCommand) { + return { PublicKey: new Uint8Array(Buffer.concat([ED25519_SPKI_PREFIX, keypair.rawPublicKey()])) }; + } + if (command instanceof SignCommand) { + const message = Buffer.from(command.input.Message as Uint8Array); + return { + Signature: new Uint8Array(keypair.sign(message)), + KeyId: 'arn:aws:kms:us-east-1:111122223333:key/relayer', + $metadata: { requestId }, + }; + } + throw new Error('unexpected command'); + }); + return { send } as unknown as { send: jest.Mock }; +} + +function createVaultFetch(keypair: Keypair) { + return jest.fn(async (url: string, init?: { method?: string; body?: string }) => { + let payload: unknown; + if (url.endsWith('/v1/transit/keys/stellar-relayer')) { + payload = { + request_id: 'vault-read-1', + data: { + type: 'ed25519', + latest_version: 2, + keys: { '2': { public_key: keypair.rawPublicKey().toString('base64') } }, + }, + }; + } else if (url.endsWith('/v1/transit/sign/stellar-relayer') && init?.method === 'POST') { + const { input } = JSON.parse(init.body as string) as { input: string }; + const signature = keypair.sign(Buffer.from(input, 'base64')).toString('base64'); + payload = { request_id: 'vault-sign-42', data: { signature: `vault:v2:${signature}` } }; + } else { + return { ok: false, status: 404, statusText: 'Not Found', text: async () => '{"errors":["not found"]}' }; + } + return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(payload) }; + }); +} + +describe('extractEd25519FromSpki', () => { + it('strips the RFC 8410 prefix', () => { + const raw = Keypair.random().rawPublicKey(); + expect(extractEd25519FromSpki(Buffer.concat([ED25519_SPKI_PREFIX, raw])).equals(raw)).toBe(true); + }); + + it('rejects non-Ed25519 keys', () => { + expect(() => extractEd25519FromSpki(Buffer.alloc(91))).toThrow(/ECC_NIST_EDWARDS25519/); + }); +}); + +describe('AwsKmsHsmSigner', () => { + it('signs via KMS with pure Ed25519 and returns the IAM request id', async () => { + const keypair = Keypair.random(); + const client = createFakeKmsClient(keypair); + const signer = new AwsKmsHsmSigner({ keyId: 'alias/relayer', client: client as never }); + const payload = Buffer.alloc(32, 7); + + await expect(signer.getPublicKey()).resolves.toBe(keypair.publicKey()); + const result = await signer.sign(payload); + + expect(result.requestId).toBe('iam-req-123'); + expect(keypair.verify(payload, result.signature)).toBe(true); + const signCommand = client.send.mock.calls[1]?.[0] as SignCommand; + expect(signCommand.input).toMatchObject({ + KeyId: 'alias/relayer', + MessageType: 'RAW', + SigningAlgorithm: 'ED25519_SHA_512', + }); + }); +}); + +describe('VaultTransitHsmSigner', () => { + it('signs through the transit engine and strips the vault:vN: prefix', async () => { + const keypair = Keypair.random(); + const config = loadVaultConfig({ VAULT_ADDR: 'http://vault.local:8200', VAULT_TOKEN: 'root' } as NodeJS.ProcessEnv); + const signer = new VaultTransitHsmSigner(config, new VaultClient(config, createVaultFetch(keypair) as never)); + const payload = Buffer.alloc(32, 9); + + const result = await signer.sign(payload); + + expect(result.requestId).toBe('vault-sign-42'); + expect(result.keyRef).toBe('vault:transit/stellar-relayer@v2'); + expect(keypair.verify(payload, result.signature)).toBe(true); + }); + + it('refuses to start without credentials', () => { + const config = loadVaultConfig({ VAULT_ADDR: 'http://vault.local:8200' } as NodeJS.ProcessEnv); + expect(() => new VaultTransitHsmSigner(config)).toThrow(/VAULT_TOKEN/); + }); +}); + +describe('software signer gating', () => { + it('is only permitted in development and test', () => { + expect(isSoftwareSignerPermitted({ NODE_ENV: 'test' } as NodeJS.ProcessEnv)).toBe(true); + expect(isSoftwareSignerPermitted({ NODE_ENV: 'development' } as NodeJS.ProcessEnv)).toBe(true); + expect(isSoftwareSignerPermitted({ NODE_ENV: 'production' } as NodeJS.ProcessEnv)).toBe(false); + expect(isSoftwareSignerPermitted({ NODE_ENV: 'staging' } as NodeJS.ProcessEnv)).toBe(false); + expect( + isSoftwareSignerPermitted({ NODE_ENV: 'test', HSM_ALLOW_SOFTWARE_SIGNER: 'false' } as NodeJS.ProcessEnv), + ).toBe(false); + }); + + it('fails closed in production when no HSM is configured', () => { + expect(() => createHsmSignerFromEnv({ NODE_ENV: 'production' } as NodeJS.ProcessEnv)).toThrow( + /No HSM signer configured/, + ); + process.env.NODE_ENV = 'production'; + expect(() => new SoftwareDevSigner()).toThrow(/restricted/); + }); + + it('selects the provider from the environment', () => { + expect( + createHsmSignerFromEnv({ NODE_ENV: 'production', AWS_KMS_KEY_ID: 'alias/x' } as NodeJS.ProcessEnv).provider, + ).toBe('aws-kms'); + expect( + createHsmSignerFromEnv({ + NODE_ENV: 'production', + VAULT_ADDR: 'https://vault.internal', + VAULT_TOKEN: 't', + } as NodeJS.ProcessEnv).provider, + ).toBe('vault-transit'); + expect(createHsmSignerFromEnv({ NODE_ENV: 'test' } as NodeJS.ProcessEnv).provider).toBe('software-dev'); + }); +}); + +describe('KmsSignerService', () => { + let records: SignatureAuditRecord[]; + + beforeEach(() => { + records = []; + }); + + it('emits a hash-chained audit record with the IAM request id for every signature', async () => { + const keypair = Keypair.random(); + const service = new KmsSignerService({ + signer: new AwsKmsHsmSigner({ keyId: 'alias/relayer', client: createFakeKmsClient(keypair) as never }), + auditSinks: [(record) => void records.push(record)], + }); + + await service.signHash(Buffer.alloc(32, 1), { purpose: 'faucet' }); + await service.signHash(Buffer.alloc(32, 2)); + + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ + event: 'hsm.sign', + outcome: 'success', + provider: 'aws-kms', + iamRequestId: 'iam-req-123', + purpose: 'faucet', + publicKey: keypair.publicKey(), + traceId: 'trace-test', + }); + expect(records[0]?.prevHash).toBe('0'.repeat(64)); + expect(records[1]?.prevHash).toBe(records[0]?.hash); + // Only digests are logged, never payload or signature bytes. + expect(JSON.stringify(records)).not.toContain(Buffer.alloc(32, 1).toString('hex')); + }); + + it('rejects a signature that does not verify against the custodial key, and audits the failure', async () => { + const custodial = Keypair.random(); + const rogue = Keypair.random(); + const signer: HsmSigner = { + provider: 'aws-kms', + getPublicKey: async () => custodial.publicKey(), + sign: async (payload) => ({ signature: rogue.sign(payload), requestId: 'iam-req-bad', keyRef: 'k' }), + }; + const service = new KmsSignerService({ signer, auditSinks: [(record) => void records.push(record)] }); + + await expect(service.signHash(Buffer.alloc(32, 3))).rejects.toThrow(/failed verification/); + expect(records[0]).toMatchObject({ outcome: 'failure', iamRequestId: 'iam-req-bad' }); + }); + + it('records the AWS request id from SDK errors', async () => { + const signer: HsmSigner = { + provider: 'aws-kms', + getPublicKey: async () => Keypair.random().publicKey(), + sign: async () => { + throw Object.assign(new Error('AccessDeniedException'), { $metadata: { requestId: 'iam-req-denied' } }); + }, + }; + const service = new KmsSignerService({ signer, auditSinks: [(record) => void records.push(record)] }); + + await expect(service.signHash(Buffer.alloc(32))).rejects.toThrow(/AccessDeniedException/); + expect(records[0]).toMatchObject({ outcome: 'failure', iamRequestId: 'iam-req-denied' }); + }); +}); + +describe('RelayerStellarService', () => { + it('wraps a user transaction in a fee-bump signed by the HSM relayer key', async () => { + const relayer = Keypair.random(); + const user = Keypair.random(); + const service = new RelayerStellarService({ + signer: new KmsSignerService({ + signer: new AwsKmsHsmSigner({ keyId: 'alias/relayer', client: createFakeKmsClient(relayer) as never }), + }), + networkPassphrase: Networks.TESTNET, + maxFeeStroops: 1000, + }); + + const inner = new TransactionBuilder(new Account(user.publicKey(), '1'), { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.payment({ destination: relayer.publicKey(), asset: Asset.native(), amount: '1' })) + .setTimeout(30) + .build(); + inner.sign(user); + + const signed = await service.sponsorWithFeeBump(inner.toXDR(), 500); + const feeBump = TransactionBuilder.fromXDR(signed.xdr, Networks.TESTNET); + + expect(signed.provider).toBe('aws-kms'); + expect(signed.requestId).toBe('iam-req-123'); + expect(signed.signerPublicKey).toBe(relayer.publicKey()); + expect(relayer.verify(feeBump.hash(), feeBump.signatures[0]!.signature())).toBe(true); + }); + + it('refuses unsigned inner transactions and fees above the cap', async () => { + const service = new RelayerStellarService({ + signer: new KmsSignerService({ signer: new SoftwareDevSigner() }), + networkPassphrase: Networks.TESTNET, + maxFeeStroops: 1000, + }); + const user = Keypair.random(); + const inner = new TransactionBuilder(new Account(user.publicKey(), '1'), { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.bumpSequence({ bumpTo: '10' })) + .setTimeout(30) + .build(); + + await expect(service.sponsorWithFeeBump(inner.toXDR(), 500)).rejects.toThrow(/must be signed/); + inner.sign(user); + await expect(service.sponsorWithFeeBump(inner.toXDR(), 5000)).rejects.toThrow(/between 1 and 1000/); + }); +}); diff --git a/frontend/next.config.ts b/frontend/next.config.ts index b4d525c0a..c48f7ba1a 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -24,39 +24,85 @@ const nextConfig: NextConfig = { }; } - if (!config.optimization.splitChunks) { - config.optimization.splitChunks = {}; - } - config.optimization.splitChunks = { - ...config.optimization.splitChunks, - chunks: 'all', - cacheGroups: { - vendor: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - chunks: 'all', - priority: 10, - }, - stellar: { - test: /[\\/]node_modules[\\/]@stellar[\\/]/, - name: 'stellar', - chunks: 'all', - priority: 20, - }, - d3: { - test: /[\\/]node_modules[\\/]d3[\\/]/, - name: 'd3', - chunks: 'all', - priority: 20, - }, - common: { - name: 'common', - minChunks: 2, - chunks: 'all', - priority: 5, + // Client-only: server bundles are not shipped to the browser (FE-050). + if (!isServer) { + if (!config.optimization.splitChunks) { + config.optimization.splitChunks = {}; + } + config.optimization.splitChunks = { + ...config.optimization.splitChunks, + chunks: 'all', + maxInitialRequests: 25, + minSize: 20000, + cacheGroups: { + // Heavy editor/canvas/chart/crypto libraries are only reachable through + // next/dynamic boundaries. `chunks: 'async'` + top priority guarantees + // they never get hoisted into an initial (render-blocking) chunk. + monaco: { + test: /[\\/]node_modules[\\/](monaco-editor|@monaco-editor|monaco-languageclient|y-monaco|vscode-[\w-]+)[\\/]/, + name: 'async-monaco', + chunks: 'async', + priority: 40, + enforce: true, + }, + canvas: { + test: /[\\/]node_modules[\\/](tldraw|@tldraw|reactflow|@reactflow|html2canvas|jspdf)[\\/]/, + name: 'async-canvas', + chunks: 'async', + priority: 40, + enforce: true, + }, + charts: { + test: /[\\/]node_modules[\\/](chart\.js|react-chartjs-2|recharts|d3-[\w-]+)[\\/]/, + name: 'async-charts', + chunks: 'async', + priority: 40, + }, + wasm: { + test: /[\\/]node_modules[\\/](wabt|ethers|@ethersproject)[\\/]/, + name: 'async-wasm-evm', + chunks: 'async', + priority: 40, + enforce: true, + }, + stellar: { + test: /[\\/]node_modules[\\/]@stellar[\\/]/, + name: 'stellar', + chunks: 'all', + priority: 20, + }, + d3: { + test: /[\\/]node_modules[\\/]d3[\\/]/, + name: 'd3', + chunks: 'all', + priority: 20, + }, + // React runtime shared by every route — one long-lived cacheable chunk. + framework: { + test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, + name: 'framework', + chunks: 'all', + priority: 30, + enforce: true, + }, + // Remaining vendor code for first paint. No fixed `name`, so webpack + // emits one chunk per set of routes sharing it instead of a single + // global vendors file every page must download. + vendor: { + test: /[\\/]node_modules[\\/]/, + chunks: 'initial', + priority: 10, + reuseExistingChunk: true, + }, + common: { + name: 'common', + minChunks: 2, + chunks: 'all', + priority: 5, + }, }, - }, - }; + }; + } return config; }, @@ -104,7 +150,8 @@ const nextConfig: NextConfig = { ]; }, experimental: { - optimizePackageImports: ['d3', 'lucide-react'], + // Barrel-file tree shaking for icon/util/animation libraries (FE-050). + optimizePackageImports: ['d3', 'lucide-react', 'date-fns', 'framer-motion', 'recharts', 'react-chartjs-2'], }, }; diff --git a/frontend/src/app/admin/audit-logs/page.tsx b/frontend/src/app/admin/audit-logs/page.tsx new file mode 100644 index 000000000..5a9ba361f --- /dev/null +++ b/frontend/src/app/admin/audit-logs/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +// The grid (react-window + 50k-row dataset) is client-only and loads after the shell. +const VirtualizedLogTable = dynamic(() => import('@/components/admin/VirtualizedLogTable'), { + ssr: false, + loading: () =>
, +}); + +export default function AdminAuditLogsPage() { + return ( +
+
+
+

+ Audit Logs +

+

+ Inspect administrative and signing activity. Shift-click column headers for multi-column sorting. +

+
+ +
+
+ ); +} diff --git a/frontend/src/app/brainstorm/page.tsx b/frontend/src/app/brainstorm/page.tsx index 76712b160..937c4a0a6 100644 --- a/frontend/src/app/brainstorm/page.tsx +++ b/frontend/src/app/brainstorm/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CollaborativeCanvas } from '@/components/CollaborativeCanvas'; +import dynamic from 'next/dynamic'; import { EncryptedRoomChat } from '@/components/collaboration/EncryptedRoomChat'; import { useAuth } from '@/contexts/AuthContext'; import { useRouter, useSearchParams } from 'next/navigation'; @@ -8,6 +8,12 @@ import { useEffect, useState, Suspense } from 'react'; import { getApiEndpoint } from '@/lib/env'; import { BrainstormSkeleton } from '@/components/ui/skeletons/PageSkeletons'; +// ReactFlow + html2canvas + jsPDF are only needed once a session is open. +const CollaborativeCanvas = dynamic( + () => import('@/components/CollaborativeCanvas').then((mod) => mod.CollaborativeCanvas), + { ssr: false, loading: () =>
, }, +); + function BrainstormContent() { const searchParams = useSearchParams(); const router = useRouter(); diff --git a/frontend/src/app/collaborative-lab/page.tsx b/frontend/src/app/collaborative-lab/page.tsx index 197dff575..0e8469cf4 100644 --- a/frontend/src/app/collaborative-lab/page.tsx +++ b/frontend/src/app/collaborative-lab/page.tsx @@ -1,13 +1,19 @@ 'use client'; +import dynamic from 'next/dynamic'; import { useState } from 'react'; import IdeaGeneratorPanel from '@/components/idea-generator/IdeaGeneratorPanel'; -import { CollaborativeCanvas } from '@/components/CollaborativeCanvas'; -import PeerReviewDashboard from '@/components/review/PeerReviewDashboard'; import { ReputationDashboard } from '@/components/reputation/ReputationDashboard'; import { EncryptedRoomChat } from '@/components/collaboration/EncryptedRoomChat'; import { Users, Lightbulb, PenTool, Star, MessageSquare } from 'lucide-react'; +// Heavy tab bodies load only when their tab is opened. +const CollaborativeCanvas = dynamic( + () => import('@/components/CollaborativeCanvas').then((mod) => mod.CollaborativeCanvas), + { ssr: false, loading: () =>
, }, +); +const PeerReviewDashboard = dynamic(() => import('@/components/review/PeerReviewDashboard'), { ssr: false }); + export default function CollaborativeLab() { const [activeTab, setActiveTab] = useState('ideas'); diff --git a/frontend/src/app/courses/page.tsx b/frontend/src/app/courses/page.tsx index c0834a33f..8fc0b34b0 100644 --- a/frontend/src/app/courses/page.tsx +++ b/frontend/src/app/courses/page.tsx @@ -2,6 +2,7 @@ import { useI18n } from '@/i18n'; import { Course, CourseDataSource, coursesAPI } from '@/lib/api'; +import { usePrefetch } from '@/hooks/usePrefetch'; import { useQuery } from '@tanstack/react-query'; import { ArrowRight, BookOpen, Search, Sparkles } from 'lucide-react'; import Link from 'next/link'; @@ -11,6 +12,8 @@ import { ErrorBoundary, ErrorFallback, CourseListSkeleton, DemoDataBanner } from export default function CoursesPage() { const { t } = useI18n(); const [query, setQuery] = useState(''); + // Course grids can be long: skip viewport prefetching and prefetch on hover/focus intent instead. + const { prefetch, handleMouseEnter, handleMouseLeave } = usePrefetch(); const { data, @@ -113,6 +116,11 @@ export default function CoursesPage() { handleMouseEnter(`/courses/${course.id}`)} + onMouseLeave={handleMouseLeave} + onFocus={() => prefetch(`/courses/${course.id}`)} + onTouchStart={() => prefetch(`/courses/${course.id}`)} className="group relative flex flex-col justify-between bg-zinc-900/40 border border-white/5 p-8 rounded-[2rem] overflow-hidden transition-all hover:-translate-y-2 hover:bg-zinc-900/80 hover:border-red-500/40 hover:shadow-[0_20px_40px_rgba(0,0,0,0.5)]" >
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 44e1b132d..ea8bbc068 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -100,7 +100,7 @@ } @theme inline { - --font-sans: 'Outfit', 'Inter', 'Sora', 'Segoe UI', sans-serif; + --font-sans: var(--font-outfit), var(--font-inter), 'Outfit', 'Inter', 'Sora', 'Segoe UI', sans-serif; --font-mono: 'JetBrains Mono', 'SFMono-Regular', monospace; --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index fca40e5fb..675fad761 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,11 +1,14 @@ import { QueryProvider } from '@/lib/query/QueryProvider'; -import { CommandPalette } from '@/components/common/CommandPalette'; import { KeyboardShortcutsProvider } from '@/components/keyboard/KeyboardShortcutsProvider'; import Navbar from '@/components/layout/Navbar'; -import RenderWarningModal from '@/components/layout/RenderWarningModal'; +import { + DeferredCommandPalette, + DeferredOfflineNotification, + DeferredRenderWarningModal, +} from '@/components/layout/DeferredLayoutWidgets'; import ResiliencyBanner from '@/components/layout/ResiliencyBanner'; import WalletGate from '@/components/layout/WalletGate'; -import { OfflineNotification, CourseNotificationListener, ToastContainer } from '@/components/notifications'; +import { CourseNotificationListener, ToastContainer } from '@/components/notifications'; import { OfflineSyncHandler } from '@/components/OfflineSyncHandler'; import { SkipLink } from '@/components/ui/SkipLink'; import { AuthProvider } from '@/contexts/AuthContext'; @@ -16,8 +19,25 @@ import { WalletProvider } from '@/contexts/WalletContext'; import { Web3OnboardingProvider } from '@/contexts/Web3OnboardingContext'; import { I18nProvider } from '@/i18n'; import type { Metadata, Viewport } from 'next'; +import { Inter, Outfit } from 'next/font/google'; import './globals.css'; +// Self-hosted at build time: no render-blocking Google Fonts stylesheet or +// extra origin connections on the critical path (FE-050 / LCP). +const inter = Inter({ + subsets: ['latin'], + weight: ['400', '500', '600', '700'], + display: 'swap', + variable: '--font-inter', +}); + +const outfit = Outfit({ + subsets: ['latin'], + weight: ['500', '700', '900'], + display: 'swap', + variable: '--font-outfit', +}); + export const metadata: Metadata = { title: 'Web3 Student Lab', description: @@ -35,15 +55,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - - - + @@ -63,14 +79,14 @@ export default function RootLayout({ /> - +
{children}
- - + + diff --git a/frontend/src/app/yield-calculator/page.tsx b/frontend/src/app/yield-calculator/page.tsx index 5beb7e748..49e9e00bc 100644 --- a/frontend/src/app/yield-calculator/page.tsx +++ b/frontend/src/app/yield-calculator/page.tsx @@ -3,7 +3,6 @@ import React, { useState, useMemo } from 'react'; import { LivePoolYield } from '@/components/yield-calculator/LivePoolYield'; -import { TwapBacktestCanvas } from '@/components/analytics/TwapBacktestCanvas'; import { Chart as ChartJS, CategoryScale, @@ -18,6 +17,12 @@ import { } from 'chart.js'; import { Line } from 'react-chartjs-2'; import { HelpCircle, RefreshCw, Calculator, TrendingUp, DollarSign, Percent, Clock } from 'lucide-react'; +import dynamic from 'next/dynamic'; + +const TwapBacktestCanvas = dynamic( + () => import('@/components/analytics/TwapBacktestCanvas').then((mod) => mod.TwapBacktestCanvas), + { ssr: false, loading: () =>
, }, +); // Register ChartJS modules ChartJS.register( diff --git a/frontend/src/components/admin/LogDetailModal.tsx b/frontend/src/components/admin/LogDetailModal.tsx new file mode 100644 index 000000000..b97ed29da --- /dev/null +++ b/frontend/src/components/admin/LogDetailModal.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useFocusTrap } from '@/hooks/useFocusTrap'; +import type { AuditLogRecord } from '@/hooks/useAuditLogs'; +import { Check, Copy, X } from 'lucide-react'; +import { useMemo, useRef, useState, type ReactNode } from 'react'; + +interface LogDetailModalProps { + log: AuditLogRecord | null; + onClose: () => void; +} + +const FIELDS: { key: keyof AuditLogRecord; label: string }[] = [ + { key: 'id', label: 'ID' }, + { key: 'createdAt', label: 'Timestamp' }, + { key: 'action', label: 'Action' }, + { key: 'userEmail', label: 'User' }, + { key: 'userId', label: 'User ID' }, + { key: 'entity', label: 'Entity' }, + { key: 'entityId', label: 'Entity ID' }, + { key: 'ipAddress', label: 'IP Address' }, + { key: 'userAgent', label: 'User Agent' }, + { key: 'prevHash', label: 'Prev Hash' }, +]; + +/** + * Tokenise pretty-printed JSON into coloured spans. Rendered as React text + * nodes (never `dangerouslySetInnerHTML`), so payload contents cannot inject markup. + */ +function highlightJson(json: string) { + const tokenPattern = /("(?:\\.|[^"\\])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + const parts: ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + + while ((match = tokenPattern.exec(json)) !== null) { + if (match.index > lastIndex) parts.push(json.slice(lastIndex, match.index)); + const token = match[0]; + const className = token.startsWith('"') + ? token.trimEnd().endsWith(':') + ? 'text-sky-300' + : 'text-emerald-300' + : /true|false|null/.test(token) + ? 'text-fuchsia-300' + : 'text-amber-300'; + parts.push( + + {token} + , + ); + lastIndex = match.index + token.length; + } + if (lastIndex < json.length) parts.push(json.slice(lastIndex)); + return parts; +} + +export function LogDetailModal({ log, onClose }: LogDetailModalProps) { + const dialogRef = useRef(null); + const [copied, setCopied] = useState(false); + + useFocusTrap(dialogRef, { enabled: log !== null, onEscape: onClose }); + + const payload = useMemo(() => { + if (!log) return ''; + try { + return JSON.stringify(log.details ?? null, null, 2); + } catch { + return String(log.details); + } + }, [log]); + + const highlighted = useMemo(() => highlightJson(payload), [payload]); + + if (!log) return null; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(log, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

+ {log.action} +

+

{log.id}

+
+
+ + +
+
+ +
+
+ {FIELDS.map(({ key, label }) => ( +
+
{label}
+
{String(log[key] ?? '—')}
+
+ ))} +
+ +

+ Payload +

+
+            {highlighted}
+          
+
+
+
+ ); +} + +export default LogDetailModal; diff --git a/frontend/src/components/admin/VirtualizedLogTable.tsx b/frontend/src/components/admin/VirtualizedLogTable.tsx new file mode 100644 index 000000000..59a24ad78 --- /dev/null +++ b/frontend/src/components/admin/VirtualizedLogTable.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { LogDetailModal } from '@/components/admin/LogDetailModal'; +import { + downloadAuditLogCsv, + useAuditLogs, + type AuditLogColumnKey, + type AuditLogRecord, + type SortRule, + type UseAuditLogsOptions, +} from '@/hooks/useAuditLogs'; +import { ArrowDown, ArrowUp, Download, RefreshCw, Search, X } from 'lucide-react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { List } from 'react-window'; + +/** + * High-density administrative audit log grid (FE-048). + * + * Only the rows inside the viewport (plus a small overscan) are mounted, so + * 50,000 records render ~20 row nodes at any time. Supports drag-to-resize + * columns, multi-column sorting (shift-click), debounced search, a JSON + * payload inspector, and CSV export of the current filtered + sorted view. + */ + +interface ColumnDef { + key: AuditLogColumnKey; + label: string; + defaultWidth: number; + render?: (log: AuditLogRecord) => React.ReactNode; +} + +const ROW_HEIGHT = 36; +const MIN_COLUMN_WIDTH = 72; +const OVERSCAN = 3; + +const formatTimestamp = (iso: string) => { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? iso : date.toISOString().replace('T', ' ').slice(0, 19); +}; + +const COLUMNS: ColumnDef[] = [ + { key: 'createdAt', label: 'Timestamp (UTC)', defaultWidth: 180, render: (log) => formatTimestamp(log.createdAt) }, + { key: 'action', label: 'Action', defaultWidth: 180 }, + { key: 'userEmail', label: 'User', defaultWidth: 220 }, + { key: 'entity', label: 'Entity', defaultWidth: 130 }, + { key: 'entityId', label: 'Entity ID', defaultWidth: 180 }, + { key: 'ipAddress', label: 'IP Address', defaultWidth: 140 }, +]; + +interface LogRowCustomProps { + rows: AuditLogRecord[]; + gridTemplateColumns: string; + onOpen: (log: AuditLogRecord) => void; +} + +interface LogRowProps extends LogRowCustomProps { + index: number; + style: React.CSSProperties; + ariaAttributes?: React.AriaAttributes & { role?: React.AriaRole }; +} + +function LogRow({ index, style, rows, gridTemplateColumns, onOpen, ariaAttributes }: LogRowProps) { + const log = rows[index]; + if (!log) return null; + + return ( +
onOpen(log)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(log); + } + }} + className={`cursor-pointer items-center border-b border-white/5 font-mono text-xs text-zinc-300 outline-none hover:bg-white/[0.04] focus-visible:bg-red-500/10 ${ + index % 2 === 0 ? 'bg-transparent' : 'bg-white/[0.015]' + }`} + > + {COLUMNS.map((column) => ( +
+ {column.render ? column.render(log) : (log[column.key] ?? —)} +
+ ))} +
+ ); +} + +function SortIcon({ rule, priority }: { rule?: SortRule; priority: number | null }) { + if (!rule) return ; + const Icon = rule.direction === 'asc' ? ArrowUp : ArrowDown; + return ( + + + {priority !== null && {priority}} + + ); +} + +export interface VirtualizedLogTableProps extends UseAuditLogsOptions { + /** Height of the scrolling body in pixels. */ + height?: number; +} + +export function VirtualizedLogTable({ height = 600, ...options }: VirtualizedLogTableProps) { + const { + rows, + totalCount, + isLoading, + error, + dataSource, + search, + setSearch, + isFiltering, + filterDurationMs, + sortRules, + toggleSort, + clearSort, + refetch, + } = useAuditLogs(options); + + const [selected, setSelected] = useState(null); + const [widths, setWidths] = useState>( + () => Object.fromEntries(COLUMNS.map((column) => [column.key, column.defaultWidth])) as Record, + ); + + const gridTemplateColumns = useMemo( + () => COLUMNS.map((column) => `${widths[column.key]}px`).join(' '), + [widths], + ); + const totalWidth = useMemo(() => COLUMNS.reduce((sum, column) => sum + widths[column.key], 0), [widths]); + + const startResize = useCallback((key: AuditLogColumnKey, event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const handle = event.currentTarget; + const startX = event.clientX; + const startWidth = widths[key]; + handle.setPointerCapture(event.pointerId); + + const onMove = (moveEvent: PointerEvent) => { + const next = Math.max(MIN_COLUMN_WIDTH, startWidth + moveEvent.clientX - startX); + setWidths((current) => (current[key] === next ? current : { ...current, [key]: next })); + }; + const onUp = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', onUp); + handle.removeEventListener('pointercancel', onUp); + }; + + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + }, [widths]); + + const handleOpen = useCallback((log: AuditLogRecord) => setSelected(log), []); + const handleExport = useCallback(() => downloadAuditLogCsv(rows), [rows]); + + return ( +
+
+ + +

+ {rows.length.toLocaleString()} of {totalCount.toLocaleString()} rows + {!isLoading && ( + + {isFiltering ? 'filtering…' : `filtered in ${filterDurationMs.toFixed(1)}ms`} + + )} +

+ + {sortRules.length > 0 && ( + + )} + + +
+ + {dataSource === 'demo' && ( +
+ Audit log endpoint unavailable — showing generated demo records. +
+ )} + {error &&
Failed to load audit logs: {error.message}
} + +
+
+
+ {COLUMNS.map((column) => { + const ruleIndex = sortRules.findIndex((rule) => rule.key === column.key); + const rule = ruleIndex >= 0 ? sortRules[ruleIndex] : undefined; + const ariaSort = rule ? (rule.direction === 'asc' ? 'ascending' : 'descending') : 'none'; + return ( +
+ +
startResize(column.key, event)} + className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none hover:bg-red-500/50" + /> +
+ ); + })} +
+ + {isLoading ? ( +
+ Loading audit logs… +
+ ) : rows.length === 0 ? ( +
+ No audit records match “{search}”. +
+ ) : ( + + rowCount={rows.length} + rowHeight={ROW_HEIGHT} + overscanCount={OVERSCAN} + style={{ height, width: '100%' }} + rowComponent={LogRow} + rowProps={{ rows, gridTemplateColumns, onOpen: handleOpen }} + /> + )} +
+
+ + setSelected(null)} /> +
+ ); +} + +export default VirtualizedLogTable; diff --git a/frontend/src/components/layout/DeferredLayoutWidgets.tsx b/frontend/src/components/layout/DeferredLayoutWidgets.tsx new file mode 100644 index 000000000..49aec7e77 --- /dev/null +++ b/frontend/src/components/layout/DeferredLayoutWidgets.tsx @@ -0,0 +1,25 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +/** + * Layout-level widgets that are never part of first paint (FE-050). + * + * `next/dynamic` with `ssr: false` is not allowed in the server-component + * root layout, so the lazy boundaries live in this client module. Each widget + * is fetched after hydration, keeping the command index (fuse.js + route + * catalogue) and modal code out of the initial JS shared by every route. + */ +export const DeferredCommandPalette = dynamic( + () => import('@/components/common/CommandPalette').then((mod) => mod.CommandPalette), + { ssr: false }, +); + +export const DeferredRenderWarningModal = dynamic(() => import('@/components/layout/RenderWarningModal'), { + ssr: false, +}); + +export const DeferredOfflineNotification = dynamic( + () => import('@/components/notifications/OfflineNotification').then((mod) => mod.OfflineNotification), + { ssr: false }, +); diff --git a/frontend/src/components/lesson/LessonWorkspace.tsx b/frontend/src/components/lesson/LessonWorkspace.tsx index 2aac0dcc4..cc928ccf4 100644 --- a/frontend/src/components/lesson/LessonWorkspace.tsx +++ b/frontend/src/components/lesson/LessonWorkspace.tsx @@ -1,7 +1,13 @@ 'use client'; +import dynamic from 'next/dynamic'; import { useState } from 'react'; -import LessonCodeEditor from './LessonCodeEditor'; + +// Keep Monaco out of the lesson's initial chunk; the prose renders first. +const LessonCodeEditor = dynamic(() => import('./LessonCodeEditor'), { + ssr: false, + loading: () =>
, +}); /** * LessonWorkspace — pairs lesson content with the interactive code editor. diff --git a/frontend/src/components/review/CollaborativeReviewDashboard.tsx b/frontend/src/components/review/CollaborativeReviewDashboard.tsx index c37f49ce4..b4772e6d5 100644 --- a/frontend/src/components/review/CollaborativeReviewDashboard.tsx +++ b/frontend/src/components/review/CollaborativeReviewDashboard.tsx @@ -2,11 +2,10 @@ import { AnimatePresence, motion } from 'framer-motion'; import { BarChart3, Code, FileText, Settings, Target, Users, X } from 'lucide-react'; +import dynamic from 'next/dynamic'; import { useState } from 'react'; // Import our new collaborative components -import CodeReview from './CodeReview'; -import CodeSuggestion from './CodeSuggestion'; import CollaborativeCursors from './CollaborativeCursors'; import MentionsNotifications from './MentionsNotifications'; import PresenceIndicators from './PresenceIndicators'; @@ -20,6 +19,10 @@ import ReviewWorkflow from './ReviewWorkflow'; import { useCommentSync } from '../../lib/review/CommentSync'; import { ReviewManager } from '../../lib/review/ReviewManager'; +// Monaco-backed panels are split out of the dashboard's initial chunk. +const CodeReview = dynamic(() => import('./CodeReview'), { ssr: false }); +const CodeSuggestion = dynamic(() => import('./CodeSuggestion'), { ssr: false }); + const currentCode = `use soroban_sdk::{contractimpl, Env, Symbol}; pub struct StudentContract; diff --git a/frontend/src/components/sandbox/MicroFrontendContainer.tsx b/frontend/src/components/sandbox/MicroFrontendContainer.tsx new file mode 100644 index 000000000..5f22a7045 --- /dev/null +++ b/frontend/src/components/sandbox/MicroFrontendContainer.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { HostRpcBridge, RpcHandlerError, validateManifest } from '@/services/sandbox/rpcBridge'; +import type { + ExerciseEvaluationParams, + MicroFrontendManifest, + MicroFrontendPermission, + ProgressReportParams, + SecurityViolation, + SignTransactionParams, + SignTransactionResult, + StellarNetwork, +} from '@/types/microFrontend'; +import { AlertTriangle, PenTool, ShieldCheck, ShieldAlert } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +/** + * Sandboxed container for third-party course widgets (FE-047). + * + * Isolation comes from three layers: + * 1. The widget must live on a registry-allowlisted origin that differs from + * the host, so `sandbox="allow-scripts allow-same-origin"` gives it its + * *own* origin — the browser blocks any access to the host DOM, cookies, + * and storage. Same-origin widgets are refused outright. + * 2. Permissions Policy denies powerful features and `referrerPolicy` + * hides the lesson URL. + * 3. All communication goes through {@link HostRpcBridge}: typed, tokenised, + * permission-gated, rate limited. Signing requests require an explicit + * student approval in host UI; the widget only ever receives signed XDR. + */ + +export const MICRO_FRONTEND_SANDBOX = 'allow-scripts allow-same-origin'; + +const IFRAME_PERMISSIONS_POLICY = [ + "camera 'none'", + "microphone 'none'", + "geolocation 'none'", + "payment 'none'", + "usb 'none'", + "serial 'none'", + "hid 'none'", + "display-capture 'none'", + "clipboard-read 'none'", +].join('; '); + +export interface MicroFrontendContainerProps { + manifest: MicroFrontendManifest; + /** Registry allowlist of origins approved to serve widgets. */ + allowedOrigins: string[]; + /** Permissions the host grants; defaults to everything the manifest requests. */ + grantedPermissions?: MicroFrontendPermission[]; + /** Read-only wallet projection. Never pass a signer or secret here. */ + wallet: { publicKey: string | null; network: StellarNetwork }; + /** Signs an approved envelope with the student's wallet and returns signed XDR. */ + onSignTransaction?: (xdr: string, request: { network: StellarNetwork; description: string }) => Promise; + onProgress?: (progress: ProgressReportParams, widgetId: string) => void; + onEvaluation?: (evaluation: ExerciseEvaluationParams, widgetId: string) => void | Promise; + onViolation?: (violation: SecurityViolation) => void; + courseId?: string; + lessonId?: string; + locale?: string; + theme?: 'dark' | 'light'; + /** Allow `http://localhost` widgets; defaults to development builds only. */ + allowInsecureLocalhost?: boolean; + className?: string; +} + +type ContainerStatus = 'loading' | 'connected' | 'blocked'; + +interface PendingSignature { + params: SignTransactionParams; + resolve: (signedXdr: string) => void; + reject: (error: RpcHandlerError) => void; +} + +export function MicroFrontendContainer({ + manifest, + allowedOrigins, + grantedPermissions, + wallet, + onSignTransaction, + onProgress, + onEvaluation, + onViolation, + courseId, + lessonId, + locale = 'en', + theme = 'dark', + allowInsecureLocalhost = process.env.NODE_ENV === 'development', + className = '', +}: MicroFrontendContainerProps) { + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const pendingRef = useRef(null); + const [status, setStatus] = useState('loading'); + const [height, setHeight] = useState(manifest.initialHeight ?? 480); + const [pendingSignature, setPendingSignature] = useState(null); + const [violations, setViolations] = useState([]); + const [signing, setSigning] = useState(false); + // The host origin is only known in the browser; resolving it after mount keeps SSR and hydration identical. + const [hostOrigin, setHostOrigin] = useState(null); + + useEffect(() => { + setHostOrigin(window.location.origin); + }, []); + + // Handlers read the latest props through a ref so the bridge (and its token) survive re-renders. + const latest = useRef({ wallet, onSignTransaction, onProgress, onEvaluation, onViolation }); + latest.current = { wallet, onSignTransaction, onProgress, onEvaluation, onViolation }; + + const validation = useMemo( + () => (hostOrigin ? validateManifest(manifest, { allowedOrigins, hostOrigin, allowInsecureLocalhost }) : null), + [manifest, allowedOrigins, allowInsecureLocalhost, hostOrigin], + ); + + const granted = grantedPermissions ?? manifest.permissions; + // Stable identity so a parent passing a new array literal each render does not rebuild the bridge. + const grantedKey = Array.from(new Set(granted)).sort().join(','); + + useEffect(() => { + if (!validation) return; + if (!validation.ok) { + setStatus('blocked'); + latest.current.onViolation?.({ reason: 'invalid-manifest', detail: validation.reason, at: Date.now() }); + return; + } + + setStatus('loading'); + const recordViolation = (violation: SecurityViolation) => { + setViolations((current) => [violation, ...current].slice(0, 5)); + latest.current.onViolation?.(violation); + }; + + const bridge = new HostRpcBridge({ + manifest, + getTarget: () => iframeRef.current?.contentWindow ?? null, + grantedPermissions: grantedKey ? (grantedKey.split(',') as MicroFrontendPermission[]) : [], + context: { courseId, lessonId, locale, theme }, + onReady: () => setStatus('connected'), + onViolation: recordViolation, + handlers: { + 'wallet.getPublicKey': () => ({ + publicKey: latest.current.wallet.publicKey, + network: latest.current.wallet.network, + }), + 'wallet.signTransaction': (params) => { + const { wallet: currentWallet, onSignTransaction: sign } = latest.current; + if (!sign) throw new RpcHandlerError('METHOD_NOT_ALLOWED', 'Signing is not available in this lesson'); + if (!currentWallet.publicKey) throw new RpcHandlerError('USER_REJECTED', 'No wallet is connected'); + if (params.network !== currentWallet.network) { + throw new RpcHandlerError('INVALID_PARAMS', `Wallet is on ${currentWallet.network}, not ${params.network}`); + } + if (pendingRef.current) { + throw new RpcHandlerError('RATE_LIMITED', 'Another signing request is awaiting approval'); + } + + return new Promise((resolve, reject) => { + const pending: PendingSignature = { + params, + resolve: (signedXdr) => resolve({ signedXdr }), + reject, + }; + pendingRef.current = pending; + setPendingSignature(pending); + }); + }, + 'progress.report': (params, context) => { + latest.current.onProgress?.(params, context.widgetId); + return { accepted: Boolean(latest.current.onProgress) }; + }, + 'exercise.submitEvaluation': async (params, context) => { + await latest.current.onEvaluation?.(params, context.widgetId); + return { recorded: Boolean(latest.current.onEvaluation) }; + }, + 'widget.resize': ({ height: requested }) => { + setHeight(requested); + return { height: requested }; + }, + }, + }); + + bridge.start(); + bridgeRef.current = bridge; + + return () => { + bridge.destroy(); + bridgeRef.current = null; + pendingRef.current?.reject(new RpcHandlerError('USER_REJECTED', 'Widget was unmounted')); + pendingRef.current = null; + setPendingSignature(null); + }; + // The bridge is rebuilt only when the widget identity or its grants change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manifest.id, manifest.origin, manifest.entryUrl, validation?.ok, grantedKey]); + + useEffect(() => { + if (grantedKey.split(',').includes('wallet:read')) { + bridgeRef.current?.emit('host.walletChanged', { publicKey: wallet.publicKey, network: wallet.network }); + } + }, [wallet.publicKey, wallet.network, grantedKey]); + + useEffect(() => { + bridgeRef.current?.emit('host.themeChanged', { theme }); + }, [theme]); + + const settleSignature = async (approved: boolean) => { + const pending = pendingRef.current; + if (!pending) return; + + if (!approved) { + pending.reject(new RpcHandlerError('USER_REJECTED', 'The student rejected the signing request')); + } else { + setSigning(true); + try { + const sign = latest.current.onSignTransaction; + if (!sign) throw new Error('Signing unavailable'); + const signedXdr = await sign(pending.params.xdr, { + network: pending.params.network, + description: pending.params.description, + }); + pending.resolve(signedXdr); + } catch { + pending.reject(new RpcHandlerError('USER_REJECTED', 'The wallet did not sign the transaction')); + } finally { + setSigning(false); + } + } + + pendingRef.current = null; + setPendingSignature(null); + }; + + if (!validation) { + return ( +
+ ); + } + + if (!validation.ok) { + return ( +
+
+ Widget blocked +
+

+ “{manifest.name}” cannot be loaded: {validation.reason}. +

+
+ ); + } + + return ( +
+
+
+ + {manifest.name} + + v{manifest.version} · {manifest.publisher} · {new URL(manifest.entryUrl).host} + +
+ + {status === 'connected' ? 'sandboxed · connected' : 'connecting…'} + +
+ +