diff --git a/packages/vinext/src/server/app-browser-action-result.ts b/packages/vinext/src/server/app-browser-action-result.ts index 3c465572f..cf56e93be 100644 --- a/packages/vinext/src/server/app-browser-action-result.ts +++ b/packages/vinext/src/server/app-browser-action-result.ts @@ -1,6 +1,6 @@ import { ACTION_REVALIDATED_HEADER } from "./headers.js"; import { VINEXT_RSC_CONTENT_TYPE } from "./app-rsc-cache-busting.js"; -import { ServerActionResultFactsV0 } from "./navigation-planner.js"; +import { ServerActionResultFacts } from "./navigation-planner.js"; export type AppBrowserServerActionResult = { root?: TRoot; @@ -124,7 +124,7 @@ export type ServerActionResultResponseFactsInput = { */ export function createServerActionResultFacts( input: ServerActionResultResponseFactsInput, -): ServerActionResultFactsV0 { +): ServerActionResultFacts { return { actionRedirectHref: input.actionRedirectHref, actionRedirectType: input.actionRedirectType === "push" ? "push" : "replace", diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 6e2a24a2a..1f7077e0d 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -177,8 +177,8 @@ import { import { removeStylesheetLinksCoveredByInlineCss } from "./app-inline-css-client.js"; import { navigationPlanner, - type NavigationReuseFactsV0, - type VisitedResponseCacheCandidateFactsV0, + type NavigationReuseFacts, + type VisitedResponseCacheCandidateFacts, } from "./navigation-planner.js"; type SearchParamInput = ConstructorParameters[0]; @@ -714,12 +714,12 @@ type VisitedResponseCacheCandidate = | { cacheKey: string; entry: VisitedResponseCacheEntry; - facts: Extract; + facts: Extract; } | { cacheKey: string; entry: null; - facts: Extract; + facts: Extract; }; function readVisitedResponseCacheCandidate( @@ -1791,7 +1791,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { facts: { candidate: "missing", navigationKind, - } satisfies Extract, + } satisfies Extract, } : readVisitedResponseCacheCandidate( rscUrl, @@ -1806,7 +1806,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { visitedResponseCandidate, visitedResponseDecision, ); - const visitedResponse: NavigationReuseFactsV0["visitedResponse"] = + const visitedResponse: NavigationReuseFacts["visitedResponse"] = cachedRoute === null ? { status: "unavailable" } : { status: "available" }; const prefetchProbeDecision = navigationPlanner.classifyNavigationPrefetchProbe({ bypassNavigationCache: shouldBypassNavigationCache, diff --git a/packages/vinext/src/server/app-browser-server-action-navigation.ts b/packages/vinext/src/server/app-browser-server-action-navigation.ts index 028276c60..ec4e8e912 100644 --- a/packages/vinext/src/server/app-browser-server-action-navigation.ts +++ b/packages/vinext/src/server/app-browser-server-action-navigation.ts @@ -1,11 +1,11 @@ -import type { ServerActionResultDecisionV0 } from "./navigation-planner.js"; +import type { ServerActionResultDecision } from "./navigation-planner.js"; -// Dispatches the executor effects implied by a ServerActionResultDecisionV0. +// Dispatches the executor effects implied by a ServerActionResultDecision. // Returns true if a hard-navigation was triggered (the caller should return early); // false if the decision is "proceed" and normal action processing should continue. // Both callbacks are injected so this function is testable without browser globals. export function applyServerActionResultDecision( - decision: ServerActionResultDecisionV0, + decision: ServerActionResultDecision, clearCaches: () => void, performHardNavigation: (url: string, historyMode?: "assign" | "replace") => void, ): boolean { diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 0fe07ee53..015717c2c 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -27,12 +27,13 @@ import { createCacheEntryReuseProof, type CacheEntryReuseProof } from "./cache-p import { navigationPlanner, resolveDefaultOrUnmatchedSlotPersistenceForLayouts, - type MountedParallelSlotSnapshotV0, - type NavigationDecisionV0, + type MountedParallelSlotSnapshot, + type NavigationDecision, type OperationLane, type OperationToken, - type RouteSnapshotV0, + type RouteSnapshot, } from "./navigation-planner.js"; +import { verifyOperationTokenForCommit, type VerifiedOperationToken } from "./operation-token.js"; import { createSnapshotPathAndSearch, type ClientNavigationRenderSnapshot, @@ -511,18 +512,29 @@ export function resolvePendingNavigationCommitDispositionDecision(options: { targetHref?: string; }): PendingNavigationCommitDispositionDecision { const traceFields = createPendingNavigationTraceFields(options); + const targetSnapshot = createPendingRouteSnapshot(options.pending); + const token = createPendingNavigationOperationToken({ + pending: options.pending, + routeManifest: options.routeManifest ?? null, + startedNavigationId: options.startedNavigationId, + targetSnapshot, + }); - if ( - options.startedNavigationId !== options.activeNavigationId || - options.pending.action.operation.startedVisibleCommitVersion !== - options.currentState.visibleCommitVersion - ) { - // staleOperation — the navigation that created `pending` started from a - // different visibleCommitVersion than the current state. This happens when - // a synchronous history snapshot restore (restoreHistoryStateSnapshot, see + // OperationToken is the single eligibility authority for commit approval: a + // result may enter commit approval only if its token proves it belongs to the + // active navigation and the visible commit version it started from is still + // current. The token verifies; ApprovedVisibleCommit (downstream) mutates. + const verdict = verifyOperationTokenForCommit(token, { + activeNavigationId: options.activeNavigationId, + visibleCommitVersion: options.currentState.visibleCommitVersion, + }); + if (!verdict.authorized) { + // staleOperation — the navigation that created `pending` was superseded, or + // visible state advanced after it started. The latter happens when a + // synchronous history snapshot restore (restoreHistoryStateSnapshot, see // app-browser-entry.ts popstate handler) bumps visibleCommitVersion before - // an in-flight async RSC traverse resolves. The snapshot restore is the - // authoritative commit; the stale async payload is intentionally discarded. + // an in-flight async RSC traverse resolves. The authoritative commit wins; + // the stale async payload is intentionally discarded. return { disposition: "skip", preserveElementIds: [], @@ -536,6 +548,8 @@ export function resolvePendingNavigationCommitDispositionDecision(options: { pending: options.pending, routeManifest: options.routeManifest ?? null, targetHref: options.targetHref, + targetSnapshot, + token: verdict.token, traceFields, }), ); @@ -569,8 +583,8 @@ function createPendingNavigationTraceFields(options: { function createMountedParallelSlotSnapshots( elements: AppElements, -): readonly MountedParallelSlotSnapshotV0[] { - const snapshots: MountedParallelSlotSnapshotV0[] = []; +): readonly MountedParallelSlotSnapshot[] { + const snapshots: MountedParallelSlotSnapshot[] = []; for (const slotId of getMountedSlotIds(elements)) { const parsed = AppElementsWire.parseElementKey(slotId); if (parsed?.kind !== "slot") continue; @@ -582,7 +596,7 @@ function createMountedParallelSlotSnapshots( return snapshots; } -function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { +function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshot { const displayUrl = createSnapshotPathAndSearch(state.navigationSnapshot); const matchedUrl = normalizeNavigationSnapshotMatchedUrl(state.navigationSnapshot.pathname); return { @@ -605,7 +619,7 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { }; } -function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnapshotV0 { +function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnapshot { const displayUrl = createSnapshotPathAndSearch(pending.action.navigationSnapshot); const matchedUrl = normalizeNavigationSnapshotMatchedUrl( pending.action.navigationSnapshot.pathname, @@ -631,19 +645,24 @@ function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnap function createPendingNavigationOperationToken(options: { pending: PendingNavigationCommit; routeManifest: RouteManifest | null; - targetSnapshot: RouteSnapshotV0; + startedNavigationId: number; + targetSnapshot: RouteSnapshot; }): OperationToken { return { baseVisibleCommitVersion: options.pending.action.operation.startedVisibleCommitVersion, deploymentVersion: null, graphVersion: options.routeManifest?.graphVersion ?? null, lane: options.pending.action.operation.lane, + // The lifecycle navigation id the operation started under. operationId + // (renderId) cannot answer "belongs to the active navigation?" because it is + // a per-render counter; navigationId carries that lifecycle authority. + navigationId: options.startedNavigationId, operationId: options.pending.action.operation.id, targetSnapshotFingerprint: createRootBoundarySnapshotFingerprint(options.targetSnapshot), }; } -function createRootBoundarySnapshotFingerprint(snapshot: RouteSnapshotV0): string { +function createRootBoundarySnapshotFingerprint(snapshot: RouteSnapshot): string { return `${snapshot.routeId}|root:${snapshot.rootBoundaryId ?? "unknown"}`; } @@ -652,14 +671,14 @@ function planPendingRootBoundaryFlightResponse(options: { pending: PendingNavigationCommit; routeManifest: RouteManifest | null; targetHref?: string; + // The token has already passed commit eligibility (verifyOperationTokenForCommit) + // in the disposition gate above. Requiring the verified brand here makes that + // ordering a compile-time guarantee: the planner cannot be reached with an + // unverified token. + token: VerifiedOperationToken; + targetSnapshot: RouteSnapshot; traceFields: NavigationTraceFields; -}): NavigationDecisionV0 { - const targetSnapshot = createPendingRouteSnapshot(options.pending); - const token = createPendingNavigationOperationToken({ - pending: options.pending, - routeManifest: options.routeManifest, - targetSnapshot, - }); +}): NavigationDecision { const cacheEntryReuseProof = options.pending.cacheEntryReuseProof; // #726-CORE-07/08 keeps the browser state layer as the lifecycle gate and @@ -669,7 +688,7 @@ function planPendingRootBoundaryFlightResponse(options: { return navigationPlanner.plan({ routeManifest: options.routeManifest, state: { - nextOperationToken: token, + nextOperationToken: options.token, traceFields: options.traceFields, visibleCommitVersion: options.currentState.visibleCommitVersion, visibleSnapshot: createVisibleRouteSnapshot(options.currentState), @@ -682,16 +701,16 @@ function planPendingRootBoundaryFlightResponse(options: { // planner trace and future hard-nav executor agree with the browser // URL. The fallback remains for lower-level tests and direct disposition // callers that exercise only snapshot-derived planner semantics. - href: options.targetHref ?? targetSnapshot.displayUrl, - targetSnapshot, + href: options.targetHref ?? options.targetSnapshot.displayUrl, + targetSnapshot: options.targetSnapshot, }, - token, + token: options.token, }, }); } function mapNavigationDecisionToPendingDisposition( - decision: NavigationDecisionV0, + decision: NavigationDecision, ): PendingNavigationCommitDispositionDecision { switch (decision.kind) { case "proposeCommit": diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 22e0fb5d3..f1b60c2c8 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -32,40 +32,33 @@ import { type NavigationTraceFields, type NavigationTraceReasonCode, } from "./navigation-trace.js"; - -export type OperationLane = - | "hmr" - | "navigation" - | "prefetch" - | "refresh" - | "server-action" - | "traverse"; - -export type OperationToken = { - operationId: number; - lane: OperationLane; - baseVisibleCommitVersion: number; - graphVersion: string | null; - deploymentVersion: string | null; - targetSnapshotFingerprint: string; - cacheVariantFingerprint?: string; -}; - -export type RouteSnapshotV0 = { - interception: InterceptionSnapshotV0 | null; +import { + verifyOperationTokenForCacheReuse, + type OperationLane, + type OperationToken, + type OperationTokenRejectionReason, +} from "./operation-token.js"; + +// OperationToken and OperationLane are owned by ./operation-token.ts, the token +// authority module. Re-exported here so the planner stays the single import +// surface for navigation types. +export type { OperationLane, OperationToken } from "./operation-token.js"; + +export type RouteSnapshot = { + interception: InterceptionSnapshot | null; interceptionContext: string | null; routeId: string; // Ordered ancestor-first, with the root layout at index 0. Same-layout // persistence uses prefix comparison, so callers must preserve this order. layoutIds: readonly string[]; - mountedParallelSlots: readonly MountedParallelSlotSnapshotV0[]; + mountedParallelSlots: readonly MountedParallelSlotSnapshot[]; rootBoundaryId: string | null; displayUrl: string; matchedUrl: string; - slotBindings: readonly ParallelSlotBindingSnapshotV0[]; + slotBindings: readonly ParallelSlotBindingSnapshot[]; }; -export type InterceptionSnapshotV0 = { +export type InterceptionSnapshot = { sourceMatchedUrl: string; sourceRouteId: string; slotId: string; @@ -73,7 +66,7 @@ export type InterceptionSnapshotV0 = { targetRouteId: string; }; -export type MountedParallelSlotSnapshotV0 = { +export type MountedParallelSlotSnapshot = { slotId: string; ownerLayoutId: string | null; }; @@ -82,20 +75,20 @@ export type MountedParallelSlotSnapshotV0 = { // AppElements metadata. Keep the alias explicit so route-state and transport // readers cannot drift into structurally identical but semantically separate // shapes. -export type ParallelSlotBindingSnapshotV0 = AppElementsSlotBinding; +export type ParallelSlotBindingSnapshot = AppElementsSlotBinding; -export type NavigationPlannerStateV0 = { - // V0 keeps a single state shape so intent events and result events can move - // through one planner surface. flightResponseArrived uses event.token; later - // #726 slices can split this by event kind once more result paths are routed - // through the planner. +export type NavigationPlannerState = { + // A single state shape lets intent events and result events move through one + // planner surface. flightResponseArrived uses event.token; later #726 slices + // can split this by event kind once more result paths are routed through the + // planner. nextOperationToken: OperationToken; // Callers that have lifecycle authority should pass the complete trace // context. When absent, the planner emits the stable root-boundary facts it // can derive from the event and visible snapshot. traceFields?: NavigationTraceFields; visibleCommitVersion: number; - visibleSnapshot: RouteSnapshotV0; + visibleSnapshot: RouteSnapshot; }; export type RefreshScope = "visible"; @@ -106,7 +99,7 @@ export type NavigationEvent = | { kind: "refresh"; scope: RefreshScope } | { kind: "traverse"; direction: TraverseDirection; historyState: unknown } | { kind: "prefetch"; href: string } - | { kind: "flightResponseArrived"; token: OperationToken; result: FlightResultV0 }; + | { kind: "flightResponseArrived"; token: OperationToken; result: FlightResult }; type RequestedWork = | { kind: "flight"; href: string; mode: "push" | "replace" | "refresh" } @@ -119,12 +112,13 @@ type CommitProposal = { preserveElementIds: readonly string[]; preservePreviousSlotIds: readonly string[]; reason: "currentRootBoundary" | "interceptedCurrentRootBoundary" | "unprovenTopologyFallback"; - targetSnapshot: RouteSnapshotV0; + targetSnapshot: RouteSnapshot; }; type NoCommitReason = "prefetchOnly"; type HardNavigationReason = | "cacheProofRejected" + | "cacheReuseTokenRejected" | "interceptionProofRejected" | "rootBoundaryChanged"; export type RootBoundaryTransition = @@ -132,7 +126,7 @@ export type RootBoundaryTransition = | "rootBoundaryChanged" | "rootBoundaryUnknown"; -export type NavigationDecisionV0 = +export type NavigationDecision = | { kind: "requestWork"; token: OperationToken; @@ -159,16 +153,16 @@ export type NavigationDecisionV0 = trace: NavigationTrace; }; -export type FlightResultV0 = { +export type FlightResult = { cacheEntryReuseProof?: CacheEntryReuseProof; href: string; - targetSnapshot: RouteSnapshotV0; + targetSnapshot: RouteSnapshot; }; type RscFetchResultSource = "cached" | "live"; type RscRedirectSignal = "response-url" | "streamed-header"; -export type RscFetchResultFactsV0 = { +export type RscFetchResultFacts = { source: RscFetchResultSource; currentHref: string; origin: string; @@ -184,7 +178,7 @@ export type RscFetchResultFactsV0 = { streamedRedirectTarget: string | null; }; -type RscRedirectFollowV0 = { +type RscRedirectFollow = { href: string; historyUpdateMode: "push" | "replace"; previousNextUrl: string | null; @@ -198,12 +192,12 @@ type RscFetchResultHardNavReason = | "redirectDepthExhausted" | "streamedRedirectLoop"; -export type RscFetchResultDecisionV0 = +export type RscFetchResultDecision = | { kind: "proceedToCommit"; discardBody: false; trace: NavigationTrace } | { kind: "followRedirect"; discardBody: boolean; - redirect: RscRedirectFollowV0; + redirect: RscRedirectFollow; trace: NavigationTrace; } | { @@ -220,10 +214,10 @@ export type RscFetchResultDecisionV0 = // outcome (same-document scroll vs cache-bypassing flight vs ordinary flight), // the executor owns the effects (history mutation, scroll, RSC fetch). // -// V0 only needs the URL delta plus history/scroll intent. Richer planner inputs -// (route manifest, mounted slots) join later slices once prefetch reuse and the -// remaining hard-navigation causes route through this surface. -export type EarlyNavigationIntentFactsV0 = { +// This surface only needs the URL delta plus history/scroll intent. Richer +// planner inputs (route manifest, mounted slots) join later slices once prefetch +// reuse and the remaining hard-navigation causes route through this surface. +export type EarlyNavigationIntentFacts = { // App basePath, stripped from both pathnames before comparison. basePath: string; // The current visible document URL (window.location.href at navigation start), @@ -237,7 +231,7 @@ export type EarlyNavigationIntentFactsV0 = { targetHref: string; }; -export type EarlyNavigationIntentDecisionV0 = +export type EarlyNavigationIntentDecision = | { kind: "sameDocumentScroll"; // Always non-empty: same-document scroll is only chosen for a hash target. @@ -256,7 +250,7 @@ export type EarlyNavigationIntentDecisionV0 = type NavigationReuseNavigationKind = "navigate" | "refresh" | "traverse"; -export type VisitedResponseCacheCandidateFactsV0 = +export type VisitedResponseCacheCandidateFacts = | { candidate: "missing"; navigationKind: NavigationReuseNavigationKind; @@ -268,7 +262,7 @@ export type VisitedResponseCacheCandidateFactsV0 = navigationKind: NavigationReuseNavigationKind; }; -type VisitedResponseCacheCandidateDecisionV0 = +type VisitedResponseCacheCandidateDecision = | { kind: "miss" } | { kind: "evict"; @@ -281,7 +275,7 @@ type OptimisticRouteShellCandidateAvailability = | { status: "available" } | { status: "unavailable"; reason: "routeManifestMissing" }; -export type NavigationReuseFactsV0 = { +export type NavigationReuseFacts = { bypassNavigationCache: boolean; navigationKind: NavigationReuseNavigationKind; optimisticRouteShell: OptimisticRouteShellCandidateAvailability; @@ -292,19 +286,19 @@ export type NavigationReuseFactsV0 = { type FreshFetchReason = "cacheBypassed" | "cacheMiss" | "refresh" | "routeManifestMissing"; -export type NavigationReuseDecisionV0 = +export type NavigationReuseDecision = | { kind: "reuseVisitedResponse"; trace: NavigationTrace } | { kind: "consumePrefetch"; trace: NavigationTrace } | { kind: "attemptOptimisticRouteShell"; trace: NavigationTrace } | { kind: "fetchFresh"; reason: FreshFetchReason; trace: NavigationTrace }; -type NavigationPrefetchProbeFactsV0 = { +type NavigationPrefetchProbeFacts = { bypassNavigationCache: boolean; navigationKind: NavigationReuseNavigationKind; visitedResponse: NavigationReuseCandidateAvailability; }; -type NavigationPrefetchProbeDecisionV0 = +type NavigationPrefetchProbeDecision = | { kind: "probe" } | { kind: "skip"; reason: "cacheBypassed" | "refresh" | "visitedResponseAvailable" }; @@ -313,7 +307,7 @@ export type NavigationPlannerInput = { // decisions whenever the caller can supply it. Null keeps the legacy // snapshot-only path for low-level tests and unknown route shapes. routeManifest: RouteManifest | null; - state: NavigationPlannerStateV0; + state: NavigationPlannerState; event: NavigationEvent; }; @@ -321,7 +315,7 @@ type RouteTopologySnapshot = { layoutIds: readonly string[]; rootBoundaryId: string | null; rootLayoutTreePath: string | null; - slotBindings: readonly ParallelSlotBindingSnapshotV0[]; + slotBindings: readonly ParallelSlotBindingSnapshot[]; }; type RouteTopologyResolution = @@ -352,9 +346,9 @@ const CACHE_ENTRY_PROOF_MISSING_CODE = function createRequestWorkDecision(options: { eventKind: NavigationEvent["kind"]; - state: NavigationPlannerStateV0; + state: NavigationPlannerState; work: RequestedWork; -}): NavigationDecisionV0 { +}): NavigationDecision { const traverseFields = options.work.kind === "traverseFlight" ? { traverseDirection: options.work.direction } : {}; return { @@ -384,7 +378,7 @@ function getRequestedWorkTargetHref(work: RequestedWork): string | null { } function createRscFetchResultTraceFields( - facts: RscFetchResultFactsV0, + facts: RscFetchResultFacts, fields: NavigationTraceFields = {}, ): NavigationTraceFields { return { @@ -395,12 +389,12 @@ function createRscFetchResultTraceFields( function createRscFetchResultHardNavigationDecision(options: { discardBody: boolean; - facts: RscFetchResultFactsV0; + facts: RscFetchResultFacts; reason: RscFetchResultHardNavReason; reasonCode: NavigationTraceReasonCode; redirectSignal?: RscRedirectSignal; url: string; -}): RscFetchResultDecisionV0 { +}): RscFetchResultDecision { return { discardBody: options.discardBody, kind: "hardNavigate", @@ -421,10 +415,10 @@ function createRscFetchResultHardNavigationDecision(options: { function createRscFetchResultFollowRedirectDecision(options: { discardBody: boolean; - facts: RscFetchResultFactsV0; - redirect: RscRedirectFollowV0; + facts: RscFetchResultFacts; + redirect: RscRedirectFollow; redirectSignal: RscRedirectSignal; -}): RscFetchResultDecisionV0 { +}): RscFetchResultDecision { return { discardBody: options.discardBody, kind: "followRedirect", @@ -462,7 +456,7 @@ function mapRscRedirectTerminalReason(reason: "externalRedirect" | "maxRedirects } } -function classifyRscFetchResult(facts: RscFetchResultFactsV0): RscFetchResultDecisionV0 { +function classifyRscFetchResult(facts: RscFetchResultFacts): RscFetchResultDecision { if (!facts.responseOk || !facts.isRscContentType || !facts.hasBody) { const url = resolveHardNavigationTargetFromRscResponse( facts.responseUrl, @@ -586,14 +580,14 @@ function classifyRscFetchResult(facts: RscFetchResultFactsV0): RscFetchResultDec function createEarlyNavigationIntentTrace( reasonCode: NavigationTraceReasonCode, - facts: EarlyNavigationIntentFactsV0, + facts: EarlyNavigationIntentFacts, ): NavigationTrace { return createNavigationTrace(reasonCode, { targetHref: facts.targetHref }); } function classifyEarlyNavigationIntent( - facts: EarlyNavigationIntentFactsV0, -): EarlyNavigationIntentDecisionV0 { + facts: EarlyNavigationIntentFacts, +): EarlyNavigationIntentDecision { let current: URL; let next: URL; try { @@ -656,8 +650,8 @@ function classifyEarlyNavigationIntent( } function classifyVisitedResponseCacheCandidate( - facts: VisitedResponseCacheCandidateFactsV0, -): VisitedResponseCacheCandidateDecisionV0 { + facts: VisitedResponseCacheCandidateFacts, +): VisitedResponseCacheCandidateDecision { if (facts.candidate === "missing") { return { kind: "miss" }; } @@ -688,7 +682,7 @@ function classifyVisitedResponseCacheCandidate( function createNavigationReuseTrace( code: NavigationTraceReasonCode, - facts: NavigationReuseFactsV0, + facts: NavigationReuseFacts, fields: NavigationTraceFields = {}, ): NavigationTrace { return createNavigationTrace(code, { @@ -699,9 +693,9 @@ function createNavigationReuseTrace( } function createFreshFetchDecision( - facts: NavigationReuseFactsV0, + facts: NavigationReuseFacts, reason: FreshFetchReason, -): NavigationReuseDecisionV0 { +): NavigationReuseDecision { return { kind: "fetchFresh", reason, @@ -711,7 +705,7 @@ function createFreshFetchDecision( }; } -function classifyNavigationReuse(facts: NavigationReuseFactsV0): NavigationReuseDecisionV0 { +function classifyNavigationReuse(facts: NavigationReuseFacts): NavigationReuseDecision { if (facts.navigationKind === "refresh") { return createFreshFetchDecision(facts, "refresh"); } @@ -752,8 +746,8 @@ function classifyNavigationReuse(facts: NavigationReuseFactsV0): NavigationReuse } function classifyNavigationPrefetchProbe( - facts: NavigationPrefetchProbeFactsV0, -): NavigationPrefetchProbeDecisionV0 { + facts: NavigationPrefetchProbeFacts, +): NavigationPrefetchProbeDecision { if (facts.visitedResponse.status === "available") { return { kind: "skip", reason: "visitedResponseAvailable" }; } @@ -769,7 +763,7 @@ function classifyNavigationPrefetchProbe( return { kind: "probe" }; } -function createSnapshotRouteTopology(snapshot: RouteSnapshotV0): RouteTopologySnapshot { +function createSnapshotRouteTopology(snapshot: RouteSnapshot): RouteTopologySnapshot { return { layoutIds: snapshot.layoutIds, rootBoundaryId: snapshot.rootBoundaryId, @@ -835,7 +829,7 @@ function findRouteManifestRouteByIdOrMatchedUrl(options: { function findRouteManifestRouteForSnapshot( routeManifest: RouteManifest, - snapshot: RouteSnapshotV0, + snapshot: RouteSnapshot, ): RouteManifestRoute | null { if (snapshot.interception !== null) { return findRouteManifestRouteByIdOrMatchedUrl({ @@ -855,8 +849,8 @@ function findRouteManifestRouteForSnapshot( function resolveRouteManifestSlotBindings( routeManifest: RouteManifest, route: RouteManifestRoute, -): readonly ParallelSlotBindingSnapshotV0[] { - const bindings: ParallelSlotBindingSnapshotV0[] = []; +): readonly ParallelSlotBindingSnapshot[] { + const bindings: ParallelSlotBindingSnapshot[] = []; for (const slotId of route.slotIds) { const binding = routeManifest.segmentGraph.slotBindings.get(`${route.id}::${slotId}`); if (!binding) continue; @@ -881,7 +875,7 @@ function resolveRouteManifestRootLayoutTreePath( function resolveRouteTopologySnapshot(options: { routeManifest: RouteManifest | null; slotBindingSource: RouteTopologySlotBindingSource; - snapshot: RouteSnapshotV0; + snapshot: RouteSnapshot; }): RouteTopologyResolution { const route = options.routeManifest === null @@ -911,7 +905,7 @@ function resolveRouteTopologySnapshot(options: { function findRouteManifestInterceptionForProof( routeManifest: RouteManifest, - proof: InterceptionSnapshotV0, + proof: InterceptionSnapshot, ): RouteManifestInterception | null { const sourceParts = splitMatchedUrlIntoRouteParts(proof.sourceMatchedUrl); const targetParts = splitMatchedUrlIntoRouteParts(proof.targetMatchedUrl); @@ -951,7 +945,7 @@ function createRootBoundaryTraceFields(options: { currentRootLayoutTreePath: string | null; event: Extract; nextRootLayoutTreePath: string | null; - state: NavigationPlannerStateV0; + state: NavigationPlannerState; }): NavigationTraceFields { // Browser commit approval supplies lifecycle trace context before calling // the planner. This fallback exists for pure planner callers and tests; it @@ -986,8 +980,8 @@ function classifyRootBoundaryTransition( } function resolveSameLayoutAncestorPersistence( - currentSnapshot: RouteSnapshotV0, - targetSnapshot: RouteSnapshotV0, + currentSnapshot: RouteSnapshot, + targetSnapshot: RouteSnapshot, ): readonly string[] { return resolveSameLayoutAncestorPersistenceForTopologies( createSnapshotRouteTopology(currentSnapshot), @@ -1019,15 +1013,15 @@ function resolveSameLayoutAncestorPersistenceForTopologies( } function resolveMountedParallelSlotPersistence( - currentSnapshot: RouteSnapshotV0, - targetSnapshot: RouteSnapshotV0, + currentSnapshot: RouteSnapshot, + targetSnapshot: RouteSnapshot, ): readonly string[] { const preservedLayoutIds = resolveSameLayoutAncestorPersistence(currentSnapshot, targetSnapshot); return resolveMountedParallelSlotPersistenceForLayouts(currentSnapshot, preservedLayoutIds); } function resolveMountedParallelSlotPersistenceForLayouts( - currentSnapshot: RouteSnapshotV0, + currentSnapshot: RouteSnapshot, preservedLayoutIds: readonly string[], ): readonly string[] { if (preservedLayoutIds.length === 0) return []; @@ -1047,8 +1041,8 @@ function resolveMountedParallelSlotPersistenceForLayouts( } function resolveCurrentRootBoundaryElementPersistence( - currentSnapshot: RouteSnapshotV0, - targetSnapshot: RouteSnapshotV0, + currentSnapshot: RouteSnapshot, + targetSnapshot: RouteSnapshot, ): readonly string[] { const preservedLayoutIds = resolveSameLayoutAncestorPersistence(currentSnapshot, targetSnapshot); // Non-commit consumers still receive the legacy mounted-slot element list. @@ -1108,9 +1102,9 @@ function resolveCurrentRootBoundaryCommitSlotPersistence(options: { * Wire absence and UNMATCHED_SLOT markers are not semantic proof. */ export function resolveDefaultOrUnmatchedSlotPersistenceForLayouts(options: { - currentSlotBindings: readonly ParallelSlotBindingSnapshotV0[]; + currentSlotBindings: readonly ParallelSlotBindingSnapshot[]; preservedLayoutIds: readonly string[]; - targetSlotBindings: readonly ParallelSlotBindingSnapshotV0[]; + targetSlotBindings: readonly ParallelSlotBindingSnapshot[]; }): readonly string[] { const preservedLayoutIdSet = new Set(options.preservedLayoutIds); const slotIdsWithContent = new Set(); @@ -1151,7 +1145,7 @@ type InterceptedPreservationValidation = }; function getVisibleInterceptionSourceIdentity( - snapshot: RouteSnapshotV0, + snapshot: RouteSnapshot, ): VisibleInterceptionSourceIdentity { if (snapshot.interception) { return { @@ -1169,7 +1163,7 @@ function createInterceptionProofRejectedDecision(options: { event: Extract; reasonCode: NavigationTraceReasonCode; traceFields: NavigationTraceFields; -}): NavigationDecisionV0 { +}): NavigationDecision { return { kind: "hardNavigate", reason: "interceptionProofRejected", @@ -1232,7 +1226,7 @@ function createCacheProofRejectedDecision(options: { event: Extract; rejection: Extract; traceFields: NavigationTraceFields; -}): NavigationDecisionV0 { +}): NavigationDecision { return { kind: "hardNavigate", reason: "cacheProofRejected", @@ -1245,6 +1239,27 @@ function createCacheProofRejectedDecision(options: { }; } +// A proven cache entry rejected by the OperationToken authority (its graph +// version or variant no longer matches the installed one). Distinct from +// cacheProofRejected — the cache proof itself authorized reuse; the token did +// not — so it carries its own reason code and the verdict reason for telemetry. +function createCacheReuseTokenRejectedDecision(options: { + event: Extract; + reason: OperationTokenRejectionReason; + traceFields: NavigationTraceFields; +}): NavigationDecision { + return { + kind: "hardNavigate", + reason: "cacheReuseTokenRejected", + token: options.event.token, + trace: createNavigationTrace(NavigationTraceReasonCodes.cacheReuseTokenRejected, { + ...options.traceFields, + cacheReuseTokenReason: options.reason, + }), + url: options.event.result.href, + }; +} + function createAcceptedCacheProofTraceFields( traceFields: NavigationTraceFields, decision: AcceptedCacheEntryReuseDecision | null, @@ -1267,10 +1282,10 @@ function createCacheEntryProposalFields( } function validateInterceptedPreservation(options: { - currentSnapshot: RouteSnapshotV0; + currentSnapshot: RouteSnapshot; currentTopology: RouteTopologySnapshot; routeManifest: RouteManifest | null; - targetSnapshot: RouteSnapshotV0; + targetSnapshot: RouteSnapshot; targetTopology: RouteTopologySnapshot; }): InterceptedPreservationValidation { const proof = options.targetSnapshot.interception; @@ -1362,8 +1377,8 @@ function validateInterceptedPreservation(options: { function planFlightResponseArrived(options: { event: Extract; routeManifest: RouteManifest | null; - state: NavigationPlannerStateV0; -}): NavigationDecisionV0 { + state: NavigationPlannerState; +}): NavigationDecision { const targetSnapshot = options.event.result.targetSnapshot; const currentTopology = resolveRouteTopologySnapshot({ routeManifest: options.routeManifest, @@ -1404,6 +1419,29 @@ function planFlightResponseArrived(options: { }); } const acceptedCacheEntryDecision = cacheEntryProofEvaluation.decision; + + // Commits and cache reuse share the OperationToken authority. A proven cache + // entry may only be reused if its token still matches the installed route graph + // and cache variant. Behavior-preserving today — the token's graphVersion is + // minted from the same route manifest the planner verifies against — and a real + // guard once cross-document or segment reuse (PR 6/7) can carry a token whose + // graph version or variant has diverged from the installed one. The installed + // cache variant is not yet known to the planner (segment cache, PR 7), so that + // dimension stays dormant rather than comparing the token to itself. + if (acceptedCacheEntryDecision !== null) { + const reuseVerdict = verifyOperationTokenForCacheReuse(options.event.token, { + graphVersion: options.routeManifest?.graphVersion ?? null, + installedCacheVariantFingerprint: null, + }); + if (!reuseVerdict.authorized) { + return createCacheReuseTokenRejectedDecision({ + event: options.event, + reason: reuseVerdict.reason, + traceFields, + }); + } + } + const commitTraceFields = createAcceptedCacheProofTraceFields( traceFields, acceptedCacheEntryDecision, @@ -1526,7 +1564,7 @@ function planFlightResponseArrived(options: { }; } -function planNavigation(input: NavigationPlannerInput): NavigationDecisionV0 { +function planNavigation(input: NavigationPlannerInput): NavigationDecision { switch (input.event.kind) { case "navigate": return createRequestWorkDecision({ @@ -1580,7 +1618,7 @@ function planNavigation(input: NavigationPlannerInput): NavigationDecisionV0 { } } -export type ServerActionResultFactsV0 = { +export type ServerActionResultFacts = { actionRedirectHref: string | null; actionRedirectType: "push" | "replace"; clientCompatibilityId: string | null; @@ -1591,7 +1629,7 @@ export type ServerActionResultFactsV0 = { responseUrl: string | null; }; -export type ServerActionResultDecisionV0 = +export type ServerActionResultDecision = | { kind: "proceed"; trace: NavigationTrace } | { kind: "hardNavigate"; @@ -1602,20 +1640,18 @@ export type ServerActionResultDecisionV0 = trace: NavigationTrace; }; -export type RscNavigationErrorFactsV0 = { +export type RscNavigationErrorFacts = { currentHref: string; }; -export type RscNavigationErrorDecisionV0 = { +export type RscNavigationErrorDecision = { kind: "hardNavigate"; url: string; reason: "rscNavigationError"; trace: NavigationTrace; }; -function classifyServerActionResult( - facts: ServerActionResultFactsV0, -): ServerActionResultDecisionV0 { +function classifyServerActionResult(facts: ServerActionResultFacts): ServerActionResultDecision { // A client without a compatibility id cannot prove skew. if (facts.clientCompatibilityId === null) { return { @@ -1682,9 +1718,7 @@ function classifyServerActionResult( }; } -function classifyRscNavigationError( - facts: RscNavigationErrorFactsV0, -): RscNavigationErrorDecisionV0 { +function classifyRscNavigationError(facts: RscNavigationErrorFacts): RscNavigationErrorDecision { return { kind: "hardNavigate", url: facts.currentHref, diff --git a/packages/vinext/src/server/navigation-trace.ts b/packages/vinext/src/server/navigation-trace.ts index 920ce5973..51c1f8203 100644 --- a/packages/vinext/src/server/navigation-trace.ts +++ b/packages/vinext/src/server/navigation-trace.ts @@ -4,6 +4,7 @@ type NavigationTraceSchemaVersion = 0; export const NavigationTraceReasonCodes = { cacheProofRejected: "NC_CACHE_REJECT", + cacheReuseTokenRejected: "NC_CACHE_TOKEN_REJECT", commitCurrent: "NC_COMMIT", crossDocumentFlight: "NC_CROSS_DOC_FLIGHT", fetchFresh: "NC_FETCH_FRESH", @@ -36,6 +37,7 @@ export const NavigationTraceReasonCodes = { visitedResponseReuse: "NC_VISITED_REUSE", } satisfies Readonly<{ cacheProofRejected: "NC_CACHE_REJECT"; + cacheReuseTokenRejected: "NC_CACHE_TOKEN_REJECT"; commitCurrent: "NC_COMMIT"; crossDocumentFlight: "NC_CROSS_DOC_FLIGHT"; fetchFresh: "NC_FETCH_FRESH"; @@ -92,6 +94,7 @@ type NavigationTraceFieldName = | "cacheProofMode" | "cacheProofReuseClass" | "cacheProofScope" + | "cacheReuseTokenReason" | "currentRootLayoutTreePath" | "currentVisibleCommitVersion" | "nextRootLayoutTreePath" diff --git a/packages/vinext/src/server/operation-token.ts b/packages/vinext/src/server/operation-token.ts new file mode 100644 index 000000000..01dd476f3 --- /dev/null +++ b/packages/vinext/src/server/operation-token.ts @@ -0,0 +1,222 @@ +// OperationToken is the proof-of-eligibility object for App Router navigation. +// It answers one question at every authority boundary: may this navigation +// result enter commit approval or cache reuse? The token only *verifies*; +// `ApprovedVisibleCommit` is the separate proof-of-mutation object that actually +// advances visible router state. Keep that separation: a verified token never +// mutates and never substitutes for the approved-commit brand. +// +// See issue #1790 (PR 5). The token feeds the active-navigation, visible-commit, +// graph-version, and cache-variant checks so commits and cache reuse share one +// authority model instead of deriving eligibility inline in the browser entry. + +export type OperationLane = + | "hmr" + | "navigation" + | "prefetch" + | "refresh" + | "server-action" + | "traverse"; + +export type OperationToken = { + // Diagnostic only: the per-render operation id (renderId). Distinct from + // navigationId; not a verification dimension. + operationId: number; + // Execution-lane selector. Drives planner behavior (prefetch no-commit, slot + // persistence); not a verification dimension. + lane: OperationLane; + // Authority — active-navigation dimension. The lifecycle navigation id the + // operation started under, verified against the live activeNavigationId. + navigationId: number; + // Authority — visible-commit dimension. The visibleCommitVersion the operation + // started from, verified against the current visibleCommitVersion. + baseVisibleCommitVersion: number; + // Authority — graph-version dimension. The route graph version the operation + // was planned against, verified against the installed graph version. + graphVersion: string | null; + // Future deployment-compatibility dimension: carried for cross-deployment + // reuse authority but not yet verified by any boundary. + deploymentVersion: string | null; + // Diagnostic / future BFCache identity input (PR 6). Not a commit-authority + // check today. + targetSnapshotFingerprint: string; + // Authority — cache-variant dimension. The cache variant that produced the + // result, verified against the installed variant. Populated once segment cache + // variant keys exist (PR 7); absent until then. + cacheVariantFingerprint?: string; +}; + +// A token that has passed `verifyOperationToken`. Boundaries that consume proof +// (not raw evidence) accept this branded type so an unverified token cannot +// reach them by construction. Mirrors the ApprovedVisibleCommit brand pattern +// without collapsing verification and mutation into one object. +declare const verifiedOperationTokenBrand: unique symbol; +export type VerifiedOperationToken = OperationToken & { + readonly [verifiedOperationTokenBrand]: true; +}; + +// The live authority facts a boundary checks the token against. Each field is +// the *current* installed/active value; the token carries the value it was +// minted with. graphVersion / installedCacheVariantFingerprint are nullable +// because low-context paths (absent route manifest, pre-segment-cache reuse) may +// not carry them. +export type OperationTokenAuthority = { + activeNavigationId: number; + visibleCommitVersion: number; + graphVersion: string | null; + installedCacheVariantFingerprint: string | null; +}; + +type OperationTokenDimension = "navigation" | "visibleCommit" | "graphVersion" | "cacheVariant"; + +export type OperationTokenRejectionReason = + | "staleNavigation" + | "staleVisibleCommit" + | "graphVersionMismatch" + | "graphVersionMissing" + | "cacheVariantMismatch" + | "cacheVariantMissing"; + +export type OperationTokenVerdict = + | { readonly authorized: true; readonly token: VerifiedOperationToken } + | { readonly authorized: false; readonly reason: OperationTokenRejectionReason }; + +// Per-dimension verification policy. `check` is the set of dimensions a boundary +// evaluates; `require` is the subset whose authority fact must be present. +// +// The two-level shape is deliberate, not redundant: a *checked* dimension that +// is absent is tolerated (low-context paths must keep working), but a *required* +// dimension that is absent fails closed. Absence must never silently become +// permission — that is how proof systems rot. Required-but-absent is reserved +// for the future segment-cache / BFCache write boundaries (PR 6/7), where a +// forgotten fingerprint must reject. +export type OperationTokenVerificationPolicy = { + check: readonly OperationTokenDimension[]; + require: readonly OperationTokenDimension[]; +}; + +// Fixed evaluation order so the reported rejection reason is deterministic +// regardless of the order a caller lists `check`. +const DIMENSION_ORDER: readonly OperationTokenDimension[] = [ + "navigation", + "visibleCommit", + "graphVersion", + "cacheVariant", +]; + +type DimensionStatus = + | { kind: "satisfied" } + | { kind: "mismatch"; reason: OperationTokenRejectionReason } + | { kind: "absent"; missingReason: OperationTokenRejectionReason }; + +function evaluateDimension( + dimension: OperationTokenDimension, + token: OperationToken, + authority: OperationTokenAuthority, +): DimensionStatus { + switch (dimension) { + case "navigation": + // navigationId is always present (a number), so this dimension is never absent. + return token.navigationId === authority.activeNavigationId + ? { kind: "satisfied" } + : { kind: "mismatch", reason: "staleNavigation" }; + case "visibleCommit": + return token.baseVisibleCommitVersion === authority.visibleCommitVersion + ? { kind: "satisfied" } + : { kind: "mismatch", reason: "staleVisibleCommit" }; + case "graphVersion": { + if (token.graphVersion === null || authority.graphVersion === null) { + return { kind: "absent", missingReason: "graphVersionMissing" }; + } + return token.graphVersion === authority.graphVersion + ? { kind: "satisfied" } + : { kind: "mismatch", reason: "graphVersionMismatch" }; + } + case "cacheVariant": { + const tokenVariant = token.cacheVariantFingerprint; + const installedVariant = authority.installedCacheVariantFingerprint; + if (tokenVariant === undefined || installedVariant === null) { + return { kind: "absent", missingReason: "cacheVariantMissing" }; + } + return tokenVariant === installedVariant + ? { kind: "satisfied" } + : { kind: "mismatch", reason: "cacheVariantMismatch" }; + } + default: { + const _exhaustive: never = dimension; + throw new Error("[vinext] Unknown operation-token dimension: " + String(_exhaustive)); + } + } +} + +export function verifyOperationToken( + token: OperationToken, + authority: OperationTokenAuthority, + policy: OperationTokenVerificationPolicy, +): OperationTokenVerdict { + const required = new Set(policy.require); + // A required dimension is always evaluated, even if a caller forgot to also + // list it in `check`. Requiring a dimension you never evaluate would let its + // absence pass silently — the exact failure mode this two-level policy exists + // to prevent — so `require` implies evaluation. + const evaluated = new Set([...policy.check, ...policy.require]); + + for (const dimension of DIMENSION_ORDER) { + if (!evaluated.has(dimension)) continue; + const status = evaluateDimension(dimension, token, authority); + if (status.kind === "mismatch") { + return { authorized: false, reason: status.reason }; + } + if (status.kind === "absent" && required.has(dimension)) { + return { authorized: false, reason: status.missingReason }; + } + } + + // The verifier is the only place that mints the verified brand: the raw token + // is evidence, the returned token is proof. + return { authorized: true, token: token as VerifiedOperationToken }; +} + +// Commit eligibility. Commits gate on the lifecycle dimensions the browser owns +// (which navigation is active, which visible commit it started from). They do +// not gate on cache variant, and graph-version enforcement for commits remains +// the RSC artifact-compatibility path's job — so neither is checked here. +export function verifyOperationTokenForCommit( + token: OperationToken, + authority: Pick, +): OperationTokenVerdict { + return verifyOperationToken( + token, + { + activeNavigationId: authority.activeNavigationId, + visibleCommitVersion: authority.visibleCommitVersion, + // Unchecked for commits; echo the token so the facts are consistent. + graphVersion: token.graphVersion, + installedCacheVariantFingerprint: token.cacheVariantFingerprint ?? null, + }, + { check: ["navigation", "visibleCommit"], require: ["navigation", "visibleCommit"] }, + ); +} + +// Cache-reuse eligibility. Reuse gates on the payload-vs-installed dimensions the +// planner owns: the graph version the proof was produced under and the cache +// variant that produced it. Both are tolerated when absent today (the route +// manifest may be null; segment cache variant keys arrive in PR 7) and reject +// only on genuine divergence — behavior-preserving for current single-document +// reuse, a real guard once cross-document/segment reuse can diverge. +export function verifyOperationTokenForCacheReuse( + token: OperationToken, + authority: Pick, +): OperationTokenVerdict { + return verifyOperationToken( + token, + { + // Unchecked for cache reuse; echo the token so the lifecycle facts are + // consistent (the pre-planner commit gate already owns those dimensions). + activeNavigationId: token.navigationId, + visibleCommitVersion: token.baseVisibleCommitVersion, + graphVersion: authority.graphVersion, + installedCacheVariantFingerprint: authority.installedCacheVariantFingerprint, + }, + { check: ["graphVersion", "cacheVariant"], require: [] }, + ); +} diff --git a/tests/navigation-planner-early-intent.test.ts b/tests/navigation-planner-early-intent.test.ts index 52bd9737a..51ba94f23 100644 --- a/tests/navigation-planner-early-intent.test.ts +++ b/tests/navigation-planner-early-intent.test.ts @@ -5,13 +5,13 @@ import { } from "../packages/vinext/src/server/navigation-trace.js"; import { navigationPlanner, - type EarlyNavigationIntentDecisionV0, - type EarlyNavigationIntentFactsV0, + type EarlyNavigationIntentDecision, + type EarlyNavigationIntentFacts, } from "../packages/vinext/src/server/navigation-planner.js"; function createFacts( - overrides: Partial = {}, -): EarlyNavigationIntentFactsV0 { + overrides: Partial = {}, +): EarlyNavigationIntentFacts { return { basePath: "", currentHref: "https://example.com/docs?q=1", @@ -23,13 +23,13 @@ function createFacts( } function classify( - overrides: Partial = {}, -): EarlyNavigationIntentDecisionV0 { + overrides: Partial = {}, +): EarlyNavigationIntentDecision { return navigationPlanner.classifyEarlyNavigationIntent(createFacts(overrides)); } function expectSingleTraceEntry( - decision: EarlyNavigationIntentDecisionV0, + decision: EarlyNavigationIntentDecision, code: string, fields: Record, ): void { diff --git a/tests/navigation-planner-prefetch-reuse.test.ts b/tests/navigation-planner-prefetch-reuse.test.ts index b5f9ad262..9c487d7b4 100644 --- a/tests/navigation-planner-prefetch-reuse.test.ts +++ b/tests/navigation-planner-prefetch-reuse.test.ts @@ -5,12 +5,12 @@ import { } from "../packages/vinext/src/server/navigation-trace.js"; import { navigationPlanner, - type NavigationReuseDecisionV0, - type NavigationReuseFactsV0, - type VisitedResponseCacheCandidateFactsV0, + type NavigationReuseDecision, + type NavigationReuseFacts, + type VisitedResponseCacheCandidateFacts, } from "../packages/vinext/src/server/navigation-planner.js"; -function createReuseFacts(overrides: Partial = {}): NavigationReuseFactsV0 { +function createReuseFacts(overrides: Partial = {}): NavigationReuseFacts { return { bypassNavigationCache: false, navigationKind: "navigate", @@ -22,7 +22,7 @@ function createReuseFacts(overrides: Partial = {}): Navi }; } -function classifyReuse(overrides: Partial = {}): NavigationReuseDecisionV0 { +function classifyReuse(overrides: Partial = {}): NavigationReuseDecision { return navigationPlanner.classifyNavigationReuse(createReuseFacts(overrides)); } @@ -202,9 +202,7 @@ describe("navigationPlanner prefetch reuse classification", () => { describe("navigationPlanner visited-response cache candidate classification", () => { function classifyVisited( - overrides: Partial< - Extract - > = {}, + overrides: Partial> = {}, ) { return navigationPlanner.classifyVisitedResponseCacheCandidate({ candidate: "present", diff --git a/tests/navigation-planner-rsc-fetch-result.test.ts b/tests/navigation-planner-rsc-fetch-result.test.ts index 257952325..73ad2069a 100644 --- a/tests/navigation-planner-rsc-fetch-result.test.ts +++ b/tests/navigation-planner-rsc-fetch-result.test.ts @@ -5,11 +5,11 @@ import { } from "../packages/vinext/src/server/navigation-trace.js"; import { navigationPlanner, - type RscFetchResultDecisionV0, - type RscFetchResultFactsV0, + type RscFetchResultDecision, + type RscFetchResultFacts, } from "../packages/vinext/src/server/navigation-planner.js"; -function createFacts(overrides: Partial = {}): RscFetchResultFactsV0 { +function createFacts(overrides: Partial = {}): RscFetchResultFacts { return { clientCompatibilityId: "client-build", compatibilityIdHeader: "client-build", @@ -28,12 +28,12 @@ function createFacts(overrides: Partial = {}): RscFetchRe }; } -function classify(overrides: Partial = {}): RscFetchResultDecisionV0 { +function classify(overrides: Partial = {}): RscFetchResultDecision { return navigationPlanner.classifyRscFetchResult(createFacts(overrides)); } function expectSingleTraceEntry( - decision: RscFetchResultDecisionV0, + decision: RscFetchResultDecision, code: string, fields: Record, ): void { diff --git a/tests/navigation-planner-server-action-result.test.ts b/tests/navigation-planner-server-action-result.test.ts index 3cc9b3d0e..60042173c 100644 --- a/tests/navigation-planner-server-action-result.test.ts +++ b/tests/navigation-planner-server-action-result.test.ts @@ -5,15 +5,13 @@ import { } from "../packages/vinext/src/server/navigation-trace.js"; import { navigationPlanner, - type RscNavigationErrorDecisionV0, - type RscNavigationErrorFactsV0, - type ServerActionResultDecisionV0, - type ServerActionResultFactsV0, + type RscNavigationErrorDecision, + type RscNavigationErrorFacts, + type ServerActionResultDecision, + type ServerActionResultFacts, } from "../packages/vinext/src/server/navigation-planner.js"; -function createFacts( - overrides: Partial = {}, -): ServerActionResultFactsV0 { +function createFacts(overrides: Partial = {}): ServerActionResultFacts { return { actionRedirectHref: null, actionRedirectType: "replace", @@ -27,14 +25,12 @@ function createFacts( }; } -function classify( - overrides: Partial = {}, -): ServerActionResultDecisionV0 { +function classify(overrides: Partial = {}): ServerActionResultDecision { return navigationPlanner.classifyServerActionResult(createFacts(overrides)); } function expectSingleTraceEntry( - decision: ServerActionResultDecisionV0 | RscNavigationErrorDecisionV0, + decision: ServerActionResultDecision | RscNavigationErrorDecision, code: string, fields: Record, ): void { @@ -158,8 +154,8 @@ describe("navigationPlanner server-action result classification", () => { describe("navigationPlanner RSC navigation error classification", () => { function classifyError( - overrides: Partial = {}, - ): RscNavigationErrorDecisionV0 { + overrides: Partial = {}, + ): RscNavigationErrorDecision { return navigationPlanner.classifyRscNavigationError({ currentHref: "https://example.com/current", ...overrides, diff --git a/tests/navigation-planner.test.ts b/tests/navigation-planner.test.ts index 41530e918..7637f974a 100644 --- a/tests/navigation-planner.test.ts +++ b/tests/navigation-planner.test.ts @@ -5,17 +5,17 @@ import { } from "../packages/vinext/src/server/navigation-trace.js"; import { navigationPlanner, - type FlightResultV0, - type MountedParallelSlotSnapshotV0, - type NavigationDecisionV0, + type FlightResult, + type MountedParallelSlotSnapshot, + type NavigationDecision, type NavigationEvent, type NavigationPlannerInput, - type NavigationPlannerStateV0, + type NavigationPlannerState, type OperationToken, - type ParallelSlotBindingSnapshotV0, + type ParallelSlotBindingSnapshot, type RefreshScope, - type RouteSnapshotV0, - type InterceptionSnapshotV0, + type RouteSnapshot, + type InterceptionSnapshot, type RootBoundaryTransition, } from "../packages/vinext/src/server/navigation-planner.js"; import type { @@ -39,7 +39,7 @@ type TestManifestRoute = { pattern: string; patternParts?: readonly string[]; rootBoundaryId: string | null; - slotBindings?: readonly ParallelSlotBindingSnapshotV0[]; + slotBindings?: readonly ParallelSlotBindingSnapshot[]; interceptions?: readonly TestManifestInterception[]; }; @@ -53,9 +53,9 @@ type TestManifestInterception = { function createRouteSnapshot( rootBoundaryId: string | null, layoutIds: readonly string[] = rootBoundaryId === null ? [] : [`layout:${rootBoundaryId}`], - mountedParallelSlots: readonly MountedParallelSlotSnapshotV0[] = [], - slotBindings: readonly ParallelSlotBindingSnapshotV0[] = [], -): RouteSnapshotV0 { + mountedParallelSlots: readonly MountedParallelSlotSnapshot[] = [], + slotBindings: readonly ParallelSlotBindingSnapshot[] = [], +): RouteSnapshot { return { displayUrl: "https://example.com/dashboard", interception: null, @@ -70,8 +70,8 @@ function createRouteSnapshot( } function createInterceptionSnapshot( - overrides: Partial = {}, -): InterceptionSnapshotV0 { + overrides: Partial = {}, +): InterceptionSnapshot { return { sourceMatchedUrl: "/feed", sourceRouteId: "route:/feed", @@ -97,8 +97,8 @@ function createAcceptedStaticLayoutCacheEntryReuseProof(): CacheEntryReuseProof function createSlotBinding( slotId: string, ownerLayoutId: string, - state: ParallelSlotBindingSnapshotV0["state"] = "active", -): ParallelSlotBindingSnapshotV0 { + state: ParallelSlotBindingSnapshot["state"] = "active", +): ParallelSlotBindingSnapshot { return { ownerLayoutId, slotId, state }; } @@ -212,8 +212,8 @@ function rootBoundaryIdForManifest(rootBoundaryId: string | null): string | null } function createRouteManifestForSnapshots( - currentSnapshot: RouteSnapshotV0, - targetSnapshot: RouteSnapshotV0, + currentSnapshot: RouteSnapshot, + targetSnapshot: RouteSnapshot, ): RouteManifest { if (targetSnapshot.interception !== null) { const proof = targetSnapshot.interception; @@ -288,6 +288,7 @@ function createOperationToken(overrides: Partial = {}): Operatio deploymentVersion: null, graphVersion: null, lane: "navigation", + navigationId: 1, operationId: 7, targetSnapshotFingerprint: "route:/dashboard|root:/", ...overrides, @@ -309,7 +310,7 @@ function createRejectedCacheEntryReuseProof( }; } -function planFlightResponse(rootBoundaryId: string | null): NavigationDecisionV0 { +function planFlightResponse(rootBoundaryId: string | null): NavigationDecision { const token = createOperationToken({ targetSnapshotFingerprint: `route:/dashboard|root:${rootBoundaryId ?? "unknown"}`, }); @@ -325,7 +326,7 @@ function planFlightResponse(rootBoundaryId: string | null): NavigationDecisionV0 rootBoundaryId: rootBoundaryId === null ? null : `root-boundary:${rootBoundaryId}`, }, ]); - const result: FlightResultV0 = { + const result: FlightResult = { href: "https://example.com/dashboard", targetSnapshot: { ...createRouteSnapshot(rootBoundaryId), @@ -333,7 +334,7 @@ function planFlightResponse(rootBoundaryId: string | null): NavigationDecisionV0 routeId: "route:/dashboard", }, }; - const state: NavigationPlannerStateV0 = { + const state: NavigationPlannerState = { nextOperationToken: token, traceFields: { currentRootLayoutTreePath: "/", @@ -366,7 +367,7 @@ function planFlightResponse(rootBoundaryId: string | null): NavigationDecisionV0 function planFlightResponseFromRootBoundaries(options: { currentRootBoundaryId: string | null; nextRootBoundaryId: string | null; -}): NavigationDecisionV0 { +}): NavigationDecision { const token = createOperationToken({ targetSnapshotFingerprint: `route:/dashboard|root:${options.nextRootBoundaryId ?? "unknown"}`, }); @@ -425,14 +426,16 @@ function planFlightResponseFromRootBoundaries(options: { function planFlightResponseFromSnapshots(options: { cacheEntryReuseProof?: CacheEntryReuseProof; - currentSnapshot: RouteSnapshotV0; + currentSnapshot: RouteSnapshot; lane?: OperationToken["lane"]; routeManifest?: RouteManifest | null; - targetSnapshot: RouteSnapshotV0; - traceFields?: NavigationPlannerStateV0["traceFields"]; -}): NavigationDecisionV0 { + targetSnapshot: RouteSnapshot; + tokenGraphVersion?: string | null; + traceFields?: NavigationPlannerState["traceFields"]; +}): NavigationDecision { const token = createOperationToken({ lane: options.lane ?? "navigation", + ...(options.tokenGraphVersion !== undefined ? { graphVersion: options.tokenGraphVersion } : {}), targetSnapshotFingerprint: `${options.targetSnapshot.routeId}|root:${ options.targetSnapshot.rootBoundaryId ?? "unknown" }`, @@ -495,13 +498,13 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("rejects runtime cache entries when the cache proof is missing", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/dashboard/profile", matchedUrl: "/dashboard/profile", routeId: "route:/dashboard/profile", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -535,13 +538,13 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("rejects incompatible runtime cache entries before route-topology commit approval", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/dashboard/profile", matchedUrl: "/dashboard/profile", routeId: "route:/dashboard/profile", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -576,13 +579,13 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("keeps accepted runtime cache proof visible on the commit proposal", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/dashboard/profile", matchedUrl: "/dashboard/profile", routeId: "route:/dashboard/profile", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -614,6 +617,76 @@ describe("navigationPlanner root-boundary decisions", () => { }); }); + it("hard navigates a proven cache entry whose token graph version no longer matches the route graph", () => { + // Commits and cache reuse share the OperationToken authority: a proven cache + // entry may only be reused under the graph version it was produced for. A + // token minted under a stale graph version must not reuse a cache entry + // against a newer route graph — it hard navigates and refetches. + const currentSnapshot: RouteSnapshot = { + ...createRouteSnapshot("/"), + displayUrl: "https://example.com/dashboard/profile", + matchedUrl: "/dashboard/profile", + routeId: "route:/dashboard/profile", + }; + const targetSnapshot: RouteSnapshot = { + ...createRouteSnapshot("/"), + displayUrl: "https://example.com/dashboard/settings", + matchedUrl: "/dashboard/settings", + routeId: "route:/dashboard/settings", + }; + + const decision = planFlightResponseFromSnapshots({ + cacheEntryReuseProof: createAcceptedStaticLayoutCacheEntryReuseProof(), + currentSnapshot, + targetSnapshot, + // The test manifest declares graphVersion "graph:test". + tokenGraphVersion: "graph:stale", + }); + + expect(decision.kind).toBe("hardNavigate"); + if (decision.kind !== "hardNavigate") { + throw new Error("Expected a stale-graph cache reuse to hard navigate"); + } + expect(decision.reason).toBe("cacheReuseTokenRejected"); + expect(decision.url).toBe("https://example.com/dashboard/settings"); + expect(decision.trace.entries[0]).toEqual({ + code: NavigationTraceReasonCodes.cacheReuseTokenRejected, + fields: { + cacheReuseTokenReason: "graphVersionMismatch", + currentRootLayoutTreePath: "/", + currentVisibleCommitVersion: 2, + nextRootLayoutTreePath: "/", + startedVisibleCommitVersion: 2, + }, + }); + }); + + it("commits a proven cache entry when its token graph version still matches the route graph", () => { + // The cache-reuse token gate must not disturb the normal proven-reuse path: + // a token minted under the installed graph version commits as before. + const currentSnapshot: RouteSnapshot = { + ...createRouteSnapshot("/"), + displayUrl: "https://example.com/dashboard/profile", + matchedUrl: "/dashboard/profile", + routeId: "route:/dashboard/profile", + }; + const targetSnapshot: RouteSnapshot = { + ...createRouteSnapshot("/"), + displayUrl: "https://example.com/dashboard/settings", + matchedUrl: "/dashboard/settings", + routeId: "route:/dashboard/settings", + }; + + const decision = planFlightResponseFromSnapshots({ + cacheEntryReuseProof: createAcceptedStaticLayoutCacheEntryReuseProof(), + currentSnapshot, + targetSnapshot, + tokenGraphVersion: "graph:test", + }); + + expect(decision.kind).toBe("proposeCommit"); + }); + it("hard-navigates cross-root flight responses", () => { const transition: RootBoundaryTransition = navigationPlanner.classifyRootBoundaryTransition( "/", @@ -642,13 +715,13 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("keeps accepted runtime cache proof fields on later root-boundary rejections", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/"), displayUrl: "https://example.com/current", matchedUrl: "/current", routeId: "route:/current", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/(dashboard)"), displayUrl: "https://example.com/dashboard", matchedUrl: "/dashboard", @@ -777,7 +850,7 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("does not approve mounted parallel slot preservation for traverse commits", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -787,7 +860,7 @@ describe("navigationPlanner root-boundary decisions", () => { matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed", "layout:/feed/comments"]), displayUrl: "https://example.com/feed/comments", matchedUrl: "/feed/comments", @@ -853,7 +926,7 @@ describe("navigationPlanner root-boundary decisions", () => { createSlotBinding("slot:reports:/dashboard", "layout:/dashboard", "active"), ], ); - const targetSettingsSnapshot: RouteSnapshotV0 = { + const targetSettingsSnapshot: RouteSnapshot = { ...targetSnapshot, displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -913,7 +986,7 @@ describe("navigationPlanner root-boundary decisions", () => { createSlotBinding("slot:analytics:/dashboard", "layout:/dashboard", "unmatched"), ], ); - const targetSettingsSnapshot: RouteSnapshotV0 = { + const targetSettingsSnapshot: RouteSnapshot = { ...targetSnapshot, displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -948,7 +1021,7 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("does not preserve default target slots when their owner layout is not retained", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1035,12 +1108,12 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/marketing", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/stale-app"]), matchedUrl: "/app", routeId: "route:/app", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/stale-app", "layout:/stale-marketing"]), displayUrl: "https://example.com/marketing", matchedUrl: "/marketing", @@ -1081,12 +1154,12 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), matchedUrl: "/dashboard", routeId: "route:/dashboard", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -1121,13 +1194,13 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/marketing", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), displayUrl: "https://example.com/app", matchedUrl: "/app", routeId: "route:/app", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), displayUrl: "https://example.com/app", matchedUrl: "/app", @@ -1156,13 +1229,13 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), displayUrl: "https://example.com/blog/hello", matchedUrl: "/blog/hello", routeId: "route:/blog/hello", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), displayUrl: "https://example.com/blog/world", matchedUrl: "/blog/world", @@ -1199,7 +1272,7 @@ describe("navigationPlanner root-boundary decisions", () => { slotBindings: [createSlotBinding(modalSlot, "layout:/dashboard", "default")], }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot( null, [], @@ -1212,7 +1285,7 @@ describe("navigationPlanner root-boundary decisions", () => { matchedUrl: "/dashboard", routeId: "route:/dashboard", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( null, [], @@ -1253,12 +1326,12 @@ describe("navigationPlanner root-boundary decisions", () => { slotBindings: [createSlotBinding(modalSlot, "layout:/dashboard", "default")], }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, [], [], []), matchedUrl: "/dashboard", routeId: "route:/dashboard", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, [], [], []), displayUrl: "https://example.com/dashboard/settings", matchedUrl: "/dashboard/settings", @@ -1299,12 +1372,12 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/photos", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( null, [], @@ -1353,12 +1426,12 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), matchedUrl: "/en/feed", routeId: "route:/en/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( null, [], @@ -1409,12 +1482,12 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( null, [], @@ -1470,12 +1543,12 @@ describe("navigationPlanner root-boundary decisions", () => { rootBoundaryId: "root-boundary:/", }, ]); - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot(null, []), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( null, [], @@ -1509,7 +1582,7 @@ describe("navigationPlanner root-boundary decisions", () => { // Core-15 oracle, porting the visible behavior from Next.js: // test/e2e/app-dir/parallel-routes-and-interception-catchall/parallel-routes-and-interception-catchall.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception-catchall/parallel-routes-and-interception-catchall.test.ts - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1523,7 +1596,7 @@ describe("navigationPlanner root-boundary decisions", () => { matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1556,12 +1629,12 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("does not treat legacy context-only payloads as intercepted preservation proof", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), displayUrl: "https://example.com/photos/42", interceptionContext: "/feed", @@ -1582,12 +1655,12 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("rejects intercepted preservation when the visible source route is stale", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/gallery"]), matchedUrl: "/gallery", routeId: "route:/gallery", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1614,12 +1687,12 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("rejects intercepted preservation when proof target does not match the rendered route", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1646,12 +1719,12 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("does not use intercepted snapshot root topology as preservation authority", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/marketing", ["layout:/marketing"], @@ -1678,12 +1751,12 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("rejects intercepted preservation when the target slot is not proven active", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1710,7 +1783,7 @@ describe("navigationPlanner root-boundary decisions", () => { }); it("allows traverse to restore an intercepted visible world only with proof", () => { - const currentSnapshot: RouteSnapshotV0 = { + const currentSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], @@ -1721,7 +1794,7 @@ describe("navigationPlanner root-boundary decisions", () => { matchedUrl: "/feed", routeId: "route:/feed", }; - const targetSnapshot: RouteSnapshotV0 = { + const targetSnapshot: RouteSnapshot = { ...createRouteSnapshot( "/", ["layout:/", "layout:/feed"], diff --git a/tests/operation-token.test.ts b/tests/operation-token.test.ts new file mode 100644 index 000000000..14521899c --- /dev/null +++ b/tests/operation-token.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + verifyOperationToken, + verifyOperationTokenForCacheReuse, + verifyOperationTokenForCommit, + type OperationToken, + type OperationTokenAuthority, + type VerifiedOperationToken, +} from "../packages/vinext/src/server/operation-token.js"; + +function createToken(overrides: Partial = {}): OperationToken { + return { + operationId: 7, + lane: "navigation", + navigationId: 3, + baseVisibleCommitVersion: 2, + graphVersion: "graph:test", + deploymentVersion: null, + targetSnapshotFingerprint: "route:/dashboard|root:/", + ...overrides, + }; +} + +function createAuthority( + overrides: Partial = {}, +): OperationTokenAuthority { + return { + activeNavigationId: 3, + visibleCommitVersion: 2, + graphVersion: "graph:test", + installedCacheVariantFingerprint: null, + ...overrides, + }; +} + +// Compile-time assertion that the verified token narrows to the branded type. +function consumeVerified(_token: VerifiedOperationToken): void {} + +describe("verifyOperationTokenForCommit", () => { + it("authorizes when the navigation and visible-commit dimensions match", () => { + const token = createToken(); + const verdict = verifyOperationTokenForCommit(token, createAuthority()); + + expect(verdict.authorized).toBe(true); + if (verdict.authorized) { + // Same evidence object, now carrying the verified brand. + expect(verdict.token).toBe(token); + consumeVerified(verdict.token); + } + }); + + it("rejects staleNavigation when the token started under a superseded navigation", () => { + const verdict = verifyOperationTokenForCommit( + createToken({ navigationId: 3 }), + createAuthority({ activeNavigationId: 4 }), + ); + + expect(verdict).toEqual({ authorized: false, reason: "staleNavigation" }); + }); + + it("rejects staleVisibleCommit when visible state advanced after the operation started", () => { + const verdict = verifyOperationTokenForCommit( + createToken({ baseVisibleCommitVersion: 2 }), + createAuthority({ visibleCommitVersion: 3 }), + ); + + expect(verdict).toEqual({ authorized: false, reason: "staleVisibleCommit" }); + }); + + it("reports staleNavigation first when both navigation and visible commit diverge", () => { + const verdict = verifyOperationTokenForCommit( + createToken({ navigationId: 3, baseVisibleCommitVersion: 2 }), + createAuthority({ activeNavigationId: 4, visibleCommitVersion: 3 }), + ); + + expect(verdict).toEqual({ authorized: false, reason: "staleNavigation" }); + }); + + it("does not gate commits on the cache-variant or graph-version dimensions", () => { + // A commit eligibility check must not reject on cache/graph divergence; that + // authority belongs to the cache-reuse boundary and the RSC compatibility path. + const verdict = verifyOperationTokenForCommit( + createToken({ graphVersion: "graph:stale", cacheVariantFingerprint: "cv:stale" }), + createAuthority({ + graphVersion: "graph:fresh", + installedCacheVariantFingerprint: "cv:fresh", + }), + ); + + expect(verdict.authorized).toBe(true); + }); +}); + +describe("verifyOperationTokenForCacheReuse", () => { + it("authorizes reuse when the graph version matches the installed graph", () => { + const verdict = verifyOperationTokenForCacheReuse(createToken({ graphVersion: "graph:a" }), { + graphVersion: "graph:a", + installedCacheVariantFingerprint: null, + }); + + expect(verdict.authorized).toBe(true); + }); + + it("rejects graphVersionMismatch when the proof was produced under a different graph", () => { + const verdict = verifyOperationTokenForCacheReuse(createToken({ graphVersion: "graph:a" }), { + graphVersion: "graph:b", + installedCacheVariantFingerprint: null, + }); + + expect(verdict).toEqual({ authorized: false, reason: "graphVersionMismatch" }); + }); + + it("tolerates an absent graph version rather than rejecting low-context reuse", () => { + const verdict = verifyOperationTokenForCacheReuse(createToken({ graphVersion: null }), { + graphVersion: null, + installedCacheVariantFingerprint: null, + }); + + expect(verdict.authorized).toBe(true); + }); + + it("rejects cacheVariantMismatch when the installed variant differs from the proof", () => { + const verdict = verifyOperationTokenForCacheReuse( + createToken({ graphVersion: "graph:a", cacheVariantFingerprint: "cv:a" }), + { graphVersion: "graph:a", installedCacheVariantFingerprint: "cv:b" }, + ); + + expect(verdict).toEqual({ authorized: false, reason: "cacheVariantMismatch" }); + }); + + it("tolerates an absent cache-variant fingerprint until segment cache supplies it", () => { + const verdict = verifyOperationTokenForCacheReuse( + createToken({ graphVersion: "graph:a", cacheVariantFingerprint: undefined }), + { graphVersion: "graph:a", installedCacheVariantFingerprint: "cv:b" }, + ); + + expect(verdict.authorized).toBe(true); + }); + + it("reports graphVersionMismatch before cacheVariantMismatch when both diverge", () => { + const verdict = verifyOperationTokenForCacheReuse( + createToken({ graphVersion: "graph:a", cacheVariantFingerprint: "cv:a" }), + { graphVersion: "graph:b", installedCacheVariantFingerprint: "cv:b" }, + ); + + expect(verdict).toEqual({ authorized: false, reason: "graphVersionMismatch" }); + }); +}); + +describe("verifyOperationToken required dimensions", () => { + it("fails closed when a required dimension's authority fact is absent", () => { + // Future segment-cache writes will require the cache-variant dimension. A + // forgotten fingerprint must reject, not silently pass: absence is not + // permission. + const verdict = verifyOperationToken( + createToken({ cacheVariantFingerprint: undefined }), + createAuthority({ installedCacheVariantFingerprint: null }), + { check: ["cacheVariant"], require: ["cacheVariant"] }, + ); + + expect(verdict).toEqual({ authorized: false, reason: "cacheVariantMissing" }); + }); + + it("rejects a required-but-absent graph version with graphVersionMissing", () => { + const verdict = verifyOperationToken( + createToken({ graphVersion: null }), + createAuthority({ graphVersion: "graph:test" }), + { check: ["graphVersion"], require: ["graphVersion"] }, + ); + + expect(verdict).toEqual({ authorized: false, reason: "graphVersionMissing" }); + }); + + it("skips an unchecked dimension entirely even when it diverges", () => { + const verdict = verifyOperationToken( + createToken({ graphVersion: "graph:a" }), + createAuthority({ graphVersion: "graph:b" }), + { check: ["navigation"], require: ["navigation"] }, + ); + + expect(verdict.authorized).toBe(true); + }); + + it("evaluates a required dimension even when it is omitted from check", () => { + // require implies evaluation: a dimension you require but forget to list in + // check must still be verified, never silently waved through. + const verdict = verifyOperationToken( + createToken({ graphVersion: "graph:a" }), + createAuthority({ graphVersion: "graph:b" }), + { check: ["navigation"], require: ["graphVersion"] }, + ); + + expect(verdict).toEqual({ authorized: false, reason: "graphVersionMismatch" }); + }); +});