53c0e095cf
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.
104 lines
4.9 KiB
TypeScript
104 lines
4.9 KiB
TypeScript
/**
|
||
* 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 n’a pas réussi à vous connecter. Rien ne peut s’afficher tant que
|
||
la connexion n’a 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}</>;
|
||
}
|