From 040afcbd7d30dd78aa5a9a965287f166b9980e5d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 15:13:35 +0200 Subject: [PATCH 1/2] fix: make app boot recover instead of hanging on the loader After sign-in the app could sit on the full-page loader until a reload. Every wait on the boot path had a failure that nothing reported: - Live queries only emitted on ready or change. A sync that failed, or never loaded, left the waiting screen waiting forever. liveQueryStream now takes `onStalled`: it fires when the query has not loaded after 10s, or its source failed for 3s, and rows that arrive later still emit. The org gate and `/` use it to keep the loader and offer a reload. - The org gate's `loadedOrgSlug` becomes `orgLoad` (Found / NotFound / Stalled). An unknown slug or a non-member shows "Workspace not found" instead of an empty shell, and switching orgs drops the previous org's role. - A session check the API could not answer counted as signed out and sent signed-in users to sign-in. It now keeps auth loading and retries. A failed `user.me` retries too, and both offer a reload. - A refused token exchange now re-reads the session, so an ended session goes to sign-in instead of every shape looping on 401s. - `/` goes straight back to the page sign-in started from instead of first waiting for the membership shapes. Sync retries now live in one place. Electric already backs off on 5xx, 429 and network errors forever, so the fetch-level Effect retry is gone. `electricOnError` retries a 401 with a fresh token and stops on other 4xx. The token exchange retries a 5xx or network error 3 times, and a token the proxy refuses is dropped from the cache. Server: - A session issued "in the future" was rejected beyond 1s of clock difference between Postgres and the API, failing the first requests after sign-in. It now tolerates 30s, as does `exp` on the proxy. - The proxy's JWKS cooldown after a failed fetch drops from 30s to 2s, and it allows 1024 waiters instead of 64. - `/auth/token` answers 503 with Retry-After when the session store is unavailable, instead of a bare 500. Also restores public/sw.js, which retires the legacy app's service worker. It was deleted with apps/web. Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/services/hazel-auth.test.ts | 2 + apps/backend/src/services/hazel-auth.ts | 23 ++-- apps/web-foldkit/public/sw.js | 19 ++++ apps/web-foldkit/src/app/auth.ts | 9 +- apps/web-foldkit/src/app/command.ts | 99 ++++++++++++----- apps/web-foldkit/src/app/loading.test.ts | 75 ++++++++++++- apps/web-foldkit/src/app/message.ts | 10 +- apps/web-foldkit/src/app/model.ts | 17 ++- .../web-foldkit/src/app/routing.story.test.ts | 83 +++++++++++++- .../web-foldkit/src/app/session.scene.test.ts | 12 +- apps/web-foldkit/src/app/subscription.ts | 27 ++++- apps/web-foldkit/src/app/view.ts | 14 ++- apps/web-foldkit/src/core/db/collections.ts | 28 ++++- .../src/core/lib/access-token.test.ts | 64 ++++++++++- apps/web-foldkit/src/core/lib/access-token.ts | 32 +++++- .../src/core/lib/electric-fetch.test.ts | 17 +++ .../src/core/lib/electric-fetch.ts | 64 +++++------ apps/web-foldkit/src/data/live-query.test.ts | 103 ++++++++++++++++++ apps/web-foldkit/src/data/live-query.ts | 75 ++++++++++++- apps/web-foldkit/src/main.ts | 50 +++++++-- apps/web-foldkit/src/page/root/index.ts | 29 ++++- apps/web-foldkit/src/page/root/model.ts | 5 + apps/web-foldkit/src/page/root/story.test.ts | 23 +++- apps/web-foldkit/src/shell/layouts.ts | 36 +++++- .../auth/src/yielded/access-token.test.ts | 32 +++++- packages/auth/src/yielded/access-token.ts | 16 ++- packages/auth/src/yielded/server.ts | 8 +- 27 files changed, 839 insertions(+), 133 deletions(-) create mode 100644 apps/web-foldkit/public/sw.js create mode 100644 apps/web-foldkit/src/core/lib/electric-fetch.test.ts create mode 100644 apps/web-foldkit/src/data/live-query.test.ts diff --git a/apps/backend/src/services/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts index 537bdd99c..704d70c6f 100644 --- a/apps/backend/src/services/hazel-auth.test.ts +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -278,6 +278,8 @@ describe("Hazel sign-in in the backend", () => { const response = await send("/probe/rpc", { method: "POST" }) expect(response.status).toBe(401) expect(cookies.has("effect-auth-session")).toBe(false) + // No session is a definite answer (401), unlike a session store that did not answer (503). + expect((await post("/auth/token")).status).toBe(401) }) it("a Clerk user's first GitHub sign-in lands in their existing account by verified email", async () => { diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts index cd6c136d9..639b42d58 100644 --- a/apps/backend/src/services/hazel-auth.ts +++ b/apps/backend/src/services/hazel-auth.ts @@ -154,13 +154,22 @@ const accessTokenRoutes = (issuerOrigin: string, key: AccessToken.SigningKey) => (value) => value.startsWith("Bearer ") ? Option.some(value.slice(7)) : Option.none(), ) - const session = Option.isSome(bearer) - ? yield* auth - .verifySession(Redacted.make(bearer.value)) - .pipe(Effect.catchTag("SessionInvalid", () => Effect.succeed(null))) - : yield* auth.getSession() - if (session === null) return HttpServerResponse.empty({ status: 401 }) - const token = yield* issuer.mint(session) + const session = yield* ( + Option.isSome(bearer) + ? auth + .verifySession(Redacted.make(bearer.value)) + .pipe(Effect.catchTag("SessionInvalid", () => Effect.succeed(null))) + : auth.getSession() + ).pipe( + // The session store did not answer: that says nothing about the session, so + // the client must retry rather than treat it as signed out. + Effect.map(Option.some), + Effect.catchTag("SessionUnavailable", () => Effect.succeed(Option.none())), + ) + if (Option.isNone(session)) + return HttpServerResponse.empty({ status: 503, headers: { "retry-after": "1" } }) + if (session.value === null) return HttpServerResponse.empty({ status: 401 }) + const token = yield* issuer.mint(session.value) return yield* HttpServerResponse.json({ token: Redacted.value(token), expiresIn: AccessToken.lifetimeSeconds, diff --git a/apps/web-foldkit/public/sw.js b/apps/web-foldkit/public/sw.js new file mode 100644 index 000000000..1b3cd572d --- /dev/null +++ b/apps/web-foldkit/public/sw.js @@ -0,0 +1,19 @@ +// Retire the React/Clerk app's Workbox worker during the Foldkit cutover. +// Keep this URL available so existing registrations can update instead of +// retaining their precached HTML when /sw.js falls back to the SPA shell. +self.addEventListener("install", () => self.skipWaiting()) + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys() + await Promise.all( + keys.filter((key) => key.startsWith("workbox-")).map((key) => caches.delete(key)), + ) + await self.clients.claim() + await self.registration.unregister() + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }) + await Promise.all(windows.map((client) => client.navigate(client.url))) + })(), + ) +}) diff --git a/apps/web-foldkit/src/app/auth.ts b/apps/web-foldkit/src/app/auth.ts index 29c5da5c6..67d69e4cc 100644 --- a/apps/web-foldkit/src/app/auth.ts +++ b/apps/web-foldkit/src/app/auth.ts @@ -10,7 +10,7 @@ import type { Auth } from "../session" * (`getSession`); the desktop app holds its own session in the OS keychain. */ -/** Retried while the API is unreachable; auth stays `Loading` (the app loader) meanwhile. */ +/** Retried a few times while the API is unreachable; auth stays `Loading` (the app loader) meanwhile. */ const retryUnavailable = Schedule.min([ Schedule.exponential("500 millis").pipe(Schedule.jittered), Schedule.spaced("30 seconds"), @@ -31,10 +31,13 @@ const read: Effect.Effect = isDesktop ? readDesktopSession : readBrowserSession -export const readSession: Effect.Effect = read.pipe( +/** + * Fails only when the API could not answer at all. An unreachable API says nothing about the + * session, so it must not count as signed out: that would send a signed-in user to sign-in. + */ +export const readSession: Effect.Effect = read.pipe( Effect.tapError((error) => Effect.logWarning("[auth] session check failed", error)), Effect.retry({ schedule: retryUnavailable, times: 3 }), - Effect.orElseSucceed(() => "SignedOut" as const), ) /** The API rejected sign-out or could not be reached; the user stays signed in. */ diff --git a/apps/web-foldkit/src/app/command.ts b/apps/web-foldkit/src/app/command.ts index e7616ca50..a9e8cb42a 100644 --- a/apps/web-foldkit/src/app/command.ts +++ b/apps/web-foldkit/src/app/command.ts @@ -1,10 +1,11 @@ -import { Effect, Schema } from "effect" +import { Duration, Effect, Schedule, Schema } from "effect" import { Command } from "foldkit" import { load, pushUrl, replaceUrl } from "foldkit/navigation" import { HazelRpc } from "../rpc" import { applyTheme, ResolvedTheme, saveThemePreference, ThemeCustomization, ThemePreference } from "../theme" import { saveSoundSettings, SoundSettings } from "../notification-sound" import { deliverNotifications } from "./notification-sinks" +import { UnauthorizedError } from "@hazel/domain/errors" import { NotificationId } from "@hazel/schema" import { readSession, signOut } from "./auth" import { Message } from "./message" @@ -55,41 +56,83 @@ export const DeliverNotifications = Command.define("DeliverNotifications", { execute: ({ ids }) => deliverNotifications(ids).pipe(Effect.as(Message.CompletedDeliverNotifications())), }) -/** Whether the API has a session for this browser; retried until the API answers. */ +/** How long the app waits before asking again after the API did not answer. */ +const RETRY_DELAY = Duration.seconds(10) + +const checkSession = readSession.pipe( + Effect.map((auth) => Message.ChangedAuth({ auth })), + Effect.orElseSucceed(() => Message.FailedCheckSession()), +) + +/** Whether the API has a session for this browser. */ export const CheckSession = Command.define("CheckSession", { - messages: [Message.ChangedAuth], - execute: readSession.pipe(Effect.map((auth) => Message.ChangedAuth({ auth }))), + messages: [Message.ChangedAuth, Message.FailedCheckSession], + execute: checkSession, +}) + +/** The session check again after a pause, while the API has not answered. */ +export const RetryCheckSession = Command.define("RetryCheckSession", { + messages: [Message.ChangedAuth, Message.FailedCheckSession], + execute: Effect.sleep(RETRY_DELAY).pipe(Effect.andThen(checkSession)), +}) + +/** A full page load: every query, sync and token starts over. */ +export const Reload = Command.define("Reload", { + messages: [Message.CompletedReload], + execute: Effect.sync(() => window.location.reload()).pipe(Effect.as(Message.CompletedReload())), }) export const SignOut = Command.define("SignOut", { messages: [Message.CompletedSignOut, Message.FailedSignOut], - execute: - signOut.pipe( - Effect.as(Message.CompletedSignOut()), - Effect.catchTag("SignOutError", (error) => Effect.succeed(Message.FailedSignOut({ reason: error.message }))), + execute: signOut.pipe( + Effect.as(Message.CompletedSignOut()), + Effect.catchTag("SignOutError", (error) => + Effect.succeed(Message.FailedSignOut({ reason: error.message })), ), + ), }) +/** A failed `user.me` other than Unauthorized is retried this often before reporting failure. */ +const retryCurrentUser = Schedule.exponential("500 millis").pipe(Schedule.jittered) + +const fetchCurrentUser = Effect.gen(function* () { + const client = yield* HazelRpc + const user = yield* client("user.me", undefined).pipe( + Effect.retry({ + schedule: retryCurrentUser, + times: 3, + while: (error) => !UnauthorizedError.is(error), + }), + ) + return Message.SucceededFetchCurrentUser({ + user: { + id: user.id, + firstName: user.firstName ?? "", + lastName: user.lastName ?? "", + email: user.email, + avatarUrl: user.avatarUrl ?? null, + isOnboarded: user.isOnboarded, + organizationId: user.organizationId ?? null, + }, + }) +}).pipe( + Effect.catch((error) => + Effect.succeed( + Message.FailedFetchCurrentUser({ + reason: String(error), + isUnauthorized: UnauthorizedError.is(error), + }), + ), + ), +) + export const FetchCurrentUser = Command.define("FetchCurrentUser", { messages: [Message.SucceededFetchCurrentUser, Message.FailedFetchCurrentUser], - execute: - Effect.gen(function* () { - const client = yield* HazelRpc - const user = yield* client("user.me", undefined) - return Message.SucceededFetchCurrentUser({ - user: { - id: user.id, - firstName: user.firstName ?? "", - lastName: user.lastName ?? "", - email: user.email, - avatarUrl: user.avatarUrl ?? null, - isOnboarded: user.isOnboarded, - organizationId: user.organizationId ?? null, - }, - }) - }).pipe( - Effect.catch((error) => - Effect.succeed(Message.FailedFetchCurrentUser({ reason: String(error) })), - ), - ), + execute: fetchCurrentUser, +}) + +/** `user.me` again after a pause, while the shell has a session but no user to show. */ +export const RetryFetchCurrentUser = Command.define("RetryFetchCurrentUser", { + messages: [Message.SucceededFetchCurrentUser, Message.FailedFetchCurrentUser], + execute: Effect.sleep(RETRY_DELAY).pipe(Effect.andThen(fetchCurrentUser)), }) diff --git a/apps/web-foldkit/src/app/loading.test.ts b/apps/web-foldkit/src/app/loading.test.ts index dc8e71401..66598f6d3 100644 --- a/apps/web-foldkit/src/app/loading.test.ts +++ b/apps/web-foldkit/src/app/loading.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { OrganizationId, UserId } from "@hazel/schema" +import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Option, Schema } from "effect" -import { Mount, expect as sceneExpect, given, role, scene } from "foldkit/scene" +import { Mount, expect as sceneExpect, given, role, scene, text } from "foldkit/scene" import { fromString } from "foldkit/url" import { describe, expect, test } from "vitest" import { init, update } from "../main" @@ -51,6 +51,7 @@ const after = (model: Model, messages: ReadonlyArray) => const config = { update, view: (model: Model, h: Parameters[1]) => view(model, h).body } const loader = role("progressbar", { name: "Loading" }) +const reload = role("button", { name: "Reload" }) /** Whether the root renders the loader (``). */ const expectLoader = (model: Model, isShown: boolean) => @@ -77,12 +78,78 @@ describe("root loading states", () => { expectLoader(withOrg, false) }) - test("an unknown slug stops loading once its query answers", () => { + test("an unknown slug says so once its query answers, with a way back", () => { const model = after(boot("/nope/settings/team"), [ Message.ChangedAuth({ auth: "SignedIn" }), Message.SucceededFetchCurrentUser({ user: ada }), Message.UpdatedOrganization({ orgSlug: "nope", organization: null }), ]) - expectLoader(model, false) + scene( + config, + given(model), + sceneExpect(loader).toBeAbsent(), + sceneExpect(text("Workspace not found")).toExist(), + sceneExpect(role("link", { name: "Go to your workspace" })).toHaveAttr("href", "/"), + ) + }) + + test("a stalled organization keeps the loader and offers a reload, until the org arrives", () => { + const withUser = after(boot("/hazel/settings/team"), [ + Message.ChangedAuth({ auth: "SignedIn" }), + Message.SucceededFetchCurrentUser({ user: ada }), + ]) + scene(config, given(withUser), sceneExpect(reload).toBeAbsent()) + const stalled = after(withUser, [Message.StalledOrganization({ orgSlug: "hazel" })]) + scene(config, given(stalled), sceneExpect(loader).toExist(), sceneExpect(reload).toExist()) + expectLoader( + after(stalled, [Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel })]), + false, + ) + }) + + test("a stall after the organization loaded leaves the shell up", () => { + const loaded = after(boot("/hazel/settings/team"), [ + Message.ChangedAuth({ auth: "SignedIn" }), + Message.SucceededFetchCurrentUser({ user: ada }), + Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel }), + Message.StalledOrganization({ orgSlug: "hazel" }), + ]) + expectLoader(loaded, false) + }) + + test("an unanswered session check keeps the loader and offers a reload", () => { + const stalled = after(boot("/hazel/settings/team"), [Message.FailedCheckSession()]) + expect(stalled.auth).toBe("Loading") + scene(config, given(stalled), sceneExpect(loader).toExist(), sceneExpect(reload).toExist()) + }) + + test("a failing user.me keeps the loader and offers a reload", () => { + const stalled = after(boot("/hazel/settings/team"), [ + Message.ChangedAuth({ auth: "SignedIn" }), + Message.FailedFetchCurrentUser({ reason: "offline", isUnauthorized: false }), + ]) + scene(config, given(stalled), sceneExpect(loader).toExist(), sceneExpect(reload).toExist()) + }) + + test("switching organizations drops the previous organization's role", () => { + const acme = { + ...hazel, + id: Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-000000000003"), + slug: "acme", + } + const member = { + id: Schema.decodeSync(OrganizationMemberId)("00000000-0000-4000-8000-000000000004"), + role: "owner" as const, + } + const inHazel = after(boot("/hazel/settings/team"), [ + Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel }), + Message.UpdatedMember({ member }), + ]) + expect( + after(inHazel, [Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel })]).member, + ).toEqual(member) + expect( + after(inHazel, [Message.UpdatedOrganization({ orgSlug: "acme", organization: acme })]).member, + ).toBeNull() }) }) diff --git a/apps/web-foldkit/src/app/message.ts b/apps/web-foldkit/src/app/message.ts index 67ccc3667..66522b7a4 100644 --- a/apps/web-foldkit/src/app/message.ts +++ b/apps/web-foldkit/src/app/message.ts @@ -29,12 +29,20 @@ export const Message = defineMessageUnion({ UpdatedRecentNotifications: { ids: Schema.Array(NotificationId) }, CompletedDeliverNotifications: {}, ChangedAuth: { auth: Auth }, + /** The API did not answer the session check; auth stays `Loading` and the check runs again. */ + FailedCheckSession: {}, + /** The API refused to exchange the session for an access token. */ + RejectedSession: {}, SucceededFetchCurrentUser: { user: CurrentUser }, - FailedFetchCurrentUser: { reason: Schema.String }, + FailedFetchCurrentUser: { reason: Schema.String, isUnauthorized: Schema.Boolean }, CompletedSignOut: {}, FailedSignOut: { reason: Schema.String }, /** The organization query for `orgSlug` answered (found or not). */ UpdatedOrganization: { orgSlug: Schema.String, organization: Schema.NullOr(Organization) }, + /** The organization query for `orgSlug` has not loaded after a while, or its source failed. */ + StalledOrganization: { orgSlug: Schema.String }, + PressedReload: {}, + CompletedReload: {}, UpdatedMember: { member: Schema.NullOr(Member) }, TickedPresenceClock: { nowMs: Schema.Number }, ClickedLayoutTab: { href: Schema.String }, diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index b685c7d95..215174e8e 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -14,6 +14,16 @@ import { ResolvedTheme, resolveTheme, ThemePreference } from "../theme" import type { Context as ShellUpdateContext } from "../shell/update" import { can } from "../page/contract" +/** + * The organization query for `slug`: `Found` (the shell renders), `NotFound` (no such org, or the + * user is not a member) or `Stalled` (not loaded after a while; the loader offers a reload). + */ +export const OrgLoad = Schema.Struct({ + slug: Schema.String, + status: Schema.Literals(["Found", "NotFound", "Stalled"]), +}) +export type OrgLoad = typeof OrgLoad.Type + /** The root Model: route, session, the active page, the shell and the overlay slots. */ export const Model = Schema.Struct({ route: AppRoute, @@ -23,8 +33,11 @@ export const Model = Schema.Struct({ auth: Auth, currentUser: Schema.NullOr(CurrentUser), organization: Schema.NullOr(Organization), - /** The slug whose organization query has answered; until it matches the route, a loader shows. */ - loadedOrgSlug: Schema.NullOr(Schema.String), + /** How far the route's organization has loaded; until `slug` matches the route, a loader shows. */ + orgLoad: Schema.NullOr(OrgLoad), + /** The session check or `user.me` keeps failing: the loader offers a reload while retries go on. */ + isSessionStalled: Schema.Boolean, + isCurrentUserStalled: Schema.Boolean, member: Schema.NullOr(Member), nowMs: Schema.Number, /** The stored theme preference (`themeAtom`, `themeCustomizationAtom`), and the system's theme. */ diff --git a/apps/web-foldkit/src/app/routing.story.test.ts b/apps/web-foldkit/src/app/routing.story.test.ts index ef1b94cce..38f598db0 100644 --- a/apps/web-foldkit/src/app/routing.story.test.ts +++ b/apps/web-foldkit/src/app/routing.story.test.ts @@ -3,8 +3,16 @@ import { Command, given, message, model, story } from "foldkit/story" import { UrlRequest } from "foldkit/navigation" import { describe, expect, test } from "vitest" import { update } from "../main" -import { after, boot, signedIn, urlOf } from "../test/root-fixtures" -import { FetchCurrentUser, LoadExternal, NavigateInternal, ReplaceUrl } from "./command" +import { ada, after, boot, signedIn, urlOf } from "../test/root-fixtures" +import { + CheckSession, + FetchCurrentUser, + LoadExternal, + NavigateInternal, + ReplaceUrl, + RetryCheckSession, + RetryFetchCurrentUser, +} from "./command" import { Message } from "./message" /** Root routing: links, URL changes, the `beforeLoad` redirects and the signed-out gate. */ @@ -62,7 +70,10 @@ describe("redirects", () => { given(boot("/join/hazel")), message(Message.ChangedAuth({ auth: "SignedOut" })), Command.expectExact(FetchCurrentUser()), - Command.resolve(FetchCurrentUser, Message.FailedFetchCurrentUser({ reason: "Unauthorized" })), + Command.resolve( + FetchCurrentUser, + Message.FailedFetchCurrentUser({ reason: "Unauthorized", isUnauthorized: true }), + ), model((m) => expect(m.currentUser).toBeNull()), ) }) @@ -73,12 +84,76 @@ describe("redirects", () => { given(boot("/hazel/settings/team")), message(Message.ChangedAuth({ auth: "SignedIn" })), Command.expectExact(FetchCurrentUser()), - Command.resolve(FetchCurrentUser, Message.FailedFetchCurrentUser({ reason: "offline" })), + Command.resolve(FetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), message(Message.ChangedAuth({ auth: "SignedIn" })), Command.expectNone(), ) }) + test("a signed-in user.me failure asks again until it succeeds", () => { + const offline = Message.FailedFetchCurrentUser({ reason: "offline", isUnauthorized: false }) + story( + update, + given(boot("/hazel/settings/team")), + message(Message.ChangedAuth({ auth: "SignedIn" })), + Command.resolve(FetchCurrentUser, offline), + Command.expectExact(RetryFetchCurrentUser()), + Command.resolve(RetryFetchCurrentUser, offline), + Command.expectExact(RetryFetchCurrentUser()), + Command.resolve(RetryFetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), + model((m) => expect(m.currentUser).toEqual(ada)), + Command.expectNone(), + ) + }) + + test("an unanswered session check keeps auth loading and asks again", () => { + story( + update, + given(boot("/hazel/settings/team")), + message(Message.FailedCheckSession()), + Command.expectExact(RetryCheckSession()), + model((m) => expect(m.auth).toBe("Loading")), + Command.resolve(RetryCheckSession, Message.ChangedAuth({ auth: "SignedIn" })), + model((m) => expect(m.isSessionStalled).toBe(false)), + Command.resolve(FetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), + ) + }) + + test("a refused access token re-reads the session, and an ended one goes to sign-in", () => { + story( + update, + given(signedIn("/hazel/settings/team")), + message(Message.RejectedSession()), + Command.expectExact(CheckSession()), + Command.resolve(CheckSession, Message.ChangedAuth({ auth: "SignedOut" })), + Command.expectHas(ReplaceUrl({ url: "/sign-in?redirect_url=%2Fhazel%2Fsettings%2Fteam" })), + Command.resolve(ReplaceUrl, Message.CompletedReplaceUrl()), + ) + }) + + test("an unauthorized user.me while signed in re-reads the session", () => { + story( + update, + given(boot("/hazel/settings/team")), + message(Message.ChangedAuth({ auth: "SignedIn" })), + Command.resolve( + FetchCurrentUser, + Message.FailedFetchCurrentUser({ reason: "Unauthorized", isUnauthorized: true }), + ), + Command.expectExact(CheckSession(), RetryFetchCurrentUser()), + Command.resolve(CheckSession, Message.ChangedAuth({ auth: "SignedOut" })), + Command.expectHas(ReplaceUrl({ url: "/sign-in?redirect_url=%2Fhazel%2Fsettings%2Fteam" })), + Command.resolveAll( + [ReplaceUrl, Message.CompletedReplaceUrl()], + [ + RetryFetchCurrentUser, + Message.FailedFetchCurrentUser({ reason: "Unauthorized", isUnauthorized: true }), + ], + ), + Command.expectNone(), + ) + }) + // A5: only the ChannelSettingsRedirect page forwards; `routeRedirect` no longer doubles it. test("the channel settings index forwards to its overview tab once", () => { const channelId = "00000000-0000-4000-8000-0000000000c1" diff --git a/apps/web-foldkit/src/app/session.scene.test.ts b/apps/web-foldkit/src/app/session.scene.test.ts index 3649c3dd5..7d2e2f12f 100644 --- a/apps/web-foldkit/src/app/session.scene.test.ts +++ b/apps/web-foldkit/src/app/session.scene.test.ts @@ -4,7 +4,7 @@ import { describe, test } from "vitest" import { boot, rootScene, sessionMessages } from "../test/root-fixtures" import * as Menu from "../ui/menu" import { FocusTriggerOnPress } from "../ui/menu-view" -import { FetchCurrentUser } from "./command" +import { FetchCurrentUser, RetryFetchCurrentUser } from "./command" import { Message } from "./message" /** What the root renders for each session state, and how a session arriving changes it. */ @@ -31,12 +31,18 @@ describe("session states", () => { ) }) - test("a failed user.me keeps the loader (no error screen)", () => { + test("a failed user.me keeps the loader (no error screen) and asks again", () => { + const [, user] = sessionMessages() scene( rootScene, given(boot("/hazel/settings/team")), Subscription.emit(Message.ChangedAuth({ auth: "SignedIn" })), - Command.resolve(FetchCurrentUser, Message.FailedFetchCurrentUser({ reason: "offline" })), + Command.resolve( + FetchCurrentUser, + Message.FailedFetchCurrentUser({ reason: "offline", isUnauthorized: false }), + ), + sceneExpect(loader).toExist(), + Command.resolve(RetryFetchCurrentUser, user), sceneExpect(loader).toExist(), ) }) diff --git a/apps/web-foldkit/src/app/subscription.ts b/apps/web-foldkit/src/app/subscription.ts index 0cce95f62..4eafe8a7d 100644 --- a/apps/web-foldkit/src/app/subscription.ts +++ b/apps/web-foldkit/src/app/subscription.ts @@ -1,12 +1,13 @@ import { ChannelId, type NotificationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { eq } from "@tanstack/db" -import { Option, Schema, Stream } from "effect" +import { Duration, Option, Schema, Stream } from "effect" import { Dom, ManagedResource, Subscription } from "foldkit" import { notificationCollection, organizationCollection, organizationMemberCollection, } from "~/db/collections" +import { SESSION_REJECTED_EVENT } from "~/lib/access-token" import { liveQueryStream } from "../data/live-query" import { pageSubscriptions } from "../page/registry" import { SoundSettings } from "../notification-sound" @@ -37,6 +38,25 @@ const rootSubscriptions = Subscription.make()((entry) => ({ Stream.map(() => Message.TickedPresenceClock({ nowMs: Date.now() })), ), ), + // Every shape retries a refused token at once; one session check covers them all. + sessionRejected: entry( + { isSignedIn: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isSignedIn: model.auth === "SignedIn" }), + dependenciesToStream: ({ isSignedIn }) => + isSignedIn + ? Stream.fromEventListener(window, SESSION_REJECTED_EVENT).pipe( + Stream.throttle({ + cost: () => 1, + units: 1, + duration: Duration.seconds(30), + strategy: "enforce", + }), + Stream.map(() => Message.RejectedSession()), + ) + : Stream.empty, + }, + ), // `useOrganization()`: the org named by the route's slug. organization: entry( { orgSlug: Schema.NullOr(Schema.String) }, @@ -66,6 +86,7 @@ const rootSubscriptions = Subscription.make()((entry) => ({ : null, }) }, + { onStalled: () => Message.StalledOrganization({ orgSlug }) }, ), }, ), @@ -180,7 +201,9 @@ const shellSubscriptions = Subscription.lift(ShellSubscription.subscriptions) { const orgSlug = orgSlugOf(model.route) - return orgSlug !== undefined && model.loadedOrgSlug === orgSlug ? (model.currentUser?.id ?? null) : null + const isLoaded = + orgSlug !== undefined && model.orgLoad?.slug === orgSlug && model.orgLoad.status === "Found" + return isLoaded ? (model.currentUser?.id ?? null) : null } const platformSubscriptions = Subscription.lift(Platform.subscriptions)({ diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index 7ca9bb815..f217371dc 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -11,6 +11,7 @@ import { formatStatusExpirationAt } from "../shell/menus" import type { ShellContext } from "../shell/context" import { appLoader, + organizationNotFound, type ChannelSettingsTab, channelSettingsLayout, contentColumnLayout, @@ -146,6 +147,10 @@ const secondarySidebar = (model: Model, h: HtmlBuilder, context: ShellC ) } +/** The app loader; once loading has stalled it also offers a reload. */ +const loader = (h: HtmlBuilder, isStalled: boolean): Html => + appLoader(h, isStalled ? Message.PressedReload() : undefined) + const appRoot = (h: HtmlBuilder, model: Model, children: ReadonlyArray): Html => h.div([h.Id("app")], [toaster(h, model), ...children]) @@ -154,13 +159,16 @@ const body = (model: Model, h: HtmlBuilder): Html => { if (route._tag === "NotFound") return h.div([h.Id("app")], [`Not found: ${route.path}`]) if (isPublicRoute(route)) return appRoot(h, model, [pageBody(model, h, false)]) // `_app/layout.tsx`: a loader until the API answers for the session; signed out redirects to sign-in. - if (model.auth !== "SignedIn") return appRoot(h, model, [appLoader(h)]) + if (model.auth !== "SignedIn") return appRoot(h, model, [loader(h, model.isSessionStalled)]) // `AppShell`: a loader until `user.me` answers. - if (model.currentUser === null) return appRoot(h, model, [appLoader(h)]) + if (model.currentUser === null) return appRoot(h, model, [loader(h, model.isCurrentUserStalled)]) const orgSlug = orgSlugOf(route) if (orgSlug === undefined) return appRoot(h, model, [pageBody(model, h, false)]) // `$orgSlug/layout.tsx`: a loader while `useOrganization()` loads the route's organization. - if (model.loadedOrgSlug !== orgSlug) return appRoot(h, model, [appLoader(h)]) + const orgLoad = model.orgLoad?.slug === orgSlug ? model.orgLoad.status : "Loading" + if (orgLoad === "Loading" || orgLoad === "Stalled") + return appRoot(h, model, [loader(h, orgLoad === "Stalled")]) + if (orgLoad === "NotFound") return appRoot(h, model, [organizationNotFound(h, orgSlug)]) const context = shellContextOf(model, orgSlug) return orgShell(h, context, { page: sectionBody(model, h), diff --git a/apps/web-foldkit/src/core/db/collections.ts b/apps/web-foldkit/src/core/db/collections.ts index ded735c6a..8233cc329 100644 --- a/apps/web-foldkit/src/core/db/collections.ts +++ b/apps/web-foldkit/src/core/db/collections.ts @@ -24,7 +24,7 @@ import { User, UserPresenceStatus, } from "@hazel/domain/models" -import { electricFetchClient } from "~/lib/electric-fetch" +import { electricFetchClient, electricOnError } from "~/lib/electric-fetch" import { runtime } from "~/lib/services/common/runtime" import { createEffectCollection } from "@hazel/effect-electric-db-collection" @@ -33,7 +33,7 @@ const electricUrl = import.meta.env.VITE_ELECTRIC_URL export const organizationCollection = createEffectCollection({ id: "organizations", runtime: runtime, - backoff: false, // Retry handled at fetch level in electric-fetch.ts + backoff: false, // Retries: the Electric client (5xx, 429, network) and `electricOnError` (401) shapeOptions: { url: electricUrl, //liveSse: true, @@ -46,6 +46,7 @@ export const organizationCollection = createEffectCollection({ }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: Organization.Schema, getKey: (item) => item.id, @@ -66,6 +67,7 @@ export const messageCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: Message.Schema, getKey: (item) => item.id, @@ -85,6 +87,7 @@ export const messageReactionCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: MessageReaction.Schema, getKey: (item) => item.id, @@ -104,6 +107,7 @@ export const pinnedMessageCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: PinnedMessage.Schema, getKey: (item) => item.id, @@ -124,6 +128,7 @@ export const notificationCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: Notification.Schema, getKey: (item) => item.id, @@ -144,6 +149,7 @@ export const userCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: User.Schema, getKey: (item) => item.id, @@ -163,6 +169,7 @@ export const organizationMemberCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: OrganizationMember.Schema, getKey: (item) => item.id, @@ -182,6 +189,7 @@ export const channelCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: Channel.Schema, getKey: (item) => item.id, @@ -200,6 +208,7 @@ export const connectConversationCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ConnectConversation.Schema, getKey: (item) => item.id, @@ -218,6 +227,7 @@ export const connectConversationChannelCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ConnectConversationChannel.Schema, getKey: (item) => item.id, @@ -236,6 +246,7 @@ export const connectParticipantCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ConnectParticipant.Schema, getKey: (item) => item.id, @@ -256,6 +267,7 @@ export const channelMemberCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ChannelMember.Schema, getKey: (item) => item.id, @@ -274,6 +286,7 @@ export const channelSectionCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ChannelSection.Schema, getKey: (item) => item.id, @@ -293,6 +306,7 @@ export const attachmentCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: Attachment.Schema, getKey: (item) => item.id, @@ -310,6 +324,7 @@ export const typingIndicatorCollection = createEffectCollection({ table: "typing_indicators", }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: TypingIndicator.Schema, getKey: (item) => item.id, @@ -329,6 +344,7 @@ export const userPresenceStatusCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: UserPresenceStatus.Schema, getKey: (item) => item.id, @@ -349,6 +365,7 @@ export const integrationConnectionCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: IntegrationConnection.Schema, getKey: (item) => item.id, @@ -367,6 +384,7 @@ export const chatSyncConnectionCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ChatSyncConnection.Schema, getKey: (item) => item.id, @@ -385,6 +403,7 @@ export const chatSyncChannelLinkCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ChatSyncChannelLink.Schema, getKey: (item) => item.id, @@ -403,6 +422,7 @@ export const chatSyncMessageLinkCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: ChatSyncMessageLink.Schema, getKey: (item) => item.id, @@ -421,6 +441,7 @@ export const botCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: Bot.Schema, getKey: (item) => item.id, @@ -443,6 +464,7 @@ export const botCommandCollection = createEffectCollection({ timestamptz: ((date: string) => new Date(date)) as never, }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: BotCommand.Schema, getKey: (item) => item.id, @@ -461,6 +483,7 @@ export const botInstallationCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: BotInstallation.Schema, getKey: (item) => item.id, @@ -479,6 +502,7 @@ export const customEmojiCollection = createEffectCollection({ timestamptz: (date) => new Date(date), }, fetchClient: electricFetchClient, + onError: electricOnError, }, schema: CustomEmoji.Schema, getKey: (item) => item.id, diff --git a/apps/web-foldkit/src/core/lib/access-token.test.ts b/apps/web-foldkit/src/core/lib/access-token.test.ts index bb7ae94ce..e1d250b1a 100644 --- a/apps/web-foldkit/src/core/lib/access-token.test.ts +++ b/apps/web-foldkit/src/core/lib/access-token.test.ts @@ -1,11 +1,23 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest" -import { clearAccessToken, getAccessToken } from "./access-token" +import { + clearAccessToken, + getAccessToken, + invalidateAccessToken, + SESSION_REJECTED_EVENT, +} from "./access-token" afterEach(() => { clearAccessToken() vi.unstubAllGlobals() + vi.useRealTimers() }) + +const rejections = () => { + const seen = vi.fn() + window.addEventListener(SESSION_REJECTED_EVENT, seen) + return { seen, stop: () => window.removeEventListener(SESSION_REJECTED_EVENT, seen) } +} describe("session token exchange", () => { it("sends the browser CSRF header", async () => { const fetch = vi.fn(async () => @@ -38,4 +50,54 @@ describe("session token exchange", () => { expect(await old).toBeNull() expect(await getAccessToken()).toBe("new") }) + + it("retries an API that did not answer, then hands out the token", async () => { + vi.useFakeTimers() + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue(Response.json({ token: "late", expiresIn: 300 })) + vi.stubGlobal("fetch", fetch) + const token = getAccessToken() + await vi.runAllTimersAsync() + expect(await token).toBe("late") + expect(fetch).toHaveBeenCalledTimes(3) + }) + + it("gives up after three unanswered attempts without reporting the session as refused", async () => { + vi.useFakeTimers() + const { seen, stop } = rejections() + const fetch = vi.fn(async () => new Response(null, { status: 503 })) + vi.stubGlobal("fetch", fetch) + const token = getAccessToken() + await vi.runAllTimersAsync() + expect(await token).toBeNull() + expect(fetch).toHaveBeenCalledTimes(3) + expect(seen).not.toHaveBeenCalled() + stop() + }) + + it("reports a refused session once, without retrying", async () => { + const { seen, stop } = rejections() + const fetch = vi.fn(async () => new Response(null, { status: 401 })) + vi.stubGlobal("fetch", fetch) + expect(await getAccessToken()).toBeNull() + expect(fetch).toHaveBeenCalledOnce() + expect(seen).toHaveBeenCalledOnce() + stop() + }) + + it("a rejected token is exchanged again; a token already replaced is left alone", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ token: "first", expiresIn: 300 })) + .mockResolvedValue(Response.json({ token: "second", expiresIn: 300 })) + vi.stubGlobal("fetch", fetch) + expect(await getAccessToken()).toBe("first") + invalidateAccessToken("stale") + expect(await getAccessToken()).toBe("first") + invalidateAccessToken("first") + expect(await getAccessToken()).toBe("second") + }) }) diff --git a/apps/web-foldkit/src/core/lib/access-token.ts b/apps/web-foldkit/src/core/lib/access-token.ts index 148ce55a7..2e2d1c46f 100644 --- a/apps/web-foldkit/src/core/lib/access-token.ts +++ b/apps/web-foldkit/src/core/lib/access-token.ts @@ -12,6 +12,12 @@ interface CachedToken { readonly expiresAtMs: number } +/** + * Dispatched on `window` when the API refuses to exchange the session for a token: the session + * has likely ended. The app re-reads the session and, if it has, sends the user to sign-in. + */ +export const SESSION_REJECTED_EVENT = "hazel:session-rejected" + /** Renew this long before expiry, so a token is never sent seconds from expiring. */ const RENEW_BEFORE_MS = 60_000 @@ -29,7 +35,13 @@ const isTokenResponse = (value: unknown): value is { token: string; expiresIn: n "expiresIn" in value && typeof value.expiresIn === "number" -const exchange = async (startedGeneration: number): Promise => { +/** Attempts per exchange: a network error or 5xx right after sign-in is usually momentary. */ +const EXCHANGE_ATTEMPTS = 3 + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** `null` means the API answered and there is no session; `"retry"` means it could not answer. */ +const exchangeOnce = async (startedGeneration: number): Promise => { try { const authorization = await sessionAuthorization() const response = await fetch(`${backendUrl()}/auth/token`, { @@ -40,8 +52,10 @@ const exchange = async (startedGeneration: number): Promise => { : { credentials: "omit" as const, headers: { authorization } }), }) if (startedGeneration !== generation) return null + if (response.status >= 500) return "retry" if (!response.ok) { cached = null + if (response.status === 401) window.dispatchEvent(new Event(SESSION_REJECTED_EVENT)) return null } const body: unknown = await response.json() @@ -49,7 +63,16 @@ const exchange = async (startedGeneration: number): Promise => { cached = { token: body.token, expiresAtMs: Date.now() + body.expiresIn * 1000 } return body.token } catch { - return null + return "retry" + } +} + +const exchange = async (startedGeneration: number): Promise => { + for (let attempt = 1; ; attempt++) { + const result = await exchangeOnce(startedGeneration) + if (result !== "retry") return result + if (attempt >= EXCHANGE_ATTEMPTS || startedGeneration !== generation) return null + await sleep(250 * 2 ** attempt) } } @@ -66,6 +89,11 @@ export const getAccessToken = async (): Promise => { return pending } +/** Forgets the cached token so the next request exchanges a new one, after a host rejected it. */ +export const invalidateAccessToken = (rejected: string) => { + if (cached?.token === rejected) cached = null +} + /** Drops the cached token, for sign-out. */ export const clearAccessToken = () => { generation++ diff --git a/apps/web-foldkit/src/core/lib/electric-fetch.test.ts b/apps/web-foldkit/src/core/lib/electric-fetch.test.ts new file mode 100644 index 000000000..5d8998790 --- /dev/null +++ b/apps/web-foldkit/src/core/lib/electric-fetch.test.ts @@ -0,0 +1,17 @@ +import { FetchError } from "@electric-sql/client" +import { describe, expect, test } from "vitest" +import { electricOnError } from "./electric-fetch" + +const fetchError = (status: number) => + new FetchError(status, undefined, undefined, {}, "http://electric/v1/shape") + +describe("electricOnError", () => { + test("retries a rejected token and errors that are not HTTP responses", () => { + expect(electricOnError(fetchError(401))).toEqual({}) + expect(electricOnError(new TypeError("Failed to fetch"))).toEqual({}) + }) + + test("stops on a request the proxy will never accept", () => { + for (const status of [400, 403, 404]) expect(electricOnError(fetchError(status))).toBeUndefined() + }) +}) diff --git a/apps/web-foldkit/src/core/lib/electric-fetch.ts b/apps/web-foldkit/src/core/lib/electric-fetch.ts index 2c25fd076..335eb20e0 100644 --- a/apps/web-foldkit/src/core/lib/electric-fetch.ts +++ b/apps/web-foldkit/src/core/lib/electric-fetch.ts @@ -1,49 +1,35 @@ /** - * Authenticated fetch for Electric SQL. Attaches the Hazel access token (the proxy is on - * another host, so it cannot see the API's session cookie) and retries 5xx with jittered - * backoff so we don't hammer the proxy on outages. - * Auth state is handled by the React tree — this just fetches. + * Authenticated fetch for Electric SQL. Attaches the Hazel access token: the proxy is on another + * host, so it cannot see the API's session cookie. + * + * Retries live in the Electric client, not here. Its fetch wrapper backs off on 5xx, 429 and + * network errors without limit; every other error reaches `electricOnError`. */ -import { Effect, Schedule } from "effect" -import { getAccessToken } from "./access-token" -import { runtime } from "./services/common/runtime" +import { FetchError } from "@electric-sql/client" +import { getAccessToken, invalidateAccessToken } from "./access-token" -const retrySchedule = Schedule.max([ - Schedule.min([Schedule.exponential("2 seconds").pipe(Schedule.jittered), Schedule.spaced("60 seconds")]), - Schedule.recurs(8), -]) - -const shouldRetry = (response: Response): boolean => response.status >= 500 && response.status < 600 - -const doFetch = async (input: RequestInfo | URL, init: RequestInit | undefined): Promise => { +export const electricFetchClient = async ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { const token = await getAccessToken() + // No token: the exchange failed or there is no session. `electricOnError` retries a 401. if (!token) return new Response(null, { status: 401 }) - return fetch(input, { + const response = await fetch(input, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${token}` }, }) + // The proxy rejected this token (expired, or its session has ended); the next attempt exchanges again. + if (response.status === 401) invalidateAccessToken(token) + return response } -export const electricFetchClient = async ( - input: RequestInfo | URL, - init?: RequestInit, -): Promise => { - const fetchEffect = Effect.gen(function* () { - const response = yield* Effect.tryPromise({ - try: () => doFetch(input, init), - catch: (error) => error as Error, - }) - if (shouldRetry(response)) return yield* Effect.fail(response) - return response - }) - - const withRetry = fetchEffect.pipe( - Effect.retry({ - schedule: retrySchedule, - while: (error) => error instanceof Response && shouldRetry(error), - }), - Effect.catch((error) => (error instanceof Response ? Effect.succeed(error) : Effect.fail(error))), - ) - - return runtime.runPromise(withRetry) as Promise -} +/** + * `shapeOptions.onError` for every collection. Without one, the first error stops a shape for + * good, and a collection that never becomes ready leaves its live queries waiting. A 401 is + * retried with a fresh token: right after sign-in the token exchange can fail once. Electric + * paces these retries and gives up after 50 in a row. Any other 4xx means the request itself is + * wrong (a table the proxy does not allow, a bad parameter), so the shape stops. + */ +export const electricOnError = (error: Error): Record | undefined => + error instanceof FetchError && error.status !== 401 ? undefined : {} diff --git a/apps/web-foldkit/src/data/live-query.test.ts b/apps/web-foldkit/src/data/live-query.test.ts new file mode 100644 index 000000000..97379327d --- /dev/null +++ b/apps/web-foldkit/src/data/live-query.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { watchStall } from "./live-query" + +type Status = "idle" | "loading" | "ready" | "error" | "cleaned-up" + +/** A collection whose status the test moves by hand. */ +const fakeCollection = (initial: Status) => { + let listener: ((event: { status: Status; previousStatus: Status }) => void) | undefined + const collection = { + status: initial, + on: (_event: "status:change", callback: typeof listener) => { + listener = callback + return () => { + listener = undefined + } + }, + move(status: Status) { + const previousStatus = collection.status + collection.status = status + listener?.({ status, previousStatus }) + }, + get isWatched() { + return listener !== undefined + }, + } + return collection +} + +const watch = (collection: ReturnType) => { + const offered: Array = [] + const emit = vi.fn() + // The fake has only what the watchdog reads. + const stop = watchStall( + collection as never, + () => "stalled", + 10_000, + emit, + (message) => offered.push(message), + ) + return { offered, emit, stop } +} + +describe("watchStall", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + test("a query that loads in time is never reported", () => { + const collection = fakeCollection("loading") + const { offered } = watch(collection) + vi.advanceTimersByTime(9_000) + collection.move("ready") + vi.advanceTimersByTime(60_000) + expect(offered).toEqual([]) + }) + + test("a query still loading after the limit is reported once", () => { + const { offered } = watch(fakeCollection("loading")) + vi.advanceTimersByTime(10_000) + vi.advanceTimersByTime(60_000) + expect(offered).toEqual(["stalled"]) + }) + + test("a failed source is reported after a short grace, not at once", () => { + const collection = fakeCollection("loading") + const { offered } = watch(collection) + collection.move("error") + vi.advanceTimersByTime(2_999) + expect(offered).toEqual([]) + vi.advanceTimersByTime(1) + expect(offered).toEqual(["stalled"]) + }) + + test("a source that recovers within the grace is not reported, and its rows are emitted again", () => { + const collection = fakeCollection("loading") + const { offered, emit } = watch(collection) + collection.move("error") + vi.advanceTimersByTime(1_000) + collection.move("ready") + vi.advanceTimersByTime(60_000) + expect(offered).toEqual([]) + expect(emit).toHaveBeenCalledOnce() + }) + + test("a loaded query that later fails is reported, and again after it recovers and fails anew", () => { + const collection = fakeCollection("ready") + const { offered } = watch(collection) + collection.move("error") + vi.advanceTimersByTime(3_000) + collection.move("ready") + collection.move("error") + vi.advanceTimersByTime(3_000) + expect(offered).toEqual(["stalled", "stalled"]) + }) + + test("stopping clears the timer and the status listener", () => { + const collection = fakeCollection("loading") + const { offered, stop } = watch(collection) + stop() + vi.advanceTimersByTime(60_000) + expect(offered).toEqual([]) + expect(collection.isWatched).toBe(false) + }) +}) diff --git a/apps/web-foldkit/src/data/live-query.ts b/apps/web-foldkit/src/data/live-query.ts index e9f7154fb..fb57adfc4 100644 --- a/apps/web-foldkit/src/data/live-query.ts +++ b/apps/web-foldkit/src/data/live-query.ts @@ -1,6 +1,26 @@ -import { createLiveQueryCollection, type InitialQueryBuilder, type QueryBuilder } from "@tanstack/db" +import { + type Collection, + createLiveQueryCollection, + type InitialQueryBuilder, + type QueryBuilder, +} from "@tanstack/db" import { Effect, Queue, Stream } from "effect" +/** How long a query may stay unloaded before it counts as stalled. */ +const STALL_AFTER_MS = 10_000 + +/** A failed source is often retried within a moment; only a failure that lasts this long is reported. */ +const ERROR_GRACE_MS = 3_000 + +export interface LiveQueryOptions { + /** + * Sent once when the query has not loaded after `stallAfterMs`, or its data source has failed + * for a few seconds. Data that arrives later is still emitted, so the screen can recover. + */ + readonly onStalled?: () => Message + readonly stallAfterMs?: number +} + /** * Bridges a TanStack DB live query into a Foldkit Subscription stream: emits the * full result set once the query is ready and again after every change. @@ -11,6 +31,7 @@ import { Effect, Queue, Stream } from "effect" export const liveQueryStream = ( query: (q: InitialQueryBuilder) => QueryBuilder, toMessage: (rows: ReadonlyArray) => Message, + options: LiveQueryOptions = {}, ): Stream.Stream => Stream.callback((queue) => Effect.acquireRelease( @@ -20,13 +41,61 @@ export const liveQueryStream = ( Queue.offerUnsafe(queue, toMessage(collection.toArray as ReadonlyArray)) const subscription = collection.subscribeChanges(emit) collection.onFirstReady(emit) - return { collection, subscription } + const watchdog = options.onStalled + ? watchStall( + collection, + options.onStalled, + options.stallAfterMs ?? STALL_AFTER_MS, + emit, + (message) => Queue.offerUnsafe(queue, message), + ) + : () => {} + return { collection, subscription, watchdog } }), - ({ collection, subscription }) => + ({ collection, subscription, watchdog }) => // A failed cleanup must not take the app down; the query is gone either way. Effect.tryPromise(async () => { + watchdog() subscription.unsubscribe() await collection.cleanup() }).pipe(Effect.ignore), ).pipe(Effect.flatMap(() => Effect.never)), ) + +/** + * Reports a query that is not loading, and re-emits its rows when a failed source recovers + * (a recovery need not change any row, so `subscribeChanges` may stay quiet). Returns the cleanup. + */ +export const watchStall = ( + collection: Pick, "status" | "on">, + onStalled: () => Message, + stallAfterMs: number, + emit: () => void, + offer: (message: Message) => void, +): (() => void) => { + let isReported = false + let timer: ReturnType | undefined + const report = () => { + timer = undefined + if (isReported || collection.status === "ready") return + isReported = true + offer(onStalled()) + } + const schedule = (ms: number) => { + clearTimeout(timer) + timer = setTimeout(report, ms) + } + if (collection.status !== "ready") schedule(stallAfterMs) + const unsubscribe = collection.on("status:change", ({ status, previousStatus }) => { + if (status === "error") schedule(ERROR_GRACE_MS) + if (status === "ready") { + clearTimeout(timer) + isReported = false + if (previousStatus === "error") emit() + } + }) + return () => { + clearTimeout(timer) + unsubscribe() + } +} diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts index 2ba022025..33b26df08 100644 --- a/apps/web-foldkit/src/main.ts +++ b/apps/web-foldkit/src/main.ts @@ -6,7 +6,10 @@ import { toString as urlToString, type Url } from "foldkit/url" import { ApplyTheme, CheckSession, + Reload, + RetryCheckSession, FetchCurrentUser, + RetryFetchCurrentUser, LoadExternal, NavigateInternal, DeliverNotifications, @@ -395,7 +398,9 @@ export const init: Runtime.RoutingApplicationInit }), CompletedDeliverNotifications: () => ({ model }), ChangedAuth: ({ auth }) => { - const next = modifyFields(model, { auth: () => auth }) + const next = modifyFields(model, { auth: () => auth, isSessionStalled: () => false }) // Legacy `userAtom` runs wherever `useAuth()` mounts: the join page queries it signed out too. const isUserQueried = (auth === "SignedIn" && model.auth !== "SignedIn") || @@ -462,19 +467,48 @@ export const update = (model: Model, message: Message): Return => (m) => ({ model: m, commands: fetchUser }), ]) }, + RejectedSession: () => ({ model, commands: [CheckSession()] }), + FailedCheckSession: () => ({ + model: modifyFields(model, { isSessionStalled: () => true }), + commands: [RetryCheckSession()], + }), SucceededFetchCurrentUser: ({ user }) => - Update.combine(modifyFields(model, { currentUser: () => user }), [ - informPage, - informShell, - ]), - FailedFetchCurrentUser: () => ({ model }), + Update.combine( + modifyFields(model, { currentUser: () => user, isCurrentUserStalled: () => false }), + [informPage, informShell], + ), + // A signed-in shell has nothing to show without its user, so it keeps asking. Unauthorized + // also re-reads the session: if it has ended, ChangedAuth sends the browser to sign-in. + FailedFetchCurrentUser: ({ isUnauthorized }) => + model.auth !== "SignedIn" || model.currentUser !== null + ? { model } + : { + model: modifyFields(model, { isCurrentUserStalled: () => true }), + commands: [...(isUnauthorized ? [CheckSession()] : []), RetryFetchCurrentUser()], + }, CompletedSignOut: () => ({ model }), FailedSignOut: () => withToasts(model, Toasts.push(model.toasts, errorToast("Failed to sign out", "Please try again."))), UpdatedOrganization: ({ orgSlug, organization }) => Update.combine( - modifyFields(model, { organization: () => organization, loadedOrgSlug: () => orgSlug }), + modifyFields(model, { + organization: () => organization, + orgLoad: () => ({ slug: orgSlug, status: organization === null ? "NotFound" : "Found" }), + // The role belongs to one organization; another one's must not show until its own arrives. + member: (member) => (organization?.id === model.organization?.id ? member : null), + }), [informPage, informShell], ), + // Only a slug still loading counts: data already on screen stays when its sync hiccups. + StalledOrganization: ({ orgSlug }) => + model.orgLoad?.slug === orgSlug && model.orgLoad.status !== "Stalled" + ? { model } + : { + model: modifyFields(model, { + orgLoad: () => ({ slug: orgSlug, status: "Stalled" as const }), + }), + }, + PressedReload: () => ({ model, commands: [Reload()] }), + CompletedReload: () => ({ model }), UpdatedMember: ({ member }) => Update.combine(modifyFields(model, { member: () => member }), [ informPage, diff --git a/apps/web-foldkit/src/page/root/index.ts b/apps/web-foldkit/src/page/root/index.ts index 5ab14e663..809b4b096 100644 --- a/apps/web-foldkit/src/page/root/index.ts +++ b/apps/web-foldkit/src/page/root/index.ts @@ -34,12 +34,20 @@ const targetOf = (model: Model, user: CurrentUser): string => ? model.returnTo : rootRedirect(user, Option.fromNullishOr(model.membership)) -/** Redirects once the user, their membership and the remembered page are known. */ +export const Reload = Command.define("Reload", { + messages: [Message.CompletedReload], + execute: Effect.sync(() => window.location.reload()).pipe(Effect.as(Message.CompletedReload())), +}) + +/** The membership matters only without a page to return to, or before onboarding. */ +const needsMembership = (model: Model, user: CurrentUser): boolean => !(model.returnTo && user.isOnboarded) + +/** Redirects once the user and the remembered page are known, and the membership if it decides. */ const redirectWhenReady = (model: Model, shared: Shared): Return => model.hasRedirected || - model.membership === undefined || model.returnTo === undefined || - shared.currentUser === null + shared.currentUser === null || + (model.membership === undefined && needsMembership(model, shared.currentUser)) ? { model } : { model: modifyFields(model, { hasRedirected: () => true }), @@ -89,20 +97,27 @@ const subscriptions = Subscription.make, Message>() : null, }) }, + { onStalled: () => Message.StalledMembership() }, ), }, ), })) export const init = (): Return => ({ - model: { membership: undefined, returnTo: undefined, hasRedirected: false }, + model: { membership: undefined, returnTo: undefined, hasRedirected: false, isStalled: false }, commands: [TakeReturnTo()], }) export const update = (model: Model, message: Message, shared: Shared): Return => Message.match(message, { UpdatedMembership: ({ membership }) => - redirectWhenReady(modifyFields(model, { membership: () => membership }), shared), + redirectWhenReady( + modifyFields(model, { membership: () => membership, isStalled: () => false }), + shared, + ), + StalledMembership: () => ({ model: modifyFields(model, { isStalled: () => true }) }), + PressedReload: () => ({ model, commands: [Reload()] }), + CompletedReload: () => ({ model }), TookReturnTo: ({ returnTo }) => redirectWhenReady(modifyFields(model, { returnTo: () => returnTo }), shared), }) @@ -114,7 +129,9 @@ export const page = definePage( routes: ["Root"], init, update, - view: Submodel.defineView((_model, _inputs, h) => appLoader(h)), + view: Submodel.defineView((model, _inputs, h) => + appLoader(h, model.isStalled ? Message.PressedReload() : undefined), + ), subscriptions, sharedChanged: redirectWhenReady, }, diff --git a/apps/web-foldkit/src/page/root/model.ts b/apps/web-foldkit/src/page/root/model.ts index 55949831e..0eaf89b04 100644 --- a/apps/web-foldkit/src/page/root/model.ts +++ b/apps/web-foldkit/src/page/root/model.ts @@ -13,11 +13,16 @@ export const Model = Schema.Struct({ /** The page sign-in started from (`auth/return-to.ts`); `undefined` until read, `null` if none. */ returnTo: Schema.UndefinedOr(Schema.NullOr(Schema.String)), hasRedirected: Schema.Boolean, + /** The membership query has not loaded after a while: the loader offers a reload. */ + isStalled: Schema.Boolean, }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ UpdatedMembership: { membership: Schema.NullOr(RootMembership) }, TookReturnTo: { returnTo: Schema.NullOr(Schema.String) }, + StalledMembership: {}, + PressedReload: {}, + CompletedReload: {}, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/root/story.test.ts b/apps/web-foldkit/src/page/root/story.test.ts index dfc766a26..261872e8b 100644 --- a/apps/web-foldkit/src/page/root/story.test.ts +++ b/apps/web-foldkit/src/page/root/story.test.ts @@ -1,9 +1,9 @@ // @vitest-environment jsdom -import { Command, expectOutMessage, given, message, story } from "foldkit/story" +import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { currentUser, makeShared, organizationId, storyUpdate } from "../../test/pages-fixtures" import { PageOutMessage } from "../out-message" -import { init, TakeReturnTo, update } from "./index" +import { init, Reload, TakeReturnTo, update } from "./index" import { Message } from "./model" /** `/` forwards once the user, their membership and the page sign-in started from are known. */ @@ -29,16 +29,31 @@ describe("root redirect", () => { ) }) - test("back to the page sign-in started from", () => { + test("back to the page sign-in started from, without waiting for the membership", () => { story( signedIn, given(init().model), message(Message.TookReturnTo({ returnTo: "/join/acme" })), - message(Message.UpdatedMembership({ membership: null })), go("/join/acme"), ) }) + test("a stalled membership query offers a reload until it loads", () => { + story( + signedIn, + given(init().model), + message(Message.TookReturnTo({ returnTo: null })), + message(Message.StalledMembership()), + model((m) => expect(m.isStalled).toBe(true)), + message(Message.PressedReload()), + Command.expectExact(Reload()), + Command.resolve(Reload, Message.CompletedReload()), + message(Message.UpdatedMembership({ membership })), + model((m) => expect(m.isStalled).toBe(false)), + go("/hazel"), + ) + }) + test("onboarding comes before the remembered page", () => { story( notOnboarded, diff --git a/apps/web-foldkit/src/shell/layouts.ts b/apps/web-foldkit/src/shell/layouts.ts index 1826074dc..9c511a3d4 100644 --- a/apps/web-foldkit/src/shell/layouts.ts +++ b/apps/web-foldkit/src/shell/layouts.ts @@ -2,6 +2,8 @@ import type { ChannelId } from "@hazel/schema" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { IconHashtag } from "../icons" import { AppRoute, hrefOf } from "../route" +import { button, buttonClassName } from "../ui/button" +import { emptyState } from "../ui/empty-state" import { progressBar, progressBarTrack } from "../ui/progress-bar" import { tabStrip, tabView } from "../ui/tab-strip" import type { ChannelSummary } from "./model" @@ -194,8 +196,12 @@ export const channelSettingsLayout = ( ) } -/** `components/loader.tsx`: the full-page loader `_app` shows while auth or the user loads. */ -export const appLoader = (h: HtmlBuilder): Html => +/** + * `components/loader.tsx`: the full-page loader `_app` shows while auth or the user loads. + * With `onReload`, loading has stalled: a note and a reload button show under it, and the + * loader keeps waiting, so data that arrives late still replaces it. + */ +export const appLoader = (h: HtmlBuilder, onReload?: Message): Html => h.div( [h.Class("flex h-screen flex-col items-center justify-center gap-6")], [ @@ -237,5 +243,31 @@ export const appLoader = (h: HtmlBuilder): Html => ), ], ), + ...(onReload === undefined ? [] : [stalledNotice(h, onReload)]), + ], + ) + +const stalledNotice = (h: HtmlBuilder, onReload: Message): Html => + h.div( + [h.Class("flex flex-col items-center gap-3 text-center"), h.Role("status")], + [ + h.p([h.Class("text-muted-fg text-sm")], ["This is taking longer than usual. Still trying…"]), + button(h, { intent: "outline", size: "sm", onPress: onReload }, ["Reload"]), + ], + ) + +/** The route names an organization the user cannot see: it does not exist or they are not a member. */ +export const organizationNotFound = (h: HtmlBuilder, orgSlug: string): Html => + h.div( + [h.Class("flex h-screen items-center justify-center p-6")], + [ + emptyState(h, { + title: "Workspace not found", + description: `The workspace “${orgSlug}” does not exist, or you are not a member of it.`, + action: h.a( + [h.Attribute("href", "/"), h.Class(buttonClassName({ intent: "primary" }))], + ["Go to your workspace"], + ), + }), ], ) diff --git a/packages/auth/src/yielded/access-token.test.ts b/packages/auth/src/yielded/access-token.test.ts index a00904290..9674669a5 100644 --- a/packages/auth/src/yielded/access-token.test.ts +++ b/packages/auth/src/yielded/access-token.test.ts @@ -1,6 +1,14 @@ import { ConfigProvider, Effect, Exit, Layer, Redacted } from "effect" +import { TestClock } from "effect/testing" import { describe, expect, test } from "vitest" -import { AccessTokenVerifier, generateSigningKey, makeIssuer, publicJwk } from "./access-token.ts" +import { + AccessTokenVerifier, + clockToleranceSeconds, + generateSigningKey, + lifetimeSeconds, + makeIssuer, + publicJwk, +} from "./access-token.ts" import { CryptoLive } from "./server.ts" /** The verifier the Electric proxy and actors use, configured as they are on a stage. */ @@ -13,10 +21,14 @@ const keyPair = async (kid: string) => { return { kid, privateJwk: pair.privateKey } } -const mint = async (issuer: string, key: Awaited>) => { +/** A token as the API mints it; with `issuedAtMs`, as if minted then. */ +const mint = async (issuer: string, key: Awaited>, issuedAtMs?: number) => { const tokens = await Effect.runPromise(makeIssuer(issuer, key).pipe(Effect.provide(CryptoLive))) + const minting = tokens.mint({ subjectId: userId, sessionId: "s1" }).pipe(Effect.provide(CryptoLive)) const token = await Effect.runPromise( - tokens.mint({ subjectId: userId, sessionId: "s1" }).pipe(Effect.provide(CryptoLive)), + issuedAtMs === undefined + ? minting + : TestClock.setTime(issuedAtMs).pipe(Effect.andThen(minting), Effect.provide(TestClock.layer())), ) return Redacted.value(token) } @@ -55,6 +67,20 @@ describe("AccessTokenVerifier", () => { expect(failureTag(exit)).toEqual(["AccessTokenInvalid"]) }) + test("a token just past its expiry still verifies, within the clock tolerance", async () => { + const key = await keyPair("k1") + const issuedAtMs = Date.now() - (lifetimeSeconds + clockToleranceSeconds / 2) * 1000 + const exit = await verify(await mint(api, key, issuedAtMs), key) + expect(exit).toEqual(Exit.succeed({ userId, sessionId: "s1" })) + }) + + test("a token expired beyond the clock tolerance is rejected", async () => { + const key = await keyPair("k1") + const issuedAtMs = Date.now() - (lifetimeSeconds + clockToleranceSeconds * 2) * 1000 + const exit = await verify(await mint(api, key, issuedAtMs), key) + expect(failureTag(exit)).toEqual(["AccessTokenInvalid"]) + }) + test("a token signed with another key is rejected", async () => { const exit = await verify(await mint(api, await keyPair("k1")), await keyPair("k1")) expect(failureTag(exit)).toEqual(["AccessTokenInvalid"]) diff --git a/packages/auth/src/yielded/access-token.ts b/packages/auth/src/yielded/access-token.ts index 831ae1cc9..0df2e3ec0 100644 --- a/packages/auth/src/yielded/access-token.ts +++ b/packages/auth/src/yielded/access-token.ts @@ -74,6 +74,9 @@ export const publicJwk = (key: SigningKey) => return yield* Jwk.exportPublic(imported) }) +/** Seconds of clock difference between the API and a verifier that `exp` tolerates. */ +export const clockToleranceSeconds = 30 + /** Verification used by electric-proxy / actors. Requires `Jwks` (local or remote). */ export const verifyAccessToken = (issuer: string, token: string) => Jwt.verifyWithKeySet(AccessClaims, Redacted.make(token), { @@ -81,6 +84,7 @@ export const verifyAccessToken = (issuer: string, token: string) => issuer, audience, typ: "at+jwt", + clockTolerance: clockToleranceSeconds, requiredClaims: ["iss", "sub", "aud", "exp", "iat"], }) @@ -181,9 +185,15 @@ export class AccessTokenVerifier extends Context.Service< }).pipe(Effect.map(Jwks.layerLocal)), ), onNone: () => - Jwks.layerRemote({ url: `${issuer}/.well-known/jwks.json` }).pipe( - Layer.provide(FetchHttpClient.layer), - ), + // After a failed fetch the verifier answers "unavailable" (503) for `cooldownMs`; the + // default 30s outlasts the clients' retries, so a cold isolate whose first fetch + // failed would refuse every shape for half a minute. Every shape of a client + // verifies at once on boot, so allow more concurrent waiters than the default 64. + Jwks.layerRemote({ + url: `${issuer}/.well-known/jwks.json`, + cooldownMs: 2_000, + maxWaiters: 1_024, + }).pipe(Layer.provide(FetchHttpClient.layer)), }) return Layer.effect(AccessTokenVerifier, makeVerifier(issuer)).pipe( Layer.provide(Layer.merge(keySet, VerifierCryptoLive)), diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts index f3d6a6229..7c21e7cf1 100644 --- a/packages/auth/src/yielded/server.ts +++ b/packages/auth/src/yielded/server.ts @@ -233,9 +233,11 @@ export const makeHazelAuthLive = (config: HazelAuthConfig) => { sqlHandle.layer, ).pipe( Layer.provideMerge(Storage), - // Postgres stamps session times with its own clock; tolerate it running slightly - // ahead of this process. Shared by Auth and persistence, so provided beneath both. - Layer.provideMerge(Operations.AuthenticationClock.layer({ futureToleranceMillis: 1_000 })), + // Postgres stamps session times with its own clock; tolerate it running ahead of this + // process. A session issued "in the future" is rejected, so with too small a window the + // first requests after sign-in fail until the clocks agree. Shared by Auth and + // persistence, so provided beneath both. + Layer.provideMerge(Operations.AuthenticationClock.layer({ futureToleranceMillis: 30_000 })), Layer.provideMerge(layerRouted), Layer.provideMerge(CryptoLive), ) From 87853046552b0d6c999ee4db7c2e33d4732fce0f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 15:20:51 +0200 Subject: [PATCH 2/2] fix(web): keep shapes syncing through a token exchange outage When the API did not answer `/auth/token`, the shape fetch reported a 401. Electric then treated it as a rejected token, which counts against `onError`'s cap of 50 consecutive retries. A long outage could stop a shape for good, so it stayed stopped after the API came back. `readAccessToken` now tells an unavailable exchange apart from a refused session. The shape fetch answers 503 for an unavailable exchange, so Electric's fetch backoff retries it without a cap. A 401 now only means there is no session. The legacy service worker now navigates its windows with `Promise.allSettled`, so one failed navigation does not stop the others. Co-Authored-By: Claude Opus 5.5 --- apps/web-foldkit/public/sw.js | 3 +- .../src/core/lib/access-token.test.ts | 11 +++- apps/web-foldkit/src/core/lib/access-token.ts | 36 ++++++++--- .../src/core/lib/electric-fetch.test.ts | 61 +++++++++++++++++-- .../src/core/lib/electric-fetch.ts | 12 ++-- 5 files changed, 101 insertions(+), 22 deletions(-) diff --git a/apps/web-foldkit/public/sw.js b/apps/web-foldkit/public/sw.js index 1b3cd572d..be1f29188 100644 --- a/apps/web-foldkit/public/sw.js +++ b/apps/web-foldkit/public/sw.js @@ -13,7 +13,8 @@ self.addEventListener("activate", (event) => { await self.clients.claim() await self.registration.unregister() const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }) - await Promise.all(windows.map((client) => client.navigate(client.url))) + // One failed navigation must not stop the others. + await Promise.allSettled(windows.map((client) => client.navigate(client.url))) })(), ) }) diff --git a/apps/web-foldkit/src/core/lib/access-token.test.ts b/apps/web-foldkit/src/core/lib/access-token.test.ts index e1d250b1a..28f7787c4 100644 --- a/apps/web-foldkit/src/core/lib/access-token.test.ts +++ b/apps/web-foldkit/src/core/lib/access-token.test.ts @@ -4,6 +4,7 @@ import { clearAccessToken, getAccessToken, invalidateAccessToken, + readAccessToken, SESSION_REJECTED_EVENT, } from "./access-token" @@ -70,11 +71,15 @@ describe("session token exchange", () => { const { seen, stop } = rejections() const fetch = vi.fn(async () => new Response(null, { status: 503 })) vi.stubGlobal("fetch", fetch) - const token = getAccessToken() + const token = readAccessToken() await vi.runAllTimersAsync() - expect(await token).toBeNull() + expect(await token).toBe("unavailable") expect(fetch).toHaveBeenCalledTimes(3) expect(seen).not.toHaveBeenCalled() + // Callers that only want a token see none. + const plain = getAccessToken() + await vi.runAllTimersAsync() + expect(await plain).toBeNull() stop() }) @@ -82,7 +87,7 @@ describe("session token exchange", () => { const { seen, stop } = rejections() const fetch = vi.fn(async () => new Response(null, { status: 401 })) vi.stubGlobal("fetch", fetch) - expect(await getAccessToken()).toBeNull() + expect(await readAccessToken()).toBeNull() expect(fetch).toHaveBeenCalledOnce() expect(seen).toHaveBeenCalledOnce() stop() diff --git a/apps/web-foldkit/src/core/lib/access-token.ts b/apps/web-foldkit/src/core/lib/access-token.ts index 2e2d1c46f..6cc3c40ef 100644 --- a/apps/web-foldkit/src/core/lib/access-token.ts +++ b/apps/web-foldkit/src/core/lib/access-token.ts @@ -23,7 +23,13 @@ const RENEW_BEFORE_MS = 60_000 let generation = 0 let cached: CachedToken | null = null -let pending: Promise | null = null +let pending: Promise | null = null + +/** + * A token; `null` when there is no session (or the exchange was superseded by sign-out); + * `"unavailable"` when the API did not answer, which says nothing about the session. + */ +export type AccessTokenResult = string | null | "unavailable" const backendUrl = () => import.meta.env.VITE_BACKEND_URL || "http://localhost:3003" @@ -40,8 +46,8 @@ const EXCHANGE_ATTEMPTS = 3 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -/** `null` means the API answered and there is no session; `"retry"` means it could not answer. */ -const exchangeOnce = async (startedGeneration: number): Promise => { +/** `null` means the API answered and there is no session; `"unavailable"` means it could not answer. */ +const exchangeOnce = async (startedGeneration: number): Promise => { try { const authorization = await sessionAuthorization() const response = await fetch(`${backendUrl()}/auth/token`, { @@ -52,7 +58,7 @@ const exchangeOnce = async (startedGeneration: number): Promise= 500) return "retry" + if (response.status >= 500) return "unavailable" if (!response.ok) { cached = null if (response.status === 401) window.dispatchEvent(new Event(SESSION_REJECTED_EVENT)) @@ -63,21 +69,25 @@ const exchangeOnce = async (startedGeneration: number): Promise => { +const exchange = async (startedGeneration: number): Promise => { for (let attempt = 1; ; attempt++) { const result = await exchangeOnce(startedGeneration) - if (result !== "retry") return result - if (attempt >= EXCHANGE_ATTEMPTS || startedGeneration !== generation) return null + if (result !== "unavailable") return result + if (startedGeneration !== generation) return null + if (attempt >= EXCHANGE_ATTEMPTS) return "unavailable" await sleep(250 * 2 ** attempt) } } -/** The current access token, exchanged again shortly before it expires; `null` when signed out. */ -export const getAccessToken = async (): Promise => { +/** + * The current access token, exchanged again shortly before it expires. Tells a refused session + * (`null`) apart from an API that did not answer (`"unavailable"`), so callers can retry the latter. + */ +export const readAccessToken = async (): Promise => { if (typeof window === "undefined") return null if (cached !== null && cached.expiresAtMs - Date.now() > RENEW_BEFORE_MS) return cached.token if (pending === null) { @@ -89,6 +99,12 @@ export const getAccessToken = async (): Promise => { return pending } +/** The current access token; `null` when signed out or the API did not answer. */ +export const getAccessToken = async (): Promise => { + const result = await readAccessToken() + return result === "unavailable" ? null : result +} + /** Forgets the cached token so the next request exchanges a new one, after a host rejected it. */ export const invalidateAccessToken = (rejected: string) => { if (cached?.token === rejected) cached = null diff --git a/apps/web-foldkit/src/core/lib/electric-fetch.test.ts b/apps/web-foldkit/src/core/lib/electric-fetch.test.ts index 5d8998790..7bee264ee 100644 --- a/apps/web-foldkit/src/core/lib/electric-fetch.test.ts +++ b/apps/web-foldkit/src/core/lib/electric-fetch.test.ts @@ -1,9 +1,16 @@ +// @vitest-environment jsdom import { FetchError } from "@electric-sql/client" -import { describe, expect, test } from "vitest" -import { electricOnError } from "./electric-fetch" +import { afterEach, describe, expect, test, vi } from "vitest" +import { clearAccessToken } from "./access-token" +import { electricFetchClient, electricOnError } from "./electric-fetch" -const fetchError = (status: number) => - new FetchError(status, undefined, undefined, {}, "http://electric/v1/shape") +const fetchError = (status: number) => new FetchError(status, undefined, undefined, {}, "http://electric/v1/shape") + +afterEach(() => { + clearAccessToken() + vi.unstubAllGlobals() + vi.useRealTimers() +}) describe("electricOnError", () => { test("retries a rejected token and errors that are not HTTP responses", () => { @@ -15,3 +22,49 @@ describe("electricOnError", () => { for (const status of [400, 403, 404]) expect(electricOnError(fetchError(status))).toBeUndefined() }) }) + +describe("electricFetchClient", () => { + const shape = "http://electric/v1/shape?table=organizations" + + test("sends the shape request with the access token", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ token: "t1", expiresIn: 300 })) + .mockResolvedValue(new Response("[]")) + vi.stubGlobal("fetch", fetch) + expect((await electricFetchClient(shape)).status).toBe(200) + expect(fetch.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: "Bearer t1" }) + }) + + test("an API that does not answer the exchange is a retryable 503, not a rejected token", async () => { + vi.useFakeTimers() + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 503 })), + ) + const response = electricFetchClient(shape) + await vi.runAllTimersAsync() + expect((await response).status).toBe(503) + }) + + test("no session is a 401", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 401 })), + ) + expect((await electricFetchClient(shape)).status).toBe(401) + }) + + test("a token the proxy rejects is exchanged again on the next request", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ token: "t1", expiresIn: 300 })) + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(Response.json({ token: "t2", expiresIn: 300 })) + .mockResolvedValue(new Response("[]")) + vi.stubGlobal("fetch", fetch) + expect((await electricFetchClient(shape)).status).toBe(401) + expect((await electricFetchClient(shape)).status).toBe(200) + expect(fetch.mock.calls[3]?.[1]?.headers).toMatchObject({ Authorization: "Bearer t2" }) + }) +}) diff --git a/apps/web-foldkit/src/core/lib/electric-fetch.ts b/apps/web-foldkit/src/core/lib/electric-fetch.ts index 335eb20e0..5521a2b45 100644 --- a/apps/web-foldkit/src/core/lib/electric-fetch.ts +++ b/apps/web-foldkit/src/core/lib/electric-fetch.ts @@ -6,15 +6,19 @@ * network errors without limit; every other error reaches `electricOnError`. */ import { FetchError } from "@electric-sql/client" -import { getAccessToken, invalidateAccessToken } from "./access-token" +import { invalidateAccessToken, readAccessToken } from "./access-token" export const electricFetchClient = async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { - const token = await getAccessToken() - // No token: the exchange failed or there is no session. `electricOnError` retries a 401. - if (!token) return new Response(null, { status: 401 }) + const token = await readAccessToken() + // The API did not answer: a 503 sends this request through Electric's fetch backoff, which + // retries without limit. A 401 would count against `onError`'s 50-retry cap, and an outage + // that outlasted it would leave the shape stopped after the API came back. + if (token === "unavailable") return new Response(null, { status: 503 }) + // No session: `electricOnError` retries the 401; the app re-reads the session meanwhile. + if (token === null) return new Response(null, { status: 401 }) const response = await fetch(input, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${token}` },