GenerAI / worldmonitor /src /app /pro-activation-controller.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 6)
fa9c65f verified
Raw
History Blame Contribute Delete
21.7 kB
import type { AppContext, AppModule } from '@/app/app-context';
import type { ProActivationFlowOptions } from '@/components/ProActivationInterstitial';
import { trackProActivation } from '@/services/analytics';
import { getAuthState, subscribeAuthState } from '@/services/auth-state';
import {
getSubscription,
onSubscriptionChange,
type SubscriptionInfo,
} from '@/services/billing';
import {
getEntitlementState,
hasFeature,
onEntitlementChange,
type EntitlementState,
} from '@/services/entitlements';
import {
computeFireOnceRecord,
computeMountClaim,
computePendingMarker,
decideActivationMount,
activationFlowRetryDelay,
deriveActivationAccountKey,
deriveSubscriptionKey,
isFireOnceActive,
isMountClaimBlocking,
isProPlanKey,
MOUNT_CLAIM_KEY,
MOUNT_CLAIM_TTL_MS,
parseMountClaim,
parsePendingMarker,
PENDING_MARKER_KEY,
readScopedFireOnceRecord,
writeScopedFireOnceRecord,
type ActivationEntitlementSnapshot,
type ActivationSubscriptionSnapshot,
type FireOnceRecord,
type MountClaimRecord,
type PendingOnboardingMarker,
} from '@/services/pro-activation-state';
const MOUNT_CLAIM_SETTLE_MS = 75;
const MOUNT_CLAIM_RETRY_GRACE_MS = 25;
function readPendingMarker(): PendingOnboardingMarker | null {
try {
const raw = window.localStorage.getItem(PENDING_MARKER_KEY);
const marker = parsePendingMarker(raw);
if (marker && raw !== JSON.stringify(marker)) writePendingMarker(marker);
return marker;
} catch {
return null;
}
}
function writePendingMarker(marker: PendingOnboardingMarker): boolean {
try {
window.localStorage.setItem(PENDING_MARKER_KEY, JSON.stringify(marker));
return true;
} catch {
return false;
}
}
function clearPendingMarker(): void {
try {
window.localStorage.removeItem(PENDING_MARKER_KEY);
} catch {
// A stale marker is reaped by its TTL.
}
}
function readMountClaim(): MountClaimRecord | null {
try {
return parseMountClaim(window.localStorage.getItem(MOUNT_CLAIM_KEY));
} catch {
return null;
}
}
function writeMountClaim(record: MountClaimRecord): void {
try {
window.localStorage.setItem(MOUNT_CLAIM_KEY, JSON.stringify(record));
} catch {
// Storage-disabled browsers fall back to the in-tab latch.
}
}
function clearMountClaim(): void {
try {
window.localStorage.removeItem(MOUNT_CLAIM_KEY);
} catch {
// The claim self-expires.
}
}
function clearMountClaimIfOwned(nonce: string): void {
if (readMountClaim()?.nonce === nonce) clearMountClaim();
}
function claimSettle(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, MOUNT_CLAIM_SETTLE_MS));
}
function generateMountClaimNonce(): string {
try {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
} catch {
// Fall through.
}
return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function toEntitlementSnapshot(
state: EntitlementState | null,
): ActivationEntitlementSnapshot | null {
if (state === null) return null;
return { planKey: state.planKey, validUntil: state.validUntil };
}
function toSubscriptionSnapshot(
subscription: SubscriptionInfo | null,
): ActivationSubscriptionSnapshot | null {
if (subscription === null) return null;
return {
activationKey: subscription.activationKey ?? null,
planKey: subscription.planKey,
currentPeriodEnd: subscription.currentPeriodEnd,
activationOnboardingEligible: subscription.activationOnboardingEligible ?? null,
};
}
/** Write the durable checkout-return marker without persisting a Clerk user id. */
export function markProActivationPending(productId: string | null, now = Date.now()): void {
const accountKey = deriveActivationAccountKey(getAuthState().user?.id);
writePendingMarker(computePendingMarker(productId, accountKey, now));
}
export interface ProActivationControllerOptions {
/** Checkout-return boot reloads immediately; evaluate only on the next boot. */
reloadPending: boolean;
/** Panel-owned surface opener that cannot be implemented outside the layout. */
openAiAnalyst: () => void;
/** App-owned global command-search opener. */
openSearch?: () => void;
}
type ChipDecision = 'show' | 'wait' | 'hide';
/**
* Owns the activation boot/storage/retry lifecycle. PanelLayoutManager only
* creates, starts, and destroys this controller.
*/
export class ProActivationController implements AppModule {
private resolved = false;
private mounting = false;
private retryArmed = false;
private retryUnsubscribers: Array<() => void> = [];
private mountIdleHandle: number | null = null;
private mountTimeoutHandle: number | null = null;
private claimRetryHandle: number | null = null;
private flowRetryHandle: number | null = null;
private flowRetryAttempts = 0;
private authUnsubscribe: (() => void) | null = null;
private authStateKey: string | null = null;
private authGeneration = 0;
private readonly mountNonce = generateMountClaimNonce();
private readonly mountSessionStartedAt = Date.now();
private lastDay0SessionStartedAt = this.mountSessionStartedAt;
constructor(
private readonly ctx: AppContext,
private readonly options: ProActivationControllerOptions,
) {}
init(): void {
if (typeof window === 'undefined' || this.options.reloadPending) return;
this.authStateKey = this.activationAuthStateKey();
let initialAuthEmission = true;
this.authUnsubscribe = subscribeAuthState(() => {
const nextAuthStateKey = this.activationAuthStateKey();
if (initialAuthEmission) {
initialAuthEmission = false;
this.authStateKey = nextAuthStateKey;
return;
}
if (nextAuthStateKey === this.authStateKey) {
if (!this.resolved && !this.mounting) queueMicrotask(() => void this.evaluate());
return;
}
this.authStateKey = nextAuthStateKey;
this.resetForAuthTransition();
});
const run = (): void => {
this.mountIdleHandle = null;
this.mountTimeoutHandle = null;
if (!this.ctx.isDestroyed) void this.evaluate();
};
const idle = (window as typeof window & {
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number;
}).requestIdleCallback;
if (typeof idle === 'function') this.mountIdleHandle = idle(run, { timeout: 2000 });
else this.mountTimeoutHandle = window.setTimeout(run, 800);
}
destroy(): void {
this.authGeneration += 1;
this.authUnsubscribe?.();
this.authUnsubscribe = null;
this.teardownRetry();
this.cancelScheduledMount();
this.clearClaimRetry();
this.clearFlowRetry();
}
private activationAuthStateKey(): string {
const auth = getAuthState();
if (auth.isPending) return 'pending';
return deriveActivationAccountKey(auth.user?.id) ?? 'signed-out';
}
private resetForAuthTransition(): void {
this.authGeneration += 1;
this.resolved = false;
this.teardownRetry();
this.clearClaimRetry();
this.clearFlowRetry();
clearMountClaimIfOwned(this.mountNonce);
queueMicrotask(() => {
if (!this.ctx.isDestroyed) void this.evaluate();
});
}
private async evaluate(): Promise<void> {
if (this.resolved || this.mounting || this.ctx.isDestroyed) return;
const now = Date.now();
const auth = getAuthState();
const currentAccountKey = deriveActivationAccountKey(auth.user?.id);
let marker = readPendingMarker();
if (marker && !marker.accountKey && currentAccountKey) {
marker = { ...marker, accountKey: currentAccountKey };
writePendingMarker(marker);
}
const entitlement = toEntitlementSnapshot(getEntitlementState());
const subscription = toSubscriptionSnapshot(getSubscription());
const currentSubscriptionKey = deriveSubscriptionKey(subscription);
const fireOnce = readScopedFireOnceRecord(
window.localStorage,
currentAccountKey,
currentSubscriptionKey,
now,
);
const decision = decideActivationMount({
marker,
entitlement,
subscription,
fireOnce,
isDesktop: this.ctx.isDesktopApp,
authPending: auth.isPending,
currentAccountKey,
now,
});
switch (decision.action) {
case 'mount':
if (auth.user) {
await this.mount(
decision.subscriptionKey,
auth.user.id,
now,
decision.onlyIfUnactivated,
);
}
else this.armRetry();
return;
case 'clear':
clearPendingMarker();
this.finishWithChip(fireOnce, entitlement, subscription, auth.isPending, auth.user?.id ?? null, now);
return;
case 'none':
this.finishWithChip(fireOnce, entitlement, subscription, auth.isPending, auth.user?.id ?? null, now);
return;
case 'keep':
this.armRetry();
return;
}
}
private async mount(
subscriptionKey: string,
expectedUserId: string,
now: number,
onlyIfUnactivated: boolean,
): Promise<void> {
const authGeneration = this.authGeneration;
if (getAuthState().user?.id !== expectedUserId) {
this.armRetry();
return;
}
// Claim settlement awaits a timer. Hold a local single-flight latch before
// that await so synchronous auth/subscription listener replays cannot start
// a second mount attempt in this tab. The try/finally guarantees the latch
// releases on every exit path, including an unexpected throw.
this.mounting = true;
try {
const existingClaim = readMountClaim();
if (isMountClaimBlocking(existingClaim, this.mountNonce, now)) {
this.armRetry();
this.scheduleClaimRetry(existingClaim, now);
return;
}
writeMountClaim(computeMountClaim(this.mountNonce, now));
await claimSettle();
if (this.abortIfStale(authGeneration)) return;
if (getAuthState().user?.id !== expectedUserId) {
clearMountClaimIfOwned(this.mountNonce);
this.armRetry();
return;
}
const settledClaim = readMountClaim();
const settledAt = Date.now();
if (isMountClaimBlocking(settledClaim, this.mountNonce, settledAt)) {
this.armRetry();
this.scheduleClaimRetry(settledClaim, settledAt);
return;
}
// This tab owns the cross-tab claim. A loser remains retryable when the
// winner crashes and its lease expires.
this.teardownRetry();
this.clearClaimRetry();
const openResult = await this.openFlow(
expectedUserId,
subscriptionKey,
onlyIfUnactivated,
() => !this.ctx.isDestroyed && authGeneration === this.authGeneration,
);
if (this.abortIfStale(authGeneration)) return;
if (openResult === 'not-eligible') {
this.resolved = true;
this.clearFlowRetry();
clearMountClaimIfOwned(this.mountNonce);
return;
}
if (openResult !== 'opened') {
clearMountClaimIfOwned(this.mountNonce);
this.scheduleFlowRetry();
return;
}
this.resolved = true;
this.clearFlowRetry();
// Never consume the pending marker unless durable fire-once persistence
// succeeded. Storage failures can then retry on a later boot.
const accountKey = deriveActivationAccountKey(expectedUserId);
if (
accountKey &&
writeScopedFireOnceRecord(
window.localStorage,
accountKey,
computeFireOnceRecord(subscriptionKey, now),
)
) {
clearPendingMarker();
}
clearMountClaimIfOwned(this.mountNonce);
} finally {
this.mounting = false;
}
}
/**
* True (and cleaned up) once this in-flight mount attempt is no longer
* relevant — the tab was destroyed, or a newer auth generation (sign-out,
* account switch) superseded it. Also releases the cross-tab claim and, for
* a live tab, schedules a fresh evaluate() so the superseding attempt isn't
* stranded waiting on a claim this attempt still held.
*/
private abortIfStale(authGeneration: number): boolean {
if (!this.ctx.isDestroyed && authGeneration === this.authGeneration) return false;
clearMountClaimIfOwned(this.mountNonce);
if (!this.ctx.isDestroyed) queueMicrotask(() => void this.evaluate());
return true;
}
private finishWithChip(
fireOnce: FireOnceRecord | null,
entitlement: ActivationEntitlementSnapshot | null,
subscription: ActivationSubscriptionSnapshot | null,
authPending: boolean,
userId: string | null,
now: number,
): void {
const decision = this.chipDecision(
fireOnce,
entitlement,
subscription,
authPending,
userId,
now,
);
if (decision === 'wait') {
this.armRetry();
return;
}
this.resolved = true;
this.teardownRetry();
this.clearClaimRetry();
this.clearFlowRetry();
if (decision !== 'show') return;
const flowOptions = this.buildFlowOptions();
const subscriptionKey = deriveSubscriptionKey(subscription);
if (!flowOptions || subscriptionKey === null) return;
void import('@/components/ProActivationChip')
.then((module) =>
module.maybeShowFinishSetupChip({
...flowOptions,
onlyIfUnactivated: false,
expectedActivationKey: subscriptionKey,
}),
)
.catch((error) => console.warn('[pro-activation] finish-setup chip failed to load', error));
}
private chipDecision(
fireOnce: FireOnceRecord | null,
entitlement: ActivationEntitlementSnapshot | null,
subscription: ActivationSubscriptionSnapshot | null,
authPending: boolean,
userId: string | null,
now: number,
): ChipDecision {
if (this.ctx.isDesktopApp || !isFireOnceActive(fireOnce, now)) return 'hide';
if (authPending || entitlement === null || subscription === null) return 'wait';
if (userId === null) return 'hide';
if (!isProPlanKey(entitlement.planKey) || entitlement.validUntil < now) return 'hide';
const subscriptionKey = deriveSubscriptionKey(subscription);
if (subscriptionKey === null) return 'wait';
return subscriptionKey === fireOnce.subscriptionKey ? 'show' : 'hide';
}
private armRetry(): void {
if (this.retryArmed || this.resolved) return;
this.retryArmed = true;
// Both listener services replay their current snapshot SYNCHRONOUSLY on
// subscribe when state is already loaded. armRetry is only called right
// after an evaluate() consumed that exact snapshot, so the replay is
// always redundant — swallow the first invocation per registration or the
// exhaustion fallback in scheduleFlowRetry() would instantly re-trigger
// evaluate() and recreate the retry loop it was meant to stop.
const skipSyncReplay = (cb: () => void): (() => void) => {
let first = true;
return () => {
if (first) {
first = false;
return;
}
cb();
};
};
const reEvaluate = (): void => {
queueMicrotask(() => void this.evaluate());
};
this.retryUnsubscribers.push(onEntitlementChange(skipSyncReplay(reEvaluate)));
this.retryUnsubscribers.push(onSubscriptionChange(skipSyncReplay(reEvaluate)));
}
private teardownRetry(): void {
for (const unsubscribe of this.retryUnsubscribers) {
try {
unsubscribe();
} catch {
// Listener registry removal is best-effort during teardown.
}
}
this.retryUnsubscribers = [];
this.retryArmed = false;
}
private scheduleClaimRetry(claim: MountClaimRecord | null, now: number): void {
this.clearClaimRetry();
const remaining = claim
? Math.max(0, MOUNT_CLAIM_TTL_MS - (now - claim.claimedAt))
: MOUNT_CLAIM_TTL_MS;
this.claimRetryHandle = window.setTimeout(() => {
this.claimRetryHandle = null;
void this.evaluate();
}, remaining + MOUNT_CLAIM_RETRY_GRACE_MS);
}
private clearClaimRetry(): void {
if (this.claimRetryHandle === null) return;
window.clearTimeout(this.claimRetryHandle);
this.claimRetryHandle = null;
}
private scheduleFlowRetry(): void {
this.teardownRetry();
this.clearFlowRetry(false);
const delay = activationFlowRetryDelay(this.flowRetryAttempts);
if (delay === null) {
// The bounded backoff schedule is exhausted. Stop hammering with a
// timer, but stay unresolved and fall back to the same passive
// entitlement/subscription listeners used elsewhere in this file, so an
// external signal (e.g. the outage that caused the failures clearing)
// can still retrigger evaluate() later in the session. A fresh trigger
// gets its own full backoff schedule rather than an immediate re-give-up.
this.flowRetryAttempts = 0;
this.armRetry();
return;
}
this.flowRetryAttempts += 1;
this.flowRetryHandle = window.setTimeout(() => {
this.flowRetryHandle = null;
void this.evaluate();
}, delay);
}
private clearFlowRetry(resetAttempts = true): void {
if (this.flowRetryHandle !== null) {
window.clearTimeout(this.flowRetryHandle);
this.flowRetryHandle = null;
}
if (resetAttempts) this.flowRetryAttempts = 0;
}
private cancelScheduledMount(): void {
if (this.mountIdleHandle !== null) {
const cancelIdle = window.cancelIdleCallback as ((handle: number) => void) | undefined;
cancelIdle?.(this.mountIdleHandle);
this.mountIdleHandle = null;
}
if (this.mountTimeoutHandle !== null) {
window.clearTimeout(this.mountTimeoutHandle);
this.mountTimeoutHandle = null;
}
}
private async openFlow(
expectedUserId: string,
subscriptionKey: string,
onlyIfUnactivated: boolean,
isStillValid: () => boolean,
): Promise<'opened' | 'not-eligible' | 'retry'> {
const flowOptions = this.buildFlowOptions(expectedUserId);
if (!flowOptions) return 'retry';
try {
const module = await import('@/components/ProActivationInterstitial');
const result = await module.openProActivationFlow({
...flowOptions,
onlyIfUnactivated,
// Both cohorts carry the identity now (#5621). The markerless path uses
// it for its cross-device lease; the day-0 path uses it only to attach
// outcomes to a server-side row, which is why day-0 previously ran with
// these undefined and left its cohort invisible in Convex.
expectedActivationKey: subscriptionKey,
activationClaimNonce: this.mountNonce,
activationSessionStartedAt: this.mountSessionStartedAt,
});
// The interstitial may already be rendered by this point (opened
// synchronously inside openProActivationFlow before it resolves). If
// this tab was destroyed or superseded while that was in flight, close
// it rather than leaving an orphaned overlay wired to a dead context.
if (!isStillValid()) {
module.closeProActivationInterstitial();
return 'retry';
}
return result;
} catch (error) {
console.warn('[pro-activation] failed to open activation flow', error);
return 'retry';
}
}
private buildFlowOptions(expectedUserId?: string): ProActivationFlowOptions | null {
const user = getAuthState().user;
if (!user || (expectedUserId && user.id !== expectedUserId)) return null;
const ctx = this.ctx;
return {
accountUserId: user.id,
accountEmail: user.email,
createDay0SessionIdentity: () => {
const sessionStartedAt = Math.max(Date.now(), this.lastDay0SessionStartedAt + 1);
this.lastDay0SessionStartedAt = sessionStartedAt;
return {
activationClaimNonce: generateMountClaimNonce(),
activationSessionStartedAt: sessionStartedAt,
};
},
// Pro entitles MCP, not the API plans' keys (#5607). Gate on the feature
// rather than the plan key: UnifiedSettings hides both the MCP tab and its
// panel without `mcpAccess`, so deep-linking there would open settings on a
// tab that has no panel to show. A Pro row written before the catalog field
// existed still resolves true — convex/entitlements.ts read-merges catalog
// defaults — so this only fail-closes on an explicit per-user override.
// Leaving the opener undefined makes buildPowerExtra drop the pointer.
openMcpClients: hasFeature('mcpAccess')
? () => {
// Re-check at click time, not just here: the finish-setup chip
// replays this captured options object long after it was built, so
// an entitlement that lapsed in between would otherwise deep-link
// to a tab UnifiedSettings no longer renders.
if (hasFeature('mcpAccess')) ctx.unifiedSettings?.open('mcp-clients');
else ctx.unifiedSettings?.open('settings');
}
: undefined,
openChannelSettings: () => ctx.unifiedSettings?.open('notifications'),
openWidgetBuilder: () =>
ctx.container.dispatchEvent(new CustomEvent('wm:open-widget-creator', { detail: {} })),
openAiAnalyst: this.options.openAiAnalyst,
openSearch: this.options.openSearch,
onEvent: (event, stepId, exit) =>
trackProActivation(event, {
planKey: getEntitlementState()?.planKey ?? null,
step: stepId,
completion: exit?.completion,
verified: exit?.verified,
pending: exit?.pending,
failed: exit?.failed,
total: exit?.total,
}),
};
}
}