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..be1f29188 --- /dev/null +++ b/apps/web-foldkit/public/sw.js @@ -0,0 +1,20 @@ +// 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 }) + // 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/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..28f7787c4 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,24 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest" -import { clearAccessToken, getAccessToken } from "./access-token" +import { + clearAccessToken, + getAccessToken, + invalidateAccessToken, + readAccessToken, + 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 +51,58 @@ 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 = readAccessToken() + await vi.runAllTimersAsync() + 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() + }) + + 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 readAccessToken()).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..6cc3c40ef 100644 --- a/apps/web-foldkit/src/core/lib/access-token.ts +++ b/apps/web-foldkit/src/core/lib/access-token.ts @@ -12,12 +12,24 @@ 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 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" @@ -29,7 +41,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; `"unavailable"` 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 +58,10 @@ const exchange = async (startedGeneration: number): Promise => { : { credentials: "omit" as const, headers: { authorization } }), }) if (startedGeneration !== generation) return null + if (response.status >= 500) return "unavailable" 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,12 +69,25 @@ const exchange = async (startedGeneration: number): Promise => { cached = { token: body.token, expiresAtMs: Date.now() + body.expiresIn * 1000 } return body.token } catch { - return null + return "unavailable" } } -/** The current access token, exchanged again shortly before it expires; `null` when signed out. */ -export const getAccessToken = async (): Promise => { +const exchange = async (startedGeneration: number): Promise => { + for (let attempt = 1; ; attempt++) { + const result = await exchangeOnce(startedGeneration) + 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. 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) { @@ -66,6 +99,17 @@ 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 +} + /** 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..7bee264ee --- /dev/null +++ b/apps/web-foldkit/src/core/lib/electric-fetch.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { FetchError } from "@electric-sql/client" +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") + +afterEach(() => { + clearAccessToken() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +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() + }) +}) + +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 2c25fd076..5521a2b45 100644 --- a/apps/web-foldkit/src/core/lib/electric-fetch.ts +++ b/apps/web-foldkit/src/core/lib/electric-fetch.ts @@ -1,49 +1,39 @@ /** - * 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" - -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 => { - const token = await getAccessToken() - if (!token) return new Response(null, { status: 401 }) - return fetch(input, { - ...init, - headers: { ...init?.headers, Authorization: `Bearer ${token}` }, - }) -} +import { FetchError } from "@electric-sql/client" +import { invalidateAccessToken, readAccessToken } from "./access-token" 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 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}` }, }) - - 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 + // The proxy rejected this token (expired, or its session has ended); the next attempt exchanges again. + if (response.status === 401) invalidateAccessToken(token) + return response } + +/** + * `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), )