diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index b6d27cf7..25ea1947 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -2,11 +2,12 @@ import { logger } from '@/lib/logger' import { scopedKey } from '@/lib/storage-scope' -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { Spinner } from '@/components/ui/spinner' import { useRouter } from 'next/navigation' import { PlatformAuthController, type AuthUser as PlatformAuthUser, type PlatformAuthIntent } from 'platform-auth' -import { createYapprPlatformAuthDependencies } from '@/lib/auth/platform-auth-adapters' +import { createYapprPlatformAuthDependencies, hasYapprProfile } from '@/lib/auth/platform-auth-adapters' +import { identityService } from '@/lib/services/identity-service' import { extractErrorMessage, isAlreadyExistsError } from '@/lib/error-utils' import { useUsernameModal } from '@/hooks/use-username-modal' @@ -108,6 +109,61 @@ function decodeBase64ToBytes(value: string): Uint8Array { return bytes } +/** + * Routes that intentionally host a signed-in user who has no profile document + * yet. Bouncing them off these would make profile creation and username + * registration unreachable. + * + * Matched as suffixes so the `/testing` deployment's `basePath` still resolves. + */ +const PROFILE_OPTIONAL_ROUTES = ['/profile/create', '/dpns/register', '/login', '/welcome'] + +function isProfileOptionalRoute(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') + return PROFILE_OPTIONAL_ROUTES.some((route) => normalized.endsWith(route)) +} + +/** + * Reproduces the controller's `profile-required` decision for a *restored* + * session. + * + * `restoreSession()` hands back a user without ever consulting `deps.profiles`, + * so the intent that `loginWithAuthKey` returns on the interactive login path + * is never built on reload — a profile-less identity stays put instead of being + * sent to `/profile/create`. Returns the intent to apply, or `null` to leave + * the user where they are. + * + * Assumes the controller's `profileGate` feature is on, which it is: it + * defaults to true and Yappr passes no `features` override. + */ +async function profileIntentForRestoredSession(user: PlatformAuthUser): Promise { + // The controller returns at the *first* gate that fires, and the username + // gate is checked before the profile gate. On restore it is `withAuth` that + // pushes to /dpns/register, so bail here rather than race it to the router. + const skipDPNS = sessionStorage.getItem(scopedKey('yappr_skip_dpns')) === 'true' + if (!user.username && !skipDPNS) return null + + if (isProfileOptionalRoute(window.location.pathname)) return null + + if (await hasYapprProfile(user.identityId, user.username)) return null + + // `hasYapprProfile` cannot tell "no profile" apart from "the query failed" — + // both surface as false. `identityService.getIdentity` rethrows instead of + // swallowing, which makes it a usable liveness probe: let a failure reject so + // the caller fails open, and treat a missing identity as "not our call". + // `app/user/page.tsx` probes the same way before its own /profile/create push, + // though it goes further and logs the user out when the identity is gone — + // too destructive to do from a provider-wide effect. + const identity = await identityService.getIdentity(user.identityId) + if (!identity) return null + + // The lookups above are network round trips; the user may have navigated to a + // profile-less-friendly route in the meantime. + if (isProfileOptionalRoute(window.location.pathname)) return null + + return { kind: 'profile-required', identityId: user.identityId, username: user.username } +} + export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter() const controller = useMemo(() => new PlatformAuthController(createYapprPlatformAuthDependencies()), []) @@ -115,16 +171,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => controller.subscribe(setControllerState), [controller]) - useEffect(() => { - controller.restoreSession().catch((error) => { - logger.error('Auth: Failed to restore session:', error) - }) - - return () => { - controller.dispose() - } - }, [controller]) - const applyIntent = useCallback(async (intent: PlatformAuthIntent): Promise => { switch (intent.kind) { case 'username-required': @@ -142,6 +188,42 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [router]) + // The restore effect below must run exactly once per controller — its cleanup + // disposes the controller — so it reaches `applyIntent` through a ref instead + // of taking it as a dependency. + const applyIntentRef = useRef(applyIntent) + useEffect(() => { + applyIntentRef.current = applyIntent + }, [applyIntent]) + + useEffect(() => { + let cancelled = false + + const restoreAndGate = async (): Promise => { + const restoredUser = await controller.restoreSession() + if (cancelled || !restoredUser) return + + const intent = await profileIntentForRestoredSession(restoredUser).catch((error) => { + // Fail open: a gate that cannot reach the network must never strand a + // user who does have a profile on /profile/create. + logger.error('Auth: Failed to evaluate the profile gate after session restore:', error) + return null + }) + if (cancelled || !intent) return + + await applyIntentRef.current(intent) + } + + restoreAndGate().catch((error) => { + logger.error('Auth: Failed to restore session:', error) + }) + + return () => { + cancelled = true + controller.dispose() + } + }, [controller]) + const login = useCallback(async (identityId: string, privateKey: string, options: { skipUsernameCheck?: boolean } = {}) => { const result = await controller.loginWithAuthKey(identityId, privateKey, options) await applyIntent(result.intent) diff --git a/docs/TESTING.md b/docs/TESTING.md index 536e2d29..68ad8c0c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -185,6 +185,11 @@ Rare, manual, never from CI. Everything below runs from the repo root. which is why they are recorded in `.env.testing`. The script prints the ID to append to `E2E_IDENTITY_IDS`. + Then **create the identity's profile by hand**: seed the session per §3 and + complete `/profile/create`. The restore-time profile gate redirects every + non-exempt page for a profile-less identity, which strands the write suite + before it reaches its own profile-creating test — see §7. + 3. **Register the test contracts** (owner defaults to identity index 0): ```bash @@ -290,13 +295,44 @@ Register test copies first (`scripts/register-test-contracts.mjs` is the pattern — extend it), fill in the corresponding `NEXT_PUBLIC_*_CONTRACT_ID` in `.env.testing`, and only then write the specs. -### Session restore does not run the profile gate - -The `profile-required` intent is only applied by the interactive login path -(`applyIntent` in `contexts/auth-context.tsx`). `restoreSession()` does not go -through it, so a seeded profile-less identity is *not* bounced to -`/profile/create` — except on the own-profile page (`app/user/page.tsx`), which -redirects on its own. Do not rely on the redirect to prove a profile exists. +### Session restore runs the profile gate too (it used to not) + +It used to be that the `profile-required` intent was only applied by the +interactive login path, so a seeded profile-less identity was never bounced to +`/profile/create` on reload. That is fixed: `AuthProvider` now re-derives the +intent after `restoreSession()` resolves and applies it through the same +`applyIntent`, so **a seeded identity without a profile document will be +redirected to `/profile/create`** on the first navigation. + +What this means for tests: + +- The provisioned bot identities have profiles, so the gate resolves to "no + redirect" and the write specs pass unaffected. +- **A freshly provisioned pool slot must have its profile created before the + write suite can pass.** `post-lifecycle.spec.ts` is no longer self- + bootstrapping: "the bot identity has a profile" is the third test, but the two + ahead of it navigate to `/about/` and `/feed/`, and neither is gate-exempt — + so on a slot with no profile document both get redirected to `/profile/create` + and fail. The group is `mode: 'serial'`, so retries restart at test 1 and the + profile-creating test never runs. The first test cannot simply be reordered + after it: it is what proves the bundle targets the *test* contracts, and + creating a profile ahead of that check risks writing to production. + Create the profile by hand once (log in as the bot per §3 and complete + `/profile/create`) as part of the §4 provisioning runbook. The same applies + after a chain rollback that wipes documents but leaves identities. +- The gate is skipped entirely on `/profile/create`, `/dpns/register`, + `/login`, and `/welcome` (suffix-matched, so `basePath` is fine), and it + yields to the DPNS gate when the restored session has no username and + `yappr_skip_dpns` is not set. +- It fails **open**: the profile lookup swallows query errors and returns + `null`, so a redirect additionally requires `identityService.getIdentity` to + succeed. A DAPI outage means no redirect, not a spurious one. +- The redirect fires *after* two network round trips, so it can land on a page + that has already rendered. Assert on a stable URL, not on the first paint. + +The own-profile page (`app/user/page.tsx`) still redirects on its own as well. +A redirect away from `/profile/create` proves a profile exists; staying on a +page does **not** prove the gate ran — it may still be in flight. ### The DPNS gate fires on optional-auth pages too diff --git a/lib/auth/platform-auth-adapters.ts b/lib/auth/platform-auth-adapters.ts index 024d051f..6cbe17a5 100644 --- a/lib/auth/platform-auth-adapters.ts +++ b/lib/auth/platform-auth-adapters.ts @@ -191,6 +191,26 @@ function toPasskeyAccess(access: Awaited { + await ensureSdk() + const unifiedProfile = await unifiedProfileService.getProfile(identityId, username) + if (unifiedProfile) return true + const legacyProfile = await profileService.getProfile(identityId, username) + return Boolean(legacyProfile) +} + async function runPostLogin(identityId: string, context: { delayMs: number; isSessionActive: () => boolean }): Promise { void import('@/lib/services/block-service').then(async ({ blockService }) => { try { @@ -336,13 +356,7 @@ export function createYapprPlatformAuthDependencies(): PlatformAuthDependencies }, }, profiles: { - async hasProfile(identityId, username) { - await ensureSdk() - const unifiedProfile = await unifiedProfileService.getProfile(identityId, username) - if (unifiedProfile) return true - const legacyProfile = await profileService.getProfile(identityId, username) - return Boolean(legacyProfile) - }, + hasProfile: hasYapprProfile, }, clientIdentity: { setIdentity(identityId) {