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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/services/hazel-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
23 changes: 16 additions & 7 deletions apps/backend/src/services/hazel-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions apps/web-foldkit/public/sw.js
Original file line number Diff line number Diff line change
@@ -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)))
})(),
)
})
9 changes: 6 additions & 3 deletions apps/web-foldkit/src/app/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -31,10 +31,13 @@ const read: Effect.Effect<Auth, unknown, HazelAuthClient> = isDesktop
? readDesktopSession
: readBrowserSession

export const readSession: Effect.Effect<Auth, never, HazelAuthClient> = 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<Auth, unknown, HazelAuthClient> = 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. */
Expand Down
99 changes: 71 additions & 28 deletions apps/web-foldkit/src/app/command.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)),
})
75 changes: 71 additions & 4 deletions apps/web-foldkit/src/app/loading.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -51,6 +51,7 @@ const after = (model: Model, messages: ReadonlyArray<Message>) =>

const config = { update, view: (model: Model, h: Parameters<typeof view>[1]) => view(model, h).body }
const loader = role("progressbar", { name: "Loading" })
const reload = role("button", { name: "Reload" })

/** Whether the root renders the loader (`<ProgressBar aria-label="Loading">`). */
const expectLoader = (model: Model, isShown: boolean) =>
Expand All @@ -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()
})
})
10 changes: 9 additions & 1 deletion apps/web-foldkit/src/app/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
17 changes: 15 additions & 2 deletions apps/web-foldkit/src/app/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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. */
Expand Down
Loading
Loading