Files
festipod/src/app/AuthGate.tsx
T
Sylvain Duchesne 53c0e095cf Code against the polyfill's published contract, and nothing else
The data layer is now reached through one pulled, version-pinned engagement
(`.project/concepts/data-layer/contract_polyfill-surface.md`, @1ecf511e9d).
That copy is the only reference: the provider's sources are never opened, and
what the contract does not answer is a gap raised with it, never worked around
here.

Surface
- `@ng-eventually/sdk` -> `@ng-eventually/polyfill`, one entry point.
- `configure` loses `getSession`, `normalizeId`, `currentUser`; the session
  belongs to the package and its own `init` captures it.
- Placement is named by scope alone -- a session is one user, so the app no
  longer passes an identity it had no way to obtain. This removes a constant
  that made every user collide on one owner's document.
- `init(...)` then `await ensureIdentity()`, in that order, as one sequence:
  React runs child effects first, so the two calls sat in the wrong order and
  the contract now makes that throw.
- `sessionId` relayed as `string | number`, `materialize` -> `read`.

A rejection means "unknown", never "absent"
Four places treated a caught error as an empty result. The worst wrote a
duplicate participation: an unknown count read as zero defeated the idempotence
guard of `joinEvent`. Also fixed: a per-document count, a silently dropped
notification shown optimistically anyway, and a failed listing that left the
owned-event set empty and disabled the materializer for the whole session.

Shared identity is not a Festipod notion
A browser context is one user. The per-scenario identity plant is deleted at
its source and its five sites; what stays is the deployment's wallet file,
which the contract requires an application to serve.

Documentation
The doctrine no longer describes how the data layer works underneath: five
leaves whose subject was internals are gone, a dozen more are re-founded on the
contract's own words, and two frozen arbitrations about a deleted screen were
removed rather than left to mislead a future session.

Test harness
It can sign in at last: cucumber runs under node, which does not load `.env`,
so the harness never received the wallet material and every scenario silently
fell back to an empty local mode. A failed sign-in is now loud on both sides.
The suite also releases what it opens and exits on its own -- runs were still
resident hours after reporting, holding a browser and two servers.

Known red: `@data` cannot be measured. The served wallet accumulates and
nothing resets it; moving the browser profile aside does not, since the data
lives in the wallet file, not the profile.
2026-08-16 12:33:14 +02:00

104 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AuthGate — the ONE await the application makes before it renders.
*
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
* resolves who we are and does the connection work (restoring what others shared
* with us, draining our inboxes). Whatever a user has to see or do while that
* resolves — opening the wallet, loading it onto a first-time device — belongs to
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
* takes it down itself, and it owns the return from the broker round-trip (the
* barrier comes back prefilled, and confirming it hands the page over a second
* time; our page is never reloaded and nothing outside the barrier is touched).
* Festipod renders no access screen of its own and re-drives nothing.
*
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
* then await `ensureIdentity()`. A session arrives only through `init`, and
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
* of the statement sequence below, not of React's effect ordering — which would
* get it wrong: this gate's effect runs BEFORE its parent provider's.
*
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
* question — everything else it knows about the user it has to read first. The
* value is published for display (`shared/utils/currentPrincipal`) and goes
* nowhere near a data call: no call takes an identity, because the session
* already belongs to one user.
*
* Nothing of the app renders before that await settles: a screen mounted earlier
* would read as an identity that is not yet settled.
*
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
* app degrades through: an app that could not sign in but still shows its screens
* is indistinguishable from an app whose user simply owns nothing — a total
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
* children stay unmounted, which is also what keeps the data layer from settling
* on its empty stand-in provider for the rest of the session.
*/
import { useEffect, useState, type ReactNode } from 'react';
import { ensureIdentity } from '@ng-eventually/polyfill';
import { startNgSession } from '../shared/utils/ngSession';
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
import { useRouter, useNavigate } from './router';
export function AuthGate({ children }: { children: ReactNode }) {
const { route } = useRouter();
const navigate = useNavigate();
// Whether the ONE identity await has resolved.
const [identityReady, setIdentityReady] = useState(false);
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
const [signInError, setSignInError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
// provider above may have started it already; what matters is that it has been
// called before the await below, or the await throws.
void startNgSession();
void ensureIdentity()
.then(principal => {
// Publish who we are BEFORE anything renders — the identity is a fact of
// the session, not state of this component, so it is recorded even if the
// effect was torn down in between.
setCurrentPrincipal(principal);
if (!cancelled) setIdentityReady(true);
})
.catch(err => {
console.error('[Auth] ensureIdentity failed:', err);
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, []);
// Once identified, leave the disconnected welcome screen for the app home.
useEffect(() => {
if (identityReady && route.page === 'welcome') {
navigate('/home');
}
}, [identityReady, route.page, navigate]);
// Signing in FAILED — say so. The app has nothing legitimate to show, and
// showing it anyway would pass a broken session off as an empty one.
if (signInError) {
return (
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
<h1 className="app-title">Connexion impossible</h1>
<p className="app-text">
Festipod na pas réussi à vous connecter. Rien ne peut safficher tant que
la connexion na pas abouti les écrans seraient vides sans le dire.
</p>
<p className="app-text" data-testid="auth-error-detail">{signInError}</p>
</div>
);
}
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
// barrier is what is on screen, it put it there and it takes it down. Anything
// of ours here would be a second thing competing with it.
if (!identityReady) {
return null;
}
// The app.
return <>{children}</>;
}