Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 94 additions & 12 deletions contexts/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -108,23 +109,68 @@ 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<PlatformAuthIntent | null> {
// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Re-evaluate the gate after leaving an exempt route

This early return permanently skips the profile check for this restored session because restoreAndGate runs only once in the [controller] effect, while the root-level AuthProvider persists across client-side navigation. For example, a profile-less user with a username can restore on /login, return here, and then be pushed to /feed by app/login/page.tsx; /welcome also links directly to /feed, and /dpns/register redirects a user who already has a username there. No pathname change causes the gate to run again, so the user can continue browsing without a profile. Preserve or re-evaluate the restored user's profile status when leaving an exempt route, while suppressing navigation only while the user is actually on profile creation or the active username-registration flow.

source: ['codex']


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
Comment on lines +150 to +158

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: The identity probe cannot distinguish a failed profile query

Both unifiedProfileService.getProfile and profileService.getProfile catch query errors and return null, while identityService.getIdentity checks a separate identity endpoint. A successful identity fetch therefore does not establish that either profile query completed successfully. If the unified-profile query fails but the legacy query successfully finds nothing—or both profile queries encounter a contract-specific failure—an existing unified-profile user is incorrectly redirected to profile creation as soon as the identity endpoint responds. Use strict or result-bearing profile lookups that preserve lookup failures, and redirect only when both profile document queries conclusively succeed and report absence.

source: ['codex']


// 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()), [])
const [controllerState, setControllerState] = useState(() => controller.getState())

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<void> => {
switch (intent.kind) {
case 'username-required':
Expand All @@ -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<void> => {
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)
Comment on lines +202 to +214

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Add regression coverage for the restored profile gate

The existing smoke project restores no authenticated session, and the write identities already have profiles, so neither suite exercises the newly added redirect branch. Add focused coverage for a restored profile-less session on a gated route, a profiled session, a profile-query failure, and navigation from an exempt route to a gated route. The latter two cases would directly prevent regressions of the blocking issues above.

source: ['codex']

}

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)
Expand Down
50 changes: 43 additions & 7 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
28 changes: 21 additions & 7 deletions lib/auth/platform-auth-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,26 @@ function toPasskeyAccess(access: Awaited<ReturnType<typeof authVaultAccessServic
}
}

/**
* Profile presence exactly as the auth gates define it: a unified profile
* document, or a legacy one.
*
* Exported because the session-restore path in `contexts/auth-context.tsx` has
* to ask the same question the controller asks through `deps.profiles` on the
* interactive login path — `restoreSession()` never consults it.
*
* Note for callers: both lookups swallow query failures and return `null`, so a
* `false` here means "no profile *or* the query failed". Confirm the network is
* actually reachable before treating it as grounds for a redirect.
*/
export async function hasYapprProfile(identityId: string, username?: string): Promise<boolean> {
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> {
void import('@/lib/services/block-service').then(async ({ blockService }) => {
try {
Expand Down Expand Up @@ -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) {
Expand Down
Loading