From c31ff1f5bee62bad37423f9bbb7ac5b24021fd83 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 28 Aug 2026 10:56:18 +0900 Subject: [PATCH 1/2] feat(kit): defer authenticated offline runtime --- projects/kit/docs/offline-realtime.md | 31 +++++ .../src/lib/offline-auth-bridge.spec.ts | 27 +++++ .../offline/src/lib/offline-auth-bridge.ts | 29 ++++- .../lib/offline-coordinator.service.spec.ts | 12 +- .../src/lib/offline-coordinator.service.ts | 9 +- .../offline/src/lib/offline-provider.spec.ts | 48 +++++++- .../kit/offline/src/lib/offline-provider.ts | 14 ++- .../kit/src/lib/auth/auth-access.service.ts | 11 ++ projects/kit/src/lib/auth/auth-guards.spec.ts | 109 +++++++++++++----- projects/kit/src/lib/auth/auth-guards.ts | 36 +++++- 10 files changed, 279 insertions(+), 47 deletions(-) diff --git a/projects/kit/docs/offline-realtime.md b/projects/kit/docs/offline-realtime.md index da8209d4..639c960b 100644 --- a/projects/kit/docs/offline-realtime.md +++ b/projects/kit/docs/offline-realtime.md @@ -69,6 +69,37 @@ not rely on ordering between guards declared in the same `canActivate` array. Keep `provideOffline()` for existing root installations. Moving a provider is an application design choice; adopting the new API is not a required migration. +### Defer remote work until authenticated content is visible + +Route-scoped applications can open only the local substrate on the activation path, then resume +pull and Outbox transport after their first useful content has rendered. Existing applications keep +the blocking behavior unless they opt in to both settings below. + +```ts +const offlineReadyGuard: CanActivateFn = async () => { + await inject(OfflineRouteInitializerService).initialize({ remote: 'deferred' }); + return true; +}; + +createOfflineAuthBridge({ + exchange, + currentAuthSubject, + isUnavailableError, + resumeMode: 'background', + beforeRemoteResume: () => authenticatedContentReady.wait(), +}); +``` + +`activate` still installs and lease-checks the remotely verified identity before the guard grants +access. Only `resumeRemoteSession()` is deferred. Keep `resumeMode: 'blocking'` when the route needs +the first pull or Outbox replay before it can render safely. A readiness promise should include a +bounded fallback so a deep link that does not render the primary content cannot suspend transport +indefinitely. Always call `startRemoteRuntime()` after that same boundary, including when the +credential exchange falls back to local access. This installs network discovery so an offline start +can recover immediately when connectivity returns. Start the fallback timer only after local access +has been granted; starting it in the local initializer can launch the remote runtime while a slow +credential exchange is still pending. + ## Realtime connection Subclass `KitRealtimeConnection` to supply connection intent and `{ url, protocols }` targets. The kit owns foreground and network suspension, target-scoped reconnect, exponential backoff, ping/pong detection, self-echo annotation, and `reconnected$` resync signaling. diff --git a/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts b/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts index 6a0bb0df..d7a51171 100644 --- a/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts +++ b/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts @@ -60,7 +60,9 @@ function setupBridge( isUnavailableError: overrides.isUnavailableError ?? (() => true), availability: overrides.availability ?? (() => of(true)), isIdentityCurrent: overrides.isIdentityCurrent, + beforeRemoteResume: overrides.beforeRemoteResume, onRemoteResumed: overrides.onRemoteResumed, + resumeMode: overrides.resumeMode, retryDelayMs: overrides.retryDelayMs, }); @@ -84,12 +86,37 @@ describe('createOfflineAuthBridge', () => { ['2'], 'subject-a', expect.objectContaining({ isCurrent: expect.any(Function) }), + { deferRuntime: false }, ); await recovery.resume(lease); expect(order).toEqual(['exchange-authorize', 'prepare', 'resume']); }); + it('waits for the product readiness boundary before resuming transport', async () => { + const order: string[] = []; + const { bridge, offline } = setupBridge({ + beforeRemoteResume: async () => void order.push('ready'), + onRemoteResumed: async () => void order.push('resumed'), + resumeMode: 'background', + }); + const { lease } = createLease(); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + await recovery.activate(lease); + await recovery.resume(lease); + + expect(recovery.resumeMode).toBe('background'); + expect(offline.prepareRemoteSession).toHaveBeenCalledWith( + 1, + ['2'], + 'subject-a', + expect.objectContaining({ isCurrent: expect.any(Function) }), + { deferRuntime: true }, + ); + expect(order).toEqual(['ready', 'resumed']); + }); + it('rejects activation when the lease becomes stale after exchange', async () => { const { lease, invalidate } = createLease(); const { bridge } = setupBridge({ diff --git a/projects/kit/offline/src/lib/offline-auth-bridge.ts b/projects/kit/offline/src/lib/offline-auth-bridge.ts index 96529e91..e157d8fe 100644 --- a/projects/kit/offline/src/lib/offline-auth-bridge.ts +++ b/projects/kit/offline/src/lib/offline-auth-bridge.ts @@ -54,6 +54,10 @@ export interface CreateOfflineAuthBridgeOptions boolean; /** Optional product hook after the kit publishes remote access and transport resumes. */ readonly onRemoteResumed?: (context: OfflineAuthResumeContext) => Promise; + /** Optional readiness boundary to await after remote access is published and before transport resumes. */ + readonly beforeRemoteResume?: (context: OfflineAuthResumeContext) => Promise; + /** Whether guarded route activation waits for transport resume. Defaults to `blocking`. */ + readonly resumeMode?: 'blocking' | 'background'; /** Delay before {@link KitAuthRecoveryService} retries recovery while local access remains active. */ readonly retryDelayMs?: number; } @@ -90,7 +94,16 @@ export function createOfflineAuthBridge options: CreateOfflineAuthBridgeOptions, ): OfflineAuthBridgeConfig { const offline = options.offline ?? inject(OfflineCoordinatorService); - const { exchange, currentAuthSubject, isUnavailableError, isIdentityCurrent, onRemoteResumed, retryDelayMs } = options; + const { + exchange, + currentAuthSubject, + isUnavailableError, + isIdentityCurrent, + beforeRemoteResume, + onRemoteResumed, + resumeMode, + retryDelayMs, + } = options; const defaultAvailability$ = options.availability ? undefined : toObservable(offline.networkState).pipe( @@ -115,18 +128,30 @@ export function createOfflineAuthBridge if (!identityStillCurrent(lease, identity)) return false; return { + resumeMode, activate: async (activateLease) => { if (!identityStillCurrent(activateLease, identity)) return false; const identityLease: KitAuthAccessLease = { isCurrent: () => identityStillCurrent(activateLease, identity), }; - const prepared = await offline.prepareRemoteSession(identity.userId, identity.scopeIds, identity.authSubject, identityLease); + const prepared = await offline.prepareRemoteSession(identity.userId, identity.scopeIds, identity.authSubject, identityLease, { + deferRuntime: resumeMode === 'background', + }); return prepared && identityLease.isCurrent(); }, resume: async (resumeLease) => { const resumeStillCurrent = (): boolean => (resumeLease?.isCurrent() ?? true) && currentAuthSubject() === identity.authSubject && (isIdentityCurrent?.(identity) ?? true); if (!resumeStillCurrent()) return; + if (beforeRemoteResume) { + await beforeRemoteResume({ + phase, + state, + lease: resumeLease ?? lease, + identity, + }); + if (!resumeStillCurrent()) return; + } await offline.resumeRemoteSession( identity.foregroundScopeIds !== undefined ? { foregroundScopeIds: identity.foregroundScopeIds } : undefined, ); diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index c5915930..1e3640c6 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -184,23 +184,21 @@ describe('OfflineCoordinatorService', () => { expect(sync.initialize).toHaveBeenCalledOnce(); }); - it('does not start a session transition before runtime initialization completes', async () => { + it('prepares a verified session without waiting for remote runtime initialization', async () => { let releaseNetwork: (() => void) | undefined; const networkGate = new Promise((resolve) => { releaseNetwork = resolve; }); const { coordinator, order, sync } = setup(null, { networkInitialize: () => networkGate }); - const activation = coordinator.prepareRemoteSession(1, ['2'], 'subject'); + const activation = coordinator.prepareRemoteSession(1, ['2'], 'subject', undefined, { deferRuntime: true }); await coordinator.initializeLocal(); - expect(sync.initialize).toHaveBeenCalledOnce(); - expect(order).toEqual([]); - - releaseNetwork?.(); await expect(activation).resolves.toBe(true); - expect(sync.initialize).toHaveBeenCalledOnce(); + expect(sync.initialize).not.toHaveBeenCalled(); expect(order).toEqual(['reset', 'suspend-remote', 'activate-remote']); + + releaseNetwork?.(); }); it('does not revive a remote activation invalidated by logout while network initialization waits', async () => { diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index 76a0710c..e2899117 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -19,6 +19,12 @@ export interface OfflineResumeRemoteSessionOptions { readonly foregroundScopeIds?: readonly string[]; } +/** Startup policy for preparing a remotely verified session boundary. */ +export interface OfflinePrepareRemoteSessionOptions { + /** Skip network and sync startup until {@link resumeRemoteSession}. Defaults to `false`. */ + readonly deferRuntime?: boolean; +} + /** Coordinates local persistence, session boundaries, network state, and outbox synchronization. */ @Injectable({ providedIn: 'root' }) export class OfflineCoordinatorService { @@ -115,10 +121,11 @@ export class OfflineCoordinatorService { scopeIds: readonly string[], authSubject: string | null, authLease?: OfflineSessionTransitionLease, + options: OfflinePrepareRemoteSessionOptions = {}, ): Promise { const revision = ++this.#transitionRevision; const lease = this.#lease(revision, authLease); - await this.initialize(); + await (options.deferRuntime ? this.initializeLocal() : this.initialize()); if (this.#storageUnavailable()) return true; if (!lease.isCurrent()) return false; return this.#enqueueTransition(async () => { diff --git a/projects/kit/offline/src/lib/offline-provider.spec.ts b/projects/kit/offline/src/lib/offline-provider.spec.ts index 3c514104..9296ccc0 100644 --- a/projects/kit/offline/src/lib/offline-provider.spec.ts +++ b/projects/kit/offline/src/lib/offline-provider.spec.ts @@ -169,7 +169,10 @@ describe('provideOffline', () => { commandHooks: ParentCommandHooks, replicaProjector: ParentReplicaProjector, }), - { provide: OfflineCoordinatorService, useValue: { initialize: vi.fn(async () => undefined), initializeLocal: vi.fn(async () => undefined) } }, + { + provide: OfflineCoordinatorService, + useValue: { initialize: vi.fn(async () => undefined), initializeLocal: vi.fn(async () => undefined) }, + }, ], }); const parent = TestBed.inject(EnvironmentInjector); @@ -253,4 +256,47 @@ describe('provideOffline', () => { expect(coordinator.initializeLocal).toHaveBeenCalledOnce(); expect(coordinator.initialize).toHaveBeenCalledOnce(); }); + + it('can defer route transport initialization while still opening local storage once', async () => { + const coordinator = { + initialize: vi.fn(async () => undefined), + initializeLocal: vi.fn(async () => undefined), + }; + TestBed.configureTestingModule({ + providers: [ + OfflineRouteInitializerService, + { provide: OfflineCoordinatorService, useValue: coordinator }, + { provide: ErrorHandler, useValue: { handleError: vi.fn() } }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { mode: 'readCacheOnly' } }, + ], + }); + + const initializer = TestBed.inject(OfflineRouteInitializerService); + await initializer.initialize({ remote: 'deferred' }); + + expect(coordinator.initializeLocal).toHaveBeenCalledOnce(); + expect(coordinator.initialize).not.toHaveBeenCalled(); + }); + + it('can start network discovery after deferred local initialization', async () => { + const coordinator = { + initialize: vi.fn(async () => undefined), + initializeLocal: vi.fn(async () => undefined), + }; + TestBed.configureTestingModule({ + providers: [ + OfflineRouteInitializerService, + { provide: OfflineCoordinatorService, useValue: coordinator }, + { provide: ErrorHandler, useValue: { handleError: vi.fn() } }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { mode: 'readCacheOnly' } }, + ], + }); + + const initializer = TestBed.inject(OfflineRouteInitializerService); + await initializer.initialize({ remote: 'deferred' }); + await initializer.startRemoteRuntime(); + + expect(coordinator.initializeLocal).toHaveBeenCalledTimes(2); + expect(coordinator.initialize).toHaveBeenCalledOnce(); + }); }); diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 7b6b88b8..4e42e5cb 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -117,15 +117,21 @@ export class OfflineRouteInitializerService { readonly #options = inject(OFFLINE_KIT_OPTIONS); #initialization: Promise | null = null; - /** Initialize local storage once, then continue transport initialization in the background. */ - initialize(): Promise { - return (this.#initialization ??= this.#initialize()); + /** Initialize local storage once, optionally continuing transport initialization in the background. */ + initialize(options: { remote?: 'background' | 'deferred' } = {}): Promise { + return (this.#initialization ??= this.#initialize(options.remote ?? 'background')); } - #initialize(): Promise { + /** Starts deferred network discovery and synchronization without delaying the caller on transport. */ + startRemoteRuntime(): Promise { assertSupportedOfflineMode(Capacitor.getPlatform(), this.#options.mode ?? 'synchronized'); return initializeOfflineRuntime(this.#coordinator, this.#errorHandler); } + + #initialize(remote: 'background' | 'deferred'): Promise { + assertSupportedOfflineMode(Capacitor.getPlatform(), this.#options.mode ?? 'synchronized'); + return remote === 'deferred' ? this.#coordinator.initializeLocal() : initializeOfflineRuntime(this.#coordinator, this.#errorHandler); + } } /** diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index 2c44850a..633034d7 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -35,6 +35,17 @@ export interface KitRemoteAccessRecovery { * with callers that manually resumed a recovery result before leases were introduced. */ resume(lease?: KitAuthAccessLease): Promise; + /** + * Whether route activation must wait for {@link resume} to settle. + * + * @remarks + * Use `background` only when {@link activate} has already installed every identity and local + * capability boundary required to render the route safely. Authentication denial still revokes + * access; transport unavailability is handled by the configured recovery policy. + * + * @defaultValue 'blocking' + */ + resumeMode?: 'blocking' | 'background'; } /** Recovery-specific authentication configuration consumed by {@link KitAuthRecoveryService}. */ diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index dc483e19..fb6744bf 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -1,4 +1,4 @@ -import { provideZonelessChangeDetection } from '@angular/core'; +import { ErrorHandler, provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import type { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Router } from '@angular/router'; @@ -67,6 +67,7 @@ function setup( ) { const navigate = vi.fn().mockResolvedValue(true); const setDirection = vi.fn(); + const handleError = vi.fn(); TestBed.configureTestingModule({ providers: [ @@ -81,10 +82,11 @@ function setup( })), { provide: Router, useValue: { navigate } }, { provide: NavController, useValue: { setDirection } }, + { provide: ErrorHandler, useValue: { handleError } }, ], }); - return { navigate, setDirection, onAuthorized, onUnauthenticated, onUnavailable, isUnavailableError }; + return { navigate, setDirection, handleError, onAuthorized, onUnauthenticated, onUnavailable, isUnavailableError }; } // --------------------------------------------------------------------------- @@ -234,6 +236,79 @@ describe('kitRequireAuthorizedGuard', () => { expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); + it("'user' → does not block route activation on background transport resume", async () => { + let releaseResume!: () => void; + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const resume = vi.fn(() => resumeGate); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume, + resumeMode: 'background' as const, + })); + setup('user', { onAuthorized }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); + expect(resume).toHaveBeenCalledOnce(); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); + + releaseResume(); + await resumeGate; + }); + + it("'user' → reports an unclassified background transport failure without rejecting activation", async () => { + const failure = new Error('resume failed'); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async () => { + throw failure; + }, + resumeMode: 'background' as const, + })); + const { handleError } = setup('user', { onAuthorized }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); + await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(failure)); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); + }); + + it.each([401, 403])("'user' → redirects after an explicit denial from background resume", async (status) => { + const denial = { status }; + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async () => Promise.reject(denial), + resumeMode: 'background' as const, + })); + const { navigate, setDirection, handleError } = setup('user', { onAuthorized }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); + await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized])); + + expect(setDirection).toHaveBeenCalledWith('root'); + expect(handleError).toHaveBeenCalledWith(denial); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); + }); + + it("'user' → ignores a stale explicit denial from background resume", async () => { + let rejectResume!: (reason: unknown) => void; + const resumeGate = new Promise((_resolve, reject) => (rejectResume = reject)); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async () => resumeGate, + resumeMode: 'background' as const, + })); + const { navigate, handleError } = setup('user', { onAuthorized }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); + TestBed.inject(KitAuthAccessService).clear(); + rejectResume({ status: 401 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(navigate).not.toHaveBeenCalled(); + expect(handleError).not.toHaveBeenCalled(); + }); + it("'user' → invalidates the post-grant resume lease before stale user-visible effects", async () => { let markResumeStarted!: () => void; let releaseResume!: () => void; @@ -295,9 +370,7 @@ describe('kitRequireAuthorizedGuard', () => { })); setup('user', { onAuthorized, isUnavailableError: (error) => error === networkError }); - await expect( - runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))), - ).resolves.toBe(true); + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); @@ -311,9 +384,7 @@ describe('kitRequireAuthorizedGuard', () => { })); setup('user', { onAuthorized, isUnavailableError: (error) => error === networkError }); - await expect( - runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))), - ).resolves.toBe(true); + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); @@ -367,11 +438,7 @@ describe('kitRequireAuthorizedGuard', () => { const { onUnauthenticated } = setup('unavailable', { onUnavailable }); const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); expect(result).toBe(true); - expect(onUnavailable).toHaveBeenCalledWith( - stateStub, - undefined, - expect.objectContaining({ isCurrent: expect.any(Function) }), - ); + expect(onUnavailable).toHaveBeenCalledWith(stateStub, undefined, expect.objectContaining({ isCurrent: expect.any(Function) })); expect(onUnauthenticated).not.toHaveBeenCalled(); expect(TestBed.inject(KitAuthAccessService).mode).toBe('local'); }); @@ -380,9 +447,7 @@ describe('kitRequireAuthorizedGuard', () => { const onUnavailable = vi.fn().mockResolvedValue(false); const { navigate } = setup('unavailable', { onUnavailable }); - await expect( - runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))), - ).resolves.toBe(false); + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(false); expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized]); expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); }); @@ -413,11 +478,7 @@ describe('kitRequireAuthorizedGuard', () => { }); const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); expect(result).toBe(true); - expect(onUnavailable).toHaveBeenCalledWith( - stateStub, - networkError, - expect.objectContaining({ isCurrent: expect.any(Function) }), - ); + expect(onUnavailable).toHaveBeenCalledWith(stateStub, networkError, expect.objectContaining({ isCurrent: expect.any(Function) })); }); it('unclassified onAuthorized error propagates without local fallback', async () => { @@ -598,11 +659,7 @@ describe('kitRequireAuthorizedGuard — auth state source errors', () => { const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); expect(result).toBe(true); - expect(onUnavailable).toHaveBeenCalledWith( - stateStub, - networkError, - expect.objectContaining({ isCurrent: expect.any(Function) }), - ); + expect(onUnavailable).toHaveBeenCalledWith(stateStub, networkError, expect.objectContaining({ isCurrent: expect.any(Function) })); }); it('does not classify an error from onUnavailable a second time', async () => { diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index 397728af..e079396f 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -1,5 +1,5 @@ import type { EnvironmentProviders } from '@angular/core'; -import { inject, InjectionToken, makeEnvironmentProviders, provideAppInitializer } from '@angular/core'; +import { ErrorHandler, inject, InjectionToken, makeEnvironmentProviders, provideAppInitializer } from '@angular/core'; import type { CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Router } from '@angular/router'; import { NavController } from '@ionic/angular/common'; @@ -270,10 +270,19 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { const router = inject(Router); const navCtrl = inject(NavController); const access = inject(KitAuthAccessService); + const errorHandler = inject(ErrorHandler); const lease = access.beginTransition({ suspendRemote: true }); - const redirectUnauthorized = (): false => { - if (!lease.isCurrent()) return false; + const reportError = (error: unknown): void => { + try { + errorHandler.handleError(error); + } catch { + // Error reporting must not create an unhandled background rejection. + } + }; + + const redirectUnauthorized = (currentLease: KitAuthAccessLease = lease): false => { + if (!currentLease.isCurrent()) return false; access.clear(); navCtrl.setDirection('root'); router.navigate([redirects.whenUnauthorized]); @@ -304,15 +313,30 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { const resumeLease = access.grantRemote(); if (!resumeLease.isCurrent()) return false; const resume = async (): Promise => result.resume(resumeLease); - await resume().catch((error: unknown) => { + const handleResumeError = (error: unknown): void => { if (!resumeLease.isCurrent()) return; if (isExplicitAuthDenial(error)) { + if (result.resumeMode === 'background') { + redirectUnauthorized(resumeLease); + reportError(error); + return; + } access.clear(); throw error; } if (!isUnavailableError?.(error)) throw error; - return; - }); + }; + if (result.resumeMode === 'background') { + void resume().catch((error: unknown) => { + try { + handleResumeError(error); + } catch (unhandledError: unknown) { + reportError(unhandledError); + } + }); + return resumeLease.isCurrent(); + } + await resume().catch(handleResumeError); return resumeLease.isCurrent(); } if (result === true) access.grantRemote(); From 8cb05a9a8a30c2b5380e27dcda37e5014c346387 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 28 Aug 2026 11:03:28 +0900 Subject: [PATCH 2/2] test(kit): await stale resume rejection --- projects/kit/src/lib/auth/auth-guards.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index fb6744bf..983b833e 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -293,17 +293,19 @@ describe('kitRequireAuthorizedGuard', () => { it("'user' → ignores a stale explicit denial from background resume", async () => { let rejectResume!: (reason: unknown) => void; const resumeGate = new Promise((_resolve, reject) => (rejectResume = reject)); + const resume = vi.fn(async () => resumeGate); const onAuthorized = vi.fn(async () => ({ activate: async () => true, - resume: async () => resumeGate, + resume, resumeMode: 'background' as const, })); const { navigate, handleError } = setup('user', { onAuthorized }); await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).resolves.toBe(true); TestBed.inject(KitAuthAccessService).clear(); - rejectResume({ status: 401 }); - await new Promise((resolve) => setTimeout(resolve, 0)); + const denial = { status: 401 }; + rejectResume(denial); + await expect(resume.mock.results[0]?.value).rejects.toBe(denial); expect(navigate).not.toHaveBeenCalled(); expect(handleError).not.toHaveBeenCalled();