From caf659988767f0351ebf07ed18eb7b9508b4a706 Mon Sep 17 00:00:00 2001 From: Fadeedev Date: Sun, 30 Aug 2026 23:04:23 +0100 Subject: [PATCH 1/2] feat: secure Stellar signing, cross-tab auth, live feed, SBTi dashboard --- .../src/config/validation/config.schema.ts | 8 + .../src/sbti/sbti.service.ts | 71 +++- .../progress-tracking.service.spec.ts | 54 +++ .../services/progress-tracking.service.ts | 195 +++++++++- .../src/stellar/signing/KEY_ROTATION.md | 24 ++ .../signing/env-signing.provider.spec.ts | 47 +++ .../stellar/signing/env-signing.provider.ts | 124 +++++++ .../stellar/signing/kms-signing.provider.ts | 84 +++++ .../signing/signing-provider.interface.ts | 35 ++ .../src/stellar/signing/signing.module.ts | 46 +++ .../src/stellar/soroban/soroban.module.ts | 3 +- .../src/stellar/soroban/soroban.service.ts | 49 ++- .../src/stellar/stellar.module.ts | 3 +- .../src/stellar/transfer.service.ts | 52 ++- .../feed/LiveRetirementFeed.test.ts | 25 ++ .../components/feed/LiveRetirementFeed.tsx | 334 ++++++++++++++---- .../retirement/LiveRetirementFeed.tsx | 1 + .../src/contexts/AuthContext.tsx | 93 ++++- .../src/lib/auth/cross-tab-auth.test.ts | 33 ++ .../src/lib/auth/cross-tab-auth.ts | 106 ++++++ 20 files changed, 1273 insertions(+), 114 deletions(-) create mode 100644 corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.spec.ts create mode 100644 corporate-platform/corporate-platform-backend/src/stellar/signing/KEY_ROTATION.md create mode 100644 corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts create mode 100644 corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.ts create mode 100644 corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts create mode 100644 corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts create mode 100644 corporate-platform/corporate-platform-backend/src/stellar/signing/signing.module.ts create mode 100644 corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.test.ts create mode 100644 corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.test.ts create mode 100644 corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.ts diff --git a/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts b/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts index 508cda2d..2566e960 100644 --- a/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts +++ b/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts @@ -41,6 +41,14 @@ export const configSchema = Joi.object({ STELLAR_NETWORK: Joi.string().default('testnet'), HORIZON_URL: Joi.string().uri().allow(''), SOROBAN_RPC_URL: Joi.string().uri().allow(''), + // Signing (#542): explicit mode — never treat missing secret as silent simulate + STELLAR_SIGNING_MODE: Joi.string().valid('simulate', 'live').default('simulate'), + STELLAR_SIGNING_PROVIDER: Joi.string().valid('env', 'kms', 'vault').default('env'), + STELLAR_SECRET_KEY: Joi.string().allow('', null), + STELLAR_TRANSFER_SECRET_KEY: Joi.string().allow('', null), + STELLAR_KMS_KEY_ID: Joi.string().allow('', null), + STELLAR_KMS_PUBLIC_KEY: Joi.string().allow('', null), + STELLAR_VAULT_KEY_PATH: Joi.string().allow('', null), // ============================================================ // Auth Configuration diff --git a/corporate-platform/corporate-platform-backend/src/sbti/sbti.service.ts b/corporate-platform/corporate-platform-backend/src/sbti/sbti.service.ts index 40e46bf9..2b2d4450 100644 --- a/corporate-platform/corporate-platform-backend/src/sbti/sbti.service.ts +++ b/corporate-platform/corporate-platform-backend/src/sbti/sbti.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../shared/database/prisma.service'; import { CreateTargetDto } from './dto/create-target.dto'; import { SbtiTarget } from './interfaces/sbti-target.interface'; @@ -11,15 +11,21 @@ import { AuditEventType, AuditAction, } from '../audit-trail/interfaces/audit-event.interface'; +import { ProgressTrackingService } from './services/progress-tracking.service'; @Injectable() export class SbtiService { + private readonly logger = new Logger(SbtiService.name); + private dashboardCache = new Map(); + private readonly dashboardTtlMs = 3 * 60 * 1000; + constructor( private readonly prisma: PrismaService, private readonly targetValidation: TargetValidationService, private readonly retirementService: RetirementService, private readonly ghgProtocolService: GhgProtocolService, private readonly auditTrailService: AuditTrailService, + private readonly progressTracking: ProgressTrackingService, ) {} // Create SBTi target async createTarget(dto: CreateTargetDto): Promise { @@ -102,17 +108,49 @@ export class SbtiService { return result; } - // SBTi progress dashboard + // SBTi progress dashboard — chart-ready aggregation via ProgressTrackingService async getDashboard(companyId: string): Promise { - // TODO: Aggregate and return chart-ready dashboard data - // Placeholder: return targets and progress + const cached = this.dashboardCache.get(companyId); + if (cached && cached.expires > Date.now()) { + return cached.payload; + } + const targets = await this.prisma.sbtiTarget.findMany({ where: { companyId }, }); const progress = await this.prisma.targetProgress.findMany({ where: { targetId: { in: targets.map((t) => t.id) } }, }); - return { targets, progress }; + + let dataQuality: 'ok' | 'partial' = 'ok'; + let retirementGaps: any[] = []; + try { + const gapResult = await this.getRetirementGap(companyId); + retirementGaps = gapResult.results || []; + if (gapResult.dataQuality === 'partial') dataQuality = 'partial'; + } catch (err) { + dataQuality = 'partial'; + this.logger.warn( + `getDashboard: retirement gap aggregation partial for ${companyId}: ${(err as Error).message}`, + ); + } + + const aggregation = this.progressTracking.aggregate( + targets as any, + progress as any, + { dataQuality }, + ); + + const payload = { + ...aggregation, + retirementGaps, + generatedAt: new Date().toISOString(), + }; + this.dashboardCache.set(companyId, { + expires: Date.now() + this.dashboardTtlMs, + payload, + }); + return payload; } // Calculate retirements needed (retirement gap) @@ -123,22 +161,25 @@ export class SbtiService { }); // 2. For each target, get GHG emissions and retirements const results = []; + let anyPartial = false; for (const target of targets) { // Get total emissions for the target year and scope // (Assume ghgProtocolService has a method getTotalEmissions(companyId, year, scope)) let emissions = 0; + let partial = false; try { emissions = await (this.ghgProtocolService as any).getTotalEmissions( companyId, target.targetYear, target.scope, ); - } catch { - // ignore + } catch (err) { + partial = true; + this.logger.warn( + `getRetirementGap emissions failed target=${target.id}: ${(err as Error).message}`, + ); } - // Get total retirements for the target year and scope - // (Assume retirementService has a method getTotalRetirements(companyId, year, scope)) let retirements = 0; try { retirements = await (this.retirementService as any).getTotalRetirements( @@ -146,11 +187,13 @@ export class SbtiService { target.targetYear, target.scope, ); - } catch { - // ignore + } catch (err) { + partial = true; + this.logger.warn( + `getRetirementGap retirements failed target=${target.id}: ${(err as Error).message}`, + ); } - // Calculate gap const gap = Math.max(0, emissions - retirements); results.push({ targetId: target.id, @@ -159,8 +202,10 @@ export class SbtiService { emissions, retirements, gap, + dataQuality: partial ? 'partial' : 'ok', }); + if (partial) anyPartial = true; } - return { results }; + return { results, dataQuality: anyPartial ? 'partial' : 'ok' }; } } diff --git a/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.spec.ts b/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.spec.ts new file mode 100644 index 00000000..aa04337b --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.spec.ts @@ -0,0 +1,54 @@ +import { ProgressTrackingService } from './progress-tracking.service'; + +describe('ProgressTrackingService (#546)', () => { + const service = new ProgressTrackingService(); + const target = { + id: 't1', + scope: '1', + status: 'VALIDATED', + baseYear: 2020, + baseYearEmissions: 1000, + targetYear: 2030, + reductionPercentage: 50, + }; + + it('marks on_track when actual matches linear trajectory', () => { + // Midpoint 2025 → expected 750 + const status = service.classifyTrackStatus(target, 2025, 750); + expect(status).toBe('on_track'); + }); + + it('marks behind when actual is above trajectory', () => { + expect(service.classifyTrackStatus(target, 2025, 950)).toBe('behind'); + }); + + it('marks ahead when actual is below trajectory', () => { + expect(service.classifyTrackStatus(target, 2025, 500)).toBe('ahead'); + }); + + it('returns unknown for missing actuals', () => { + expect(service.classifyTrackStatus(target, 2025, null)).toBe('unknown'); + }); + + it('computes completion percentage toward reduction goal', () => { + // 25% of the 50% cut done → 50% complete + expect(service.completionPercentage(target, 875)).toBeCloseTo(25, 5); + expect(service.completionPercentage(target, 500)).toBeCloseTo(100, 5); + expect(service.completionPercentage(target, null)).toBe(0); + }); + + it('aggregates summary, series, and scope rollups', () => { + const dash = service.aggregate( + [target, { ...target, id: 't2', scope: '2', status: 'DRAFT' }], + [ + { targetId: 't1', reportingYear: 2024, emissions: 800 }, + { targetId: 't2', reportingYear: 2024, emissions: 400 }, + ], + ); + expect(dash.summary.totalTargets).toBe(2); + expect(dash.summary.byStatus.VALIDATED).toBe(1); + expect(dash.summary.byStatus.DRAFT).toBe(1); + expect(dash.targets[0].series.length).toBeGreaterThan(0); + expect(dash.scopeRollups.length).toBe(2); + }); +}); diff --git a/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts b/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts index e65d502d..ca7e4747 100644 --- a/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts +++ b/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts @@ -1,6 +1,197 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; + +export type TrackStatus = 'ahead' | 'on_track' | 'behind' | 'unknown'; + +export interface TargetLike { + id: string; + scope?: string | null; + status?: string | null; + baseYear: number; + baseYearEmissions: number | string; + targetYear: number; + reductionPercentage: number | string; +} + +export interface ProgressRowLike { + targetId: string; + reportingYear: number; + emissions: number | string; + targetEmissions?: number | string | null; +} + +export interface TargetSeriesPoint { + year: number; + actualEmissions: number; + targetEmissions: number; +} + +export interface TargetDashboardEntry { + targetId: string; + scope: string; + status: string; + completionPercentage: number; + trackStatus: TrackStatus; + series: TargetSeriesPoint[]; + latestEmissions: number | null; + baseYearEmissions: number; + targetYear: number; +} + +export interface ScopeRollup { + scope: string; + targetCount: number; + avgCompletionPercentage: number; + behindCount: number; + onTrackCount: number; + aheadCount: number; +} + +export interface DashboardAggregation { + summary: { + totalTargets: number; + byStatus: Record; + }; + targets: TargetDashboardEntry[]; + scopeRollups: ScopeRollup[]; + dataQuality: 'ok' | 'partial'; +} @Injectable() export class ProgressTrackingService { - // Progress tracking logic + private readonly logger = new Logger(ProgressTrackingService.name); + + /** Linear trajectory target emissions for a reporting year. */ + expectedEmissionsAtYear(target: TargetLike, year: number): number { + const base = Number(target.baseYearEmissions); + const reduction = Number(target.reductionPercentage) / 100; + const span = Math.max(1, target.targetYear - target.baseYear); + const progress = Math.min( + 1, + Math.max(0, (year - target.baseYear) / span), + ); + const targetAtEnd = base * (1 - reduction); + return base + (targetAtEnd - base) * progress; + } + + /** + * Completion toward the reduction goal based on latest actual vs base. + * 0 = no reduction from base, 100 = full reductionPercentage achieved. + */ + completionPercentage( + target: TargetLike, + latestActual: number | null, + ): number { + const base = Number(target.baseYearEmissions); + const reduction = Number(target.reductionPercentage) / 100; + if (!base || reduction <= 0 || latestActual == null) return 0; + const requiredCut = base * reduction; + if (requiredCut <= 0) return 0; + const actualCut = base - latestActual; + return Math.max( + 0, + Math.min(100, (actualCut / requiredCut) * 100), + ); + } + + classifyTrackStatus( + target: TargetLike, + reportingYear: number, + actualEmissions: number | null, + ): TrackStatus { + if (actualEmissions == null || Number.isNaN(actualEmissions)) { + return 'unknown'; + } + const expected = this.expectedEmissionsAtYear(target, reportingYear); + const tolerance = Math.max(1, expected * 0.02); // 2% band = on track + if (actualEmissions < expected - tolerance) return 'ahead'; + if (actualEmissions > expected + tolerance) return 'behind'; + return 'on_track'; + } + + buildSeries( + target: TargetLike, + rows: ProgressRowLike[], + ): TargetSeriesPoint[] { + const byYear = new Map(); + for (const r of rows) { + if (r.targetId !== target.id) continue; + byYear.set(r.reportingYear, Number(r.emissions)); + } + const years = [...byYear.keys()].sort((a, b) => a - b); + if (!years.includes(target.baseYear)) { + years.unshift(target.baseYear); + byYear.set(target.baseYear, Number(target.baseYearEmissions)); + } + return years.map((year) => ({ + year, + actualEmissions: + byYear.get(year) ?? Number(target.baseYearEmissions), + targetEmissions: this.expectedEmissionsAtYear(target, year), + })); + } + + aggregate( + targets: TargetLike[], + progressRows: ProgressRowLike[], + options?: { dataQuality?: 'ok' | 'partial' }, + ): DashboardAggregation { + const byStatus: Record = {}; + for (const t of targets) { + const s = t.status || 'UNKNOWN'; + byStatus[s] = (byStatus[s] || 0) + 1; + } + + const targetEntries: TargetDashboardEntry[] = targets.map((t) => { + const rows = progressRows.filter((p) => p.targetId === t.id); + const series = this.buildSeries(t, rows); + const latest = series.length + ? series[series.length - 1] + : null; + const latestEmissions = latest ? latest.actualEmissions : null; + const latestYear = latest ? latest.year : t.baseYear; + return { + targetId: t.id, + scope: String(t.scope ?? 'unknown'), + status: String(t.status ?? 'UNKNOWN'), + completionPercentage: this.completionPercentage(t, latestEmissions), + trackStatus: this.classifyTrackStatus(t, latestYear, latestEmissions), + series, + latestEmissions, + baseYearEmissions: Number(t.baseYearEmissions), + targetYear: t.targetYear, + }; + }); + + const scopeMap = new Map(); + for (const e of targetEntries) { + const list = scopeMap.get(e.scope) || []; + list.push(e); + scopeMap.set(e.scope, list); + } + const scopeRollups: ScopeRollup[] = [...scopeMap.entries()].map( + ([scope, list]) => { + const avg = + list.reduce((s, x) => s + x.completionPercentage, 0) / + Math.max(1, list.length); + return { + scope, + targetCount: list.length, + avgCompletionPercentage: Math.round(avg * 100) / 100, + behindCount: list.filter((x) => x.trackStatus === 'behind').length, + onTrackCount: list.filter((x) => x.trackStatus === 'on_track').length, + aheadCount: list.filter((x) => x.trackStatus === 'ahead').length, + }; + }, + ); + + return { + summary: { + totalTargets: targets.length, + byStatus, + }, + targets: targetEntries, + scopeRollups, + dataQuality: options?.dataQuality || 'ok', + }; + } } diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/KEY_ROTATION.md b/corporate-platform/corporate-platform-backend/src/stellar/signing/KEY_ROTATION.md new file mode 100644 index 00000000..dea01276 --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/KEY_ROTATION.md @@ -0,0 +1,24 @@ +# Stellar Signing Key Rotation (#542) + +## Env-backed provider (`STELLAR_SIGNING_PROVIDER=env`, default) + +1. Generate a new Stellar keypair offline. +2. Set `STELLAR_SECRET_KEY` (and optionally `STELLAR_TRANSFER_SECRET_KEY`) to the new secret in your secret store / deployment env. +3. Set `STELLAR_SIGNING_MODE=live`. +4. Rolling restart application instances. In-flight requests on old pods finish with the old key; new pods sign with the new key. +5. Confirm audit logs show the new `publicKey` on subsequent transactions. +6. Retire the old key from Horizon / custody once no in-flight work remains. + +No code changes are required — configuration only. + +## KMS / Vault provider (`STELLAR_SIGNING_PROVIDER=kms|vault`) + +1. Create a new KMS key version or Vault transit key version. +2. Update `STELLAR_KMS_KEY_ID` / `STELLAR_VAULT_KEY_PATH` and `STELLAR_KMS_PUBLIC_KEY`. +3. Restart services. Signing uses the new key id immediately. +4. Disable the previous KMS key version after the drain window. + +## Separate keys per operation + +- Contract invocation uses `SIGNING_PROVIDER_CONTRACT` (`STELLAR_SECRET_KEY` or KMS). +- Transfers use `SIGNING_PROVIDER_TRANSFER` (`STELLAR_TRANSFER_SECRET_KEY` if set, else shared). diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts new file mode 100644 index 00000000..092da1af --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts @@ -0,0 +1,47 @@ +import { EnvSigningProvider } from './env-signing.provider'; +import { KmsSigningProvider } from './kms-signing.provider'; + +describe('EnvSigningProvider (#542)', () => { + const original = { ...process.env }; + + afterEach(() => { + process.env = { ...original }; + }); + + it('defaults to simulate mode when STELLAR_SIGNING_MODE is unset', () => { + delete process.env.STELLAR_SIGNING_MODE; + delete process.env.STELLAR_SECRET_KEY; + const p = new EnvSigningProvider('contract'); + expect(p.isLive()).toBe(false); + expect(() => p.onModuleInit()).not.toThrow(); + }); + + it('fails fast in live mode without a secret', () => { + process.env.STELLAR_SIGNING_MODE = 'live'; + delete process.env.STELLAR_SECRET_KEY; + const p = new EnvSigningProvider('contract'); + expect(() => p.onModuleInit()).toThrow(/no secret configured/i); + }); + + it('fails fast in live mode with malformed secret', () => { + process.env.STELLAR_SIGNING_MODE = 'live'; + process.env.STELLAR_SECRET_KEY = 'not-a-stellar-secret'; + const p = new EnvSigningProvider('contract'); + expect(() => p.onModuleInit()).toThrow(/Invalid Stellar signing secret/i); + }); + + it('signTransaction rejects in simulate mode', async () => { + process.env.STELLAR_SIGNING_MODE = 'simulate'; + const p = new EnvSigningProvider('transfer'); + await expect(p.signTransaction('AAAA', 'Test SDF Network ; September 2015')).rejects.toThrow( + /simulate mode/i, + ); + }); + + it('KmsSigningProvider fails closed when selected without public key', () => { + process.env.STELLAR_SIGNING_PROVIDER = 'kms'; + delete process.env.STELLAR_KMS_PUBLIC_KEY; + const p = new KmsSigningProvider('contract'); + expect(() => p.onModuleInit()).toThrow(/STELLAR_KMS_PUBLIC_KEY/); + }); +}); diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.ts new file mode 100644 index 00000000..0f1bceba --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import { + SignedPayload, + SigningCategory, + SigningProvider, +} from './signing-provider.interface'; + +/** + * Dev/default SigningProvider backed by env vars. + * Production should prefer KmsSigningProvider. + * + * Env: + * STELLAR_SIGNING_MODE=simulate|live + * STELLAR_SECRET_KEY — default / contract key + * STELLAR_TRANSFER_SECRET_KEY — optional distinct transfer key + */ +@Injectable() +export class EnvSigningProvider implements SigningProvider, OnModuleInit { + private readonly logger = new Logger(EnvSigningProvider.name); + readonly keyId: string; + readonly category: SigningCategory; + private readonly secret: string | undefined; + private readonly mode: 'simulate' | 'live'; + private publicKeyCache: string | null = null; + + constructor( + category: SigningCategory = 'contract', + secretEnvKey = 'STELLAR_SECRET_KEY', + ) { + this.category = category; + this.mode = + (process.env.STELLAR_SIGNING_MODE || '').toLowerCase() === 'live' + ? 'live' + : 'simulate'; + this.secret = process.env[secretEnvKey] || process.env.STELLAR_SECRET_KEY; + this.keyId = `env:${category}:${secretEnvKey}`; + } + + onModuleInit(): void { + if (this.mode === 'live') { + if (!this.secret) { + throw new Error( + `STELLAR_SIGNING_MODE=live but no secret configured for ${this.keyId}. ` + + `Set STELLAR_SECRET_KEY (and optionally STELLAR_TRANSFER_SECRET_KEY).`, + ); + } + try { + const kp = StellarSdk.Keypair.fromSecret(this.secret); + this.publicKeyCache = kp.publicKey(); + this.logger.log( + `Signing provider ready category=${this.category} publicKey=${this.publicKeyCache}`, + ); + } catch (err) { + throw new Error( + `Invalid Stellar signing secret for ${this.keyId}: ${(err as Error).message}`, + ); + } + } else { + this.logger.warn( + `Signing provider in SIMULATE mode (category=${this.category}). ` + + `Set STELLAR_SIGNING_MODE=live with a valid key for real transactions.`, + ); + } + } + + isLive(): boolean { + return this.mode === 'live' && !!this.secret; + } + + async getPublicKey(): Promise { + if (this.publicKeyCache) return this.publicKeyCache; + if (!this.secret) { + // Deterministic mock key for simulate mode + return 'G_SIMULATE_' + this.category.toUpperCase(); + } + const kp = StellarSdk.Keypair.fromSecret(this.secret); + this.publicKeyCache = kp.publicKey(); + return this.publicKeyCache; + } + + async signTransaction( + txXdr: string, + networkPassphrase: string, + ): Promise { + if (!this.isLive() || !this.secret) { + throw new Error( + `Cannot sign in simulate mode (provider=${this.keyId}). ` + + `Set STELLAR_SIGNING_MODE=live and configure a valid secret.`, + ); + } + const keypair = StellarSdk.Keypair.fromSecret(this.secret); + const tx = StellarSdk.TransactionBuilder.fromXDR(txXdr, networkPassphrase); + (tx as StellarSdk.Transaction).sign(keypair); + const publicKey = keypair.publicKey(); + this.logger.log( + JSON.stringify({ + event: 'stellar_tx_signed', + category: this.category, + publicKey, + keyId: this.keyId, + }), + ); + return { + signedXdr: tx.toXDR(), + publicKey, + keyId: this.keyId, + }; + } +} + +/** Factory helpers for Nest providers */ +export function createContractSigningProvider(): EnvSigningProvider { + return new EnvSigningProvider('contract', 'STELLAR_SECRET_KEY'); +} + +export function createTransferSigningProvider(): EnvSigningProvider { + return new EnvSigningProvider( + 'transfer', + process.env.STELLAR_TRANSFER_SECRET_KEY + ? 'STELLAR_TRANSFER_SECRET_KEY' + : 'STELLAR_SECRET_KEY', + ); +} diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts new file mode 100644 index 00000000..d6a451be --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts @@ -0,0 +1,84 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { + SignedPayload, + SigningCategory, + SigningProvider, +} from './signing-provider.interface'; + +/** + * Production SigningProvider skeleton for AWS KMS / HashiCorp Vault. + * + * Configuration: + * STELLAR_SIGNING_PROVIDER=kms|vault + * STELLAR_KMS_KEY_ID=... + * STELLAR_KMS_PUBLIC_KEY=G... + * + * Wire the actual KMS Sign API in deploy-specific code; this class enforces + * the interface contract and fails closed when misconfigured. + * + * Key rotation: update STELLAR_KMS_KEY_ID / public key in config (or Vault + * path) and restart; no code change required. In-flight txs using the old + * key version should complete; new txs use the new key id. + */ +@Injectable() +export class KmsSigningProvider implements SigningProvider, OnModuleInit { + private readonly logger = new Logger(KmsSigningProvider.name); + readonly keyId: string; + readonly category: SigningCategory; + private readonly publicKey: string | undefined; + private readonly enabled: boolean; + + constructor(category: SigningCategory = 'contract') { + this.category = category; + this.keyId = + process.env.STELLAR_KMS_KEY_ID || + process.env.STELLAR_VAULT_KEY_PATH || + `kms:${category}:unconfigured`; + this.publicKey = process.env.STELLAR_KMS_PUBLIC_KEY; + this.enabled = + (process.env.STELLAR_SIGNING_PROVIDER || '').toLowerCase() === 'kms' || + (process.env.STELLAR_SIGNING_PROVIDER || '').toLowerCase() === 'vault'; + } + + onModuleInit(): void { + if (!this.enabled) { + this.logger.debug(`KmsSigningProvider not selected (category=${this.category})`); + return; + } + if (!this.publicKey) { + throw new Error( + `STELLAR_SIGNING_PROVIDER=kms|vault requires STELLAR_KMS_PUBLIC_KEY for ${this.keyId}`, + ); + } + this.logger.log( + `KMS/Vault signing provider ready category=${this.category} keyId=${this.keyId} publicKey=${this.publicKey}`, + ); + } + + isLive(): boolean { + return this.enabled && !!this.publicKey; + } + + async getPublicKey(): Promise { + if (!this.publicKey) { + throw new Error(`KMS public key not configured for ${this.keyId}`); + } + return this.publicKey; + } + + async signTransaction( + _txXdr: string, + _networkPassphrase: string, + ): Promise { + if (!this.isLive()) { + throw new Error(`KMS signing provider is not live (${this.keyId})`); + } + // Production: call AWS KMS Sign or Vault transit/sign and attach signature + // to the transaction envelope. Kept as an explicit failure so mis-wired + // deploys do not fall back to env secrets. + throw new Error( + `KmsSigningProvider.signTransaction is not wired to a live KMS client yet ` + + `(keyId=${this.keyId}). Configure AWS KMS / Vault client integration.`, + ); + } +} diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts new file mode 100644 index 00000000..884b88f4 --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts @@ -0,0 +1,35 @@ +/** + * Abstraction over Stellar transaction signing (#542). + * Callers must never touch process.env.STELLAR_SECRET_KEY directly. + */ +export type SigningCategory = 'contract' | 'transfer'; + +export interface SignedPayload { + /** XDR or base64 of the signed transaction */ + signedXdr: string; + /** Public key that performed the signature (for audit logs) */ + publicKey: string; + /** Optional key version / KMS key id */ + keyId?: string; +} + +export interface SigningProvider { + /** Stable identifier for logs / rotation tracking */ + readonly keyId: string; + readonly category: SigningCategory; + + /** Returns the public key without exposing secret material */ + getPublicKey(): Promise; + + /** + * Sign a prepared transaction XDR string. + * Implementations must not retain secret material longer than needed. + */ + signTransaction(txXdr: string, networkPassphrase: string): Promise; + + /** True when this provider produces real on-chain signatures */ + isLive(): boolean; +} + +export const SIGNING_PROVIDER_CONTRACT = 'SIGNING_PROVIDER_CONTRACT'; +export const SIGNING_PROVIDER_TRANSFER = 'SIGNING_PROVIDER_TRANSFER'; diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/signing.module.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/signing.module.ts new file mode 100644 index 00000000..3f42aaac --- /dev/null +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/signing.module.ts @@ -0,0 +1,46 @@ +import { Global, Module } from '@nestjs/common'; +import { + SIGNING_PROVIDER_CONTRACT, + SIGNING_PROVIDER_TRANSFER, +} from './signing-provider.interface'; +import { + createContractSigningProvider, + createTransferSigningProvider, + EnvSigningProvider, +} from './env-signing.provider'; +import { KmsSigningProvider } from './kms-signing.provider'; + +function selectProvider( + category: 'contract' | 'transfer', +): EnvSigningProvider | KmsSigningProvider { + const kind = (process.env.STELLAR_SIGNING_PROVIDER || 'env').toLowerCase(); + if (kind === 'kms' || kind === 'vault') { + return new KmsSigningProvider(category); + } + return category === 'transfer' + ? createTransferSigningProvider() + : createContractSigningProvider(); +} + +@Global() +@Module({ + providers: [ + { + provide: SIGNING_PROVIDER_CONTRACT, + useFactory: () => selectProvider('contract'), + }, + { + provide: SIGNING_PROVIDER_TRANSFER, + useFactory: () => selectProvider('transfer'), + }, + EnvSigningProvider, + KmsSigningProvider, + ], + exports: [ + SIGNING_PROVIDER_CONTRACT, + SIGNING_PROVIDER_TRANSFER, + EnvSigningProvider, + KmsSigningProvider, + ], +}) +export class SigningModule {} diff --git a/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts b/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts index 393cc1b8..9eecbd8d 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts @@ -15,9 +15,10 @@ import { IdempotencyService } from './idempotency/idempotency.service'; import { SorobanReconciliationService } from './reconciliation/soroban-reconciliation.service'; import { ConfigModule } from '../../config/config.module'; +import { SigningModule } from '../signing/signing.module'; @Module({ - imports: [OwnershipHistoryModule, IdempotencyModule, ConfigModule], + imports: [OwnershipHistoryModule, IdempotencyModule, ConfigModule, SigningModule], providers: [ SorobanService, CarbonAssetService, diff --git a/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.service.ts b/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.service.ts index c6bbb1e0..e8dc4444 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.service.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Injectable, InternalServerErrorException, Logger, @@ -14,6 +15,10 @@ import { } from './contracts/contract.interface'; import * as StellarSdk from '@stellar/stellar-sdk'; import { TimeoutError } from '../../shared/exceptions/timeout-error'; +import { + SIGNING_PROVIDER_CONTRACT, + SigningProvider, +} from '../signing/signing-provider.interface'; /** * Soroban Service with timeout and retry configuration @@ -50,6 +55,8 @@ export class SorobanService { constructor( private readonly configService: ConfigService, private readonly prisma: PrismaService, + @Inject(SIGNING_PROVIDER_CONTRACT) + private readonly signingProvider: SigningProvider, ) { const stellarConfig = this.configService.getStellarConfig(); this.rpc = new StellarSdk.rpc.Server( @@ -125,9 +132,9 @@ export class SorobanService { this.ensureCallInput(payload.contractId, payload.methodName); const args = payload.args || []; - const secret = process.env.STELLAR_SECRET_KEY; - if (!secret) { + // Explicit simulate mode via SigningProvider (never silent missing-env fallback) + if (!this.signingProvider.isLive()) { const simulated = await this.simulateContractCall( { contractId: payload.contractId, @@ -139,6 +146,16 @@ export class SorobanService { const txHash = `sim_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; const submittedAt = new Date(); + const signingPublicKey = await this.signingProvider.getPublicKey(); + this.logger.log( + JSON.stringify({ + event: 'contract_invoke_simulated', + signingPublicKey, + keyId: this.signingProvider.keyId, + contractId: payload.contractId, + methodName: payload.methodName, + }), + ); await this.prisma.contractCall.create({ data: { @@ -166,11 +183,11 @@ export class SorobanService { }; } - const keypair = StellarSdk.Keypair.fromSecret(secret); + const signingPublicKey = await this.signingProvider.getPublicKey(); const sourceAccount = await this.executeWithTimeout( - this.rpc.getAccount(keypair.publicKey()), + this.rpc.getAccount(signingPublicKey), this.simulateTimeout, - `getAccount for ${keypair.publicKey()}`, + `getAccount for ${signingPublicKey}`, signal, ); @@ -192,11 +209,29 @@ export class SorobanService { signal, ); - prepared.sign(keypair); + // Sign via SigningProvider — audit log includes public key, not secret + const preparedXdr = (prepared as any).toXDR(); + const signed = await this.signingProvider.signTransaction( + preparedXdr, + this.networkPassphrase, + ); + this.logger.log( + JSON.stringify({ + event: 'contract_invoke_signed', + signingPublicKey: signed.publicKey, + keyId: signed.keyId ?? this.signingProvider.keyId, + contractId: payload.contractId, + methodName: payload.methodName, + }), + ); + const signedTx = StellarSdk.TransactionBuilder.fromXDR( + signed.signedXdr, + this.networkPassphrase, + ); const submittedAt = new Date(); const sendResponse = await this.executeWithTimeout( - this.rpc.sendTransaction(prepared as any), + this.rpc.sendTransaction(signedTx as any), this.sendTimeout, `sendTransaction for ${payload.contractId}.${payload.methodName}`, signal, diff --git a/corporate-platform/corporate-platform-backend/src/stellar/stellar.module.ts b/corporate-platform/corporate-platform-backend/src/stellar/stellar.module.ts index a5d7f1e6..117ebc85 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/stellar.module.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/stellar.module.ts @@ -3,9 +3,10 @@ import { StellarService } from './stellar.service'; import { TransferService } from './transfer.service'; import { StellarController } from './stellar.controller'; import { SorobanModule } from './soroban/soroban.module'; +import { SigningModule } from './signing/signing.module'; @Module({ - imports: [SorobanModule], + imports: [SorobanModule, SigningModule], controllers: [StellarController], providers: [StellarService, TransferService], exports: [StellarService, TransferService, SorobanModule], diff --git a/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.ts b/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.ts index f00ce318..16a855c7 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.ts @@ -1,4 +1,8 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + SIGNING_PROVIDER_TRANSFER, + SigningProvider, +} from './signing/signing-provider.interface'; import { PrismaService } from '../shared/database/prisma.service'; import { InitiateTransferDto } from './dto/transfer.dto'; import * as StellarSdk from '@stellar/stellar-sdk'; @@ -33,7 +37,11 @@ export class TransferService { private readonly rpcServer: StellarSdk.rpc.Server; private readonly networkPassphrase = StellarSdk.Networks.TESTNET; - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + @Inject(SIGNING_PROVIDER_TRANSFER) + private readonly signingProvider: SigningProvider, + ) { this.rpcServer = new StellarSdk.rpc.Server( 'https://soroban-testnet.stellar.org', ); @@ -108,15 +116,10 @@ export class TransferService { `Executing transfer ${transferId} (try ${retryCount + 1})`, ); - // Simulate/Trigger contract call - // In a real environment, we'd sign with the backend's key (acting as spender) - // For this test/integration proxy we'll construct the transaction and submit if a key is provided. - const secret = process.env.STELLAR_SECRET_KEY; - if (secret) { - const keypair = StellarSdk.Keypair.fromSecret(secret); - const sourceAccount = await this.rpcServer.getAccount( - keypair.publicKey(), - ); + // Sign through SigningProvider — simulate only when STELLAR_SIGNING_MODE!=live + if (this.signingProvider.isLive()) { + const publicKey = await this.signingProvider.getPublicKey(); + const sourceAccount = await this.rpcServer.getAccount(publicKey); const contract = new StellarSdk.Contract(contractId); const tx = new StellarSdk.TransactionBuilder(sourceAccount, { @@ -126,7 +129,7 @@ export class TransferService { .addOperation( contract.call( 'transfer_from', - nativeToScVal(keypair.publicKey(), { type: 'address' }), + nativeToScVal(publicKey, { type: 'address' }), nativeToScVal(fromAddress, { type: 'address' }), nativeToScVal(toAddress, { type: 'address' }), nativeToScVal(amount, { type: 'i128' }), @@ -135,9 +138,24 @@ export class TransferService { .setTimeout(30) .build(); - tx.sign(keypair); + const signed = await this.signingProvider.signTransaction( + tx.toXDR(), + this.networkPassphrase, + ); + this.logger.log( + JSON.stringify({ + event: 'transfer_signed', + transferId, + signingPublicKey: signed.publicKey, + keyId: signed.keyId ?? this.signingProvider.keyId, + }), + ); + const signedTx = StellarSdk.TransactionBuilder.fromXDR( + signed.signedXdr, + this.networkPassphrase, + ); - const response = await this.rpcServer.sendTransaction(tx); + const response = await this.rpcServer.sendTransaction(signedTx as any); if (response.status === 'ERROR') { throw new Error(`Stellar RPC Error: ${JSON.stringify(response)}`); @@ -146,8 +164,6 @@ export class TransferService { const hash = response.hash; const prisma = this.prisma as any; - // Broadcast acknowledged: SUBMITTED -> PENDING. The hash is now - // available so the UI can link out to the explorer while it waits. await prisma.creditTransfer.update({ where: { id: transferId }, data: { @@ -164,9 +180,9 @@ export class TransferService { }, }); } else { - // Mock successful transaction if no secret key provided this.logger.warn( - `No STELLAR_SECRET_KEY provided, simulating successful transfer for ${transferId}`, + `STELLAR_SIGNING_MODE is not live — simulating transfer ${transferId} ` + + `(keyId=${this.signingProvider.keyId})`, ); const mockHash = `simulated_tx_${Date.now()}`; const prisma = this.prisma as any; diff --git a/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.test.ts b/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.test.ts new file mode 100644 index 00000000..5040351d --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.test.ts @@ -0,0 +1,25 @@ +/** + * @jest-environment jsdom + */ + +describe('LiveRetirementFeed reconnect/dedupe design (#552)', () => { + it('documents backoff bounds', () => { + const BASE = 1000; + const MAX = 30_000; + let b = BASE; + for (let i = 0; i < 10; i++) b = Math.min(MAX, b * 2); + expect(b).toBe(MAX); + }); + + it('dedupes by id using a Set', () => { + const seen = new Set(); + const push = (id: string) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }; + expect(push('r1')).toBe(true); + expect(push('r1')).toBe(false); + expect(push('r2')).toBe(true); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.tsx b/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.tsx index 4d594365..6af69416 100644 --- a/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.tsx +++ b/corporate-platform/corporate-platform-web/src/components/feed/LiveRetirementFeed.tsx @@ -1,88 +1,282 @@ -'use client' +'use client'; -import { useState, useEffect } from 'react' -import { CheckCircle, Globe, Building, Clock } from 'lucide-react' +/** + * Live retirement event feed (#552) + * + * Connects to the backend retirement stream via WebSocket (SSE fallback URL + * documented below). Features: + * - Exponential-backoff reconnect with capped interval + * - connected | reconnecting | disconnected | error UI states + * - Pause unsubscribes (closes socket) rather than a cosmetic interval + * - Event deduplication by retirement/transaction id + * - Pauses while document.visibilityState === 'hidden' + */ -const mockLiveRetirements = [ - { company: 'Microsoft', amount: 5000, project: 'Amazon Rainforest', time: '2 minutes ago' }, - { company: 'Google', amount: 3000, project: 'Kenya Solar Farms', time: '5 minutes ago' }, - { company: 'Salesforce', amount: 7500, project: 'Indonesia Mangroves', time: '12 minutes ago' }, - { company: 'Apple', amount: 4200, project: 'US Regenerative Ag', time: '25 minutes ago' }, - { company: 'Meta', amount: 1800, project: 'India Wind Power', time: '45 minutes ago' }, -] +import React, { useCallback, useEffect, useRef, useState } from 'react'; -export default function LiveRetirementFeed() { - const [retirements, setRetirements] = useState(mockLiveRetirements) - const [isLive, setIsLive] = useState(true) +export type FeedConnectionState = + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnected' + | 'error'; - useEffect(() => { - const interval = setInterval(() => { - if (isLive && Math.random() > 0.7) { - const newRetirement = { - company: ['Amazon', 'Tesla', 'NVIDIA', 'Adobe'][Math.floor(Math.random() * 4)], - amount: Math.floor(Math.random() * 10000) + 1000, - project: ['Brazil Conservation', 'African Clean Cookstoves', 'EU Reforestation'][Math.floor(Math.random() * 3)], - time: 'Just now', +export interface RetirementFeedEvent { + id: string; + companyName: string; + projectName: string; + amount: number; + unit?: string; + timestamp: string; + transactionHash?: string; +} + +const MAX_ITEMS = 50; +const BASE_BACKOFF_MS = 1000; +const MAX_BACKOFF_MS = 30_000; + +function resolveStreamUrl(): string { + if (typeof window === 'undefined') return ''; + const envUrl = process.env.NEXT_PUBLIC_RETIREMENT_STREAM_URL; + if (envUrl) return envUrl; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/api/v1/retirements/stream`; +} + +export function LiveRetirementFeed() { + const [events, setEvents] = useState([]); + const [connectionState, setConnectionState] = + useState('connecting'); + const [paused, setPaused] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const wsRef = useRef(null); + const seenIdsRef = useRef>(new Set()); + const backoffRef = useRef(BASE_BACKOFF_MS); + const reconnectTimerRef = useRef | null>(null); + const intentionalCloseRef = useRef(false); + const visibleRef = useRef(true); + + const pushEvent = useCallback((evt: RetirementFeedEvent) => { + if (!evt?.id) return; + if (seenIdsRef.current.has(evt.id)) return; // dedupe + seenIdsRef.current.add(evt.id); + setEvents((prev) => [evt, ...prev].slice(0, MAX_ITEMS)); + }, []); + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const disconnect = useCallback(() => { + intentionalCloseRef.current = true; + clearReconnectTimer(); + if (wsRef.current) { + try { + wsRef.current.close(); + } catch { + // ignore + } + wsRef.current = null; + } + }, []); + + const connect = useCallback(() => { + if (typeof window === 'undefined') return; + if (paused || !visibleRef.current) return; + + disconnect(); + intentionalCloseRef.current = false; + setConnectionState((s) => + s === 'connected' ? 'connected' : s === 'connecting' ? 'connecting' : 'reconnecting', + ); + setErrorMessage(null); + + const url = resolveStreamUrl(); + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch (err) { + setConnectionState('error'); + setErrorMessage((err as Error).message || 'Failed to open WebSocket'); + scheduleReconnect(); + return; + } + wsRef.current = ws; + + ws.onopen = () => { + backoffRef.current = BASE_BACKOFF_MS; + setConnectionState('connected'); + setErrorMessage(null); + }; + + ws.onmessage = (message) => { + try { + const data = JSON.parse(message.data as string); + const list = Array.isArray(data) ? data : [data]; + for (const item of list) { + if (!item) continue; + pushEvent({ + id: String(item.id || item.transactionHash || item.txHash || ''), + companyName: String(item.companyName || item.company || 'Unknown'), + projectName: String(item.projectName || item.project || '—'), + amount: Number(item.amount ?? 0), + unit: item.unit || 'tCO2e', + timestamp: item.timestamp || item.createdAt || new Date().toISOString(), + transactionHash: item.transactionHash || item.txHash, + }); } - setRetirements(prev => [newRetirement, ...prev.slice(0, 4)]) + } catch { + // ignore malformed frames } - }, 10000) + }; + + ws.onerror = () => { + setConnectionState('error'); + setErrorMessage('Stream connection error'); + }; - return () => clearInterval(interval) - }, [isLive]) + ws.onclose = () => { + wsRef.current = null; + if (intentionalCloseRef.current || paused || !visibleRef.current) { + setConnectionState(paused ? 'disconnected' : 'disconnected'); + return; + } + setConnectionState('reconnecting'); + scheduleReconnect(); + }; + + function scheduleReconnect() { + clearReconnectTimer(); + const delay = backoffRef.current; + backoffRef.current = Math.min(MAX_BACKOFF_MS, backoffRef.current * 2); + reconnectTimerRef.current = setTimeout(() => { + if (!paused && visibleRef.current) { + setConnectionState('reconnecting'); + connect(); + } + }, delay); + } + }, [disconnect, paused, pushEvent]); + + // Initial connect + pause handling + useEffect(() => { + if (paused) { + disconnect(); + setConnectionState('disconnected'); + return; + } + connect(); + return () => disconnect(); + }, [paused, connect, disconnect]); + + // Tab visibility: pause subscription when hidden + useEffect(() => { + const onVis = () => { + visibleRef.current = document.visibilityState === 'visible'; + if (!visibleRef.current) { + disconnect(); + setConnectionState('disconnected'); + } else if (!paused) { + connect(); + } + }; + document.addEventListener('visibilitychange', onVis); + return () => document.removeEventListener('visibilitychange', onVis); + }, [connect, disconnect, paused]); + + const badge = (() => { + switch (connectionState) { + case 'connected': + return { label: 'LIVE', className: 'bg-green-500 animate-pulse' }; + case 'reconnecting': + case 'connecting': + return { label: 'RECONNECTING', className: 'bg-amber-500' }; + case 'error': + return { label: 'ERROR', className: 'bg-red-600' }; + default: + return { label: paused ? 'PAUSED' : 'OFFLINE', className: 'bg-gray-500' }; + } + })(); return ( -
-
-
-

Live Retirement Feed

-
-
- {isLive ? 'LIVE' : 'PAUSED'} -
+
+
+
+ + {badge.label} + +

Live retirements

+
+
+ + {connectionState === 'error' && ( + + )}
-
-
- {retirements.map((retirement, index) => ( -
-
- + {errorMessage && ( +

+ {errorMessage} +

+ )} + + {connectionState === 'connecting' && events.length === 0 && ( +
+
+
+
+
+ )} + +
    + {events.map((e) => ( +
  • +
    +
    {e.companyName}
    +
    {e.projectName}
    -
    -
    -
    {retirement.company}
    -
    {retirement.amount.toLocaleString()} tCO₂
    +
    +
    + {e.amount} {e.unit || 'tCO2e'}
    -
    - - {retirement.project} - - {retirement.time} +
    + {new Date(e.timestamp).toLocaleTimeString()}
    -
    +
  • ))} -
- -
-
-
- - Your company retired 15,000 tCO₂ this month -
-
-
+ {events.length === 0 && connectionState === 'connected' && ( +
  • Waiting for retirement events…
  • + )} +
    - ) -} \ No newline at end of file + ); +} + +export default LiveRetirementFeed; diff --git a/corporate-platform/corporate-platform-web/src/components/retirement/LiveRetirementFeed.tsx b/corporate-platform/corporate-platform-web/src/components/retirement/LiveRetirementFeed.tsx index e69de29b..a85edada 100644 --- a/corporate-platform/corporate-platform-web/src/components/retirement/LiveRetirementFeed.tsx +++ b/corporate-platform/corporate-platform-web/src/components/retirement/LiveRetirementFeed.tsx @@ -0,0 +1 @@ +export { LiveRetirementFeed, default } from '../feed/LiveRetirementFeed'; diff --git a/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx b/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx index 84235326..95157f30 100644 --- a/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx +++ b/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx @@ -35,6 +35,13 @@ import { import { reportError } from '@/lib/telemetry/errorReporter'; import { useHydrated } from '@/hooks/useHydrated'; import { isClient, safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils/hydration'; +import { + broadcastAuthEvent, + createAuthChannel, + isAuthStorageKey, + tryAcquireRefreshLeadership, + type AuthBroadcastMessage, +} from '@/lib/auth/cross-tab-auth'; export type SessionExpiryState = 'active' | 'warning' | 'grace' | 'expired'; @@ -160,6 +167,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return false; } + broadcastAuthEvent((window as any).__csAuthChannel ?? null, 'refresh'); return true; } catch (error) { reportError(error, 'AuthContext', 'warning', { operation: 'refreshToken' }); @@ -203,6 +211,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { throw new Error('Unable to load user profile after login') } + broadcastAuthEvent((window as any).__csAuthChannel ?? null, 'login') router.push('/') } catch (error) { reportError(error, 'AuthContext', 'error', { operation: 'login' }) @@ -293,8 +302,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Attempt silent auto-refresh within the refresh buffer window if (remaining <= TOKEN_REFRESH_BUFFER && now - lastRefreshAttempt > 30000) { lastRefreshAttempt = now; - await refreshTokenSilently(); - // If successful the expiry timestamp updates; next tick clears the warning + // Only the elected leader tab performs proactive refresh (#550) + if (tryAcquireRefreshLeadership()) { + await refreshTokenSilently(); + } + // Non-leaders pick up new tokens via storage / BroadcastChannel } } else { // Access token has expired — start / continue grace period @@ -324,8 +336,85 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => clearInterval(interval); }, [user, pathname, router, refreshTokenSilently]); + // Cross-tab auth sync (#550): BroadcastChannel + storage events + useEffect(() => { + if (!isHydrated || typeof window === 'undefined') return; + + const channel = createAuthChannel(); + (window as any).__csAuthChannel = channel; + + const handleRemoteLogout = () => { + clearAuthData(); + setUser(null); + if (!isPublicRoute(pathname || '/')) { + router.push('/login'); + } + }; + + const handleRemoteLoginOrRefresh = async () => { + const token = getAccessToken(); + if (!token) { + handleRemoteLogout(); + return; + } + if (isTokenExpired(token)) { + const ok = await refreshTokenSilently(); + if (!ok) handleRemoteLogout(); + return; + } + await syncProfile(token); + }; + + const onBroadcast = (event: MessageEvent) => { + const msg = event.data; + if (!msg || msg.source === undefined) return; + if (msg.type === 'logout') { + handleRemoteLogout(); + } else if (msg.type === 'login' || msg.type === 'refresh' || msg.type === 'profile') { + void handleRemoteLoginOrRefresh(); + } + }; + + const onStorage = (event: StorageEvent) => { + if (!isAuthStorageKey(event.key)) return; + if (event.key === 'cs_access_token' && !event.newValue) { + handleRemoteLogout(); + return; + } + if (event.key === 'cs_access_token' && event.newValue) { + void handleRemoteLoginOrRefresh(); + return; + } + if (event.key === 'cs_user' && event.newValue) { + void handleRemoteLoginOrRefresh(); + } + if (event.key === 'cs_auth_event' && event.newValue) { + try { + const msg = JSON.parse(event.newValue) as AuthBroadcastMessage; + if (msg.type === 'logout') handleRemoteLogout(); + else if (msg.type === 'login' || msg.type === 'refresh') void handleRemoteLoginOrRefresh(); + } catch { + // ignore + } + } + }; + + channel?.addEventListener('message', onBroadcast); + window.addEventListener('storage', onStorage); + + return () => { + channel?.removeEventListener('message', onBroadcast); + channel?.close(); + window.removeEventListener('storage', onStorage); + if ((window as any).__csAuthChannel === channel) { + delete (window as any).__csAuthChannel; + } + }; + }, [isHydrated, pathname, router, refreshTokenSilently, syncProfile]); + // Protect routes - only runs after hydration useEffect(() => { + if (!isHydrated) return; if (isLoading) return; // Wait for auth initialization diff --git a/corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.test.ts b/corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.test.ts new file mode 100644 index 00000000..b520a87c --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.test.ts @@ -0,0 +1,33 @@ +import { + AUTH_CHANNEL_NAME, + broadcastAuthEvent, + isAuthStorageKey, + tryAcquireRefreshLeadership, +} from './cross-tab-auth'; + +describe('cross-tab-auth (#550)', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('identifies auth storage keys', () => { + expect(isAuthStorageKey('cs_access_token')).toBe(true); + expect(isAuthStorageKey('cs_refresh_token')).toBe(true); + expect(isAuthStorageKey('cs_user')).toBe(true); + expect(isAuthStorageKey('unrelated')).toBe(false); + }); + + it('leader election allows only one active leader within TTL', () => { + expect(tryAcquireRefreshLeadership()).toBe(true); + // Same tab can renew + expect(tryAcquireRefreshLeadership()).toBe(true); + }); + + it('broadcastAuthEvent writes coordination key without throwing', () => { + expect(() => broadcastAuthEvent(null, 'logout')).not.toThrow(); + }); + + it('channel name is stable', () => { + expect(AUTH_CHANNEL_NAME).toBe('cs-auth'); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.ts b/corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.ts new file mode 100644 index 00000000..6fce3b40 --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/lib/auth/cross-tab-auth.ts @@ -0,0 +1,106 @@ +/** + * Cross-tab auth synchronization (#550) + * + * Design: + * - BroadcastChannel('cs-auth') for instant login/logout/refresh events when supported. + * - window 'storage' events as a fallback (and for older browsers without BroadcastChannel). + * - localStorage leader lock so only one tab runs proactive token refresh. + * + * Message types: login | logout | refresh | profile + */ + +export type AuthBroadcastType = 'login' | 'logout' | 'refresh' | 'profile'; + +export interface AuthBroadcastMessage { + type: AuthBroadcastType; + ts: number; + source: string; +} + +export const AUTH_CHANNEL_NAME = 'cs-auth'; +export const AUTH_LEADER_KEY = 'cs_auth_leader'; +export const AUTH_STORAGE_KEYS = [ + 'cs_access_token', + 'cs_refresh_token', + 'cs_user', +] as const; + +const TAB_ID = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `tab_${Date.now()}_${Math.random().toString(16).slice(2)}`; + +const LEADER_TTL_MS = 5000; + +export function getTabId(): string { + return TAB_ID; +} + +export function createAuthChannel(): BroadcastChannel | null { + if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') { + return null; + } + try { + return new BroadcastChannel(AUTH_CHANNEL_NAME); + } catch { + return null; + } +} + +export function broadcastAuthEvent( + channel: BroadcastChannel | null, + type: AuthBroadcastType, +): void { + const message: AuthBroadcastMessage = { + type, + ts: Date.now(), + source: TAB_ID, + }; + try { + channel?.postMessage(message); + } catch { + // ignore + } + // Also touch a coordination key so storage listeners fire even without BC + if (typeof window !== 'undefined') { + try { + localStorage.setItem('cs_auth_event', JSON.stringify(message)); + localStorage.removeItem('cs_auth_event'); + } catch { + // ignore + } + } +} + +/** + * Try to become / remain the refresh leader. + * Returns true if this tab should perform proactive refresh. + */ +export function tryAcquireRefreshLeadership(): boolean { + if (typeof window === 'undefined') return true; + try { + const now = Date.now(); + const raw = localStorage.getItem(AUTH_LEADER_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { id: string; ts: number }; + if (parsed.id !== TAB_ID && now - parsed.ts < LEADER_TTL_MS) { + return false; + } + } + localStorage.setItem( + AUTH_LEADER_KEY, + JSON.stringify({ id: TAB_ID, ts: now }), + ); + return true; + } catch { + return true; + } +} + +export function isAuthStorageKey(key: string | null): boolean { + if (!key) return false; + return ( + AUTH_STORAGE_KEYS.includes(key as (typeof AUTH_STORAGE_KEYS)[number]) || + key === 'cs_auth_event' + ); +} From 3bae01524411a6ac5325f83c40bcf3ef0967ea5a Mon Sep 17 00:00:00 2001 From: Fadeedev Date: Sun, 30 Aug 2026 23:23:10 +0100 Subject: [PATCH 2/2] chores --- .../src/stellar/transfer.service.spec.ts | 24 +++++++++++++++---- .../src/contexts/AuthContext.tsx | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.spec.ts b/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.spec.ts index 9476c923..2cf7f6fd 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.spec.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/transfer.service.spec.ts @@ -2,12 +2,28 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TransferService } from './transfer.service'; import { PrismaService } from '../shared/database/prisma.service'; import { InitiateTransferDto } from './dto/transfer.dto'; +import { + SIGNING_PROVIDER_TRANSFER, + SigningProvider, +} from './signing/signing-provider.interface'; describe('TransferService', () => { let service: TransferService; let prisma: jest.Mocked; + let signingProvider: jest.Mocked; beforeEach(async () => { + // The service signs through SigningProvider (#542); it must never reach + // for process.env.STELLAR_SECRET_KEY itself. isLive() false keeps the + // async execution path in simulate mode. + signingProvider = { + keyId: 'test-transfer-key', + category: 'transfer', + getPublicKey: jest.fn().mockResolvedValue('GTEST'), + signTransaction: jest.fn(), + isLive: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + const module: TestingModule = await Test.createTestingModule({ providers: [ TransferService, @@ -21,6 +37,10 @@ describe('TransferService', () => { }, }, }, + { + provide: SIGNING_PROVIDER_TRANSFER, + useValue: signingProvider, + }, ], }).compile(); @@ -49,9 +69,6 @@ describe('TransferService', () => { status: 'PENDING', } as any); - // Provide mock secret to prevent simulating transfer actually running - process.env.STELLAR_SECRET_KEY = ''; - const result = await service.initiateTransfer(dto); expect(result.id).toEqual('transfer-1'); expect(prisma.creditTransfer.create).toHaveBeenCalled(); @@ -76,7 +93,6 @@ describe('TransferService', () => { id: 'transfer-2', status: 'SUBMITTED', } as any); - process.env.STELLAR_SECRET_KEY = ''; await service.initiateTransfer(dto); diff --git a/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx b/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx index 95157f30..5a308960 100644 --- a/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx +++ b/corporate-platform/corporate-platform-web/src/contexts/AuthContext.tsx @@ -357,7 +357,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { handleRemoteLogout(); return; } - if (isTokenExpired(token)) { + if (isTokenExpired()) { const ok = await refreshTokenSilently(); if (!ok) handleRemoteLogout(); return;