Skip to content
Closed
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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { appAtomRegistry } from "../../state/atom-registry";
import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider";
import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./cloudAccountService";
import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration";

vi.mock("@clerk/expo", () => ({
Expand All @@ -27,6 +27,10 @@ vi.mock("../../connection/catalog", () => ({
}));

vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} }));
vi.mock("./connectOnboarding", () => ({
clearConnectOnboardingRequest: vi.fn(),
requestConnectOnboarding: vi.fn(),
}));
vi.mock("../../state/use-composer-drafts", () => ({
getComposerCloudAccountId: vi.fn(async () => null),
restoreCloudComposerDrafts: vi.fn(async () => undefined),
Expand Down
216 changes: 20 additions & 196 deletions apps/mobile/src/features/cloud/CloudAuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,208 +1,32 @@
import { ClerkProvider, useAuth } from "@clerk/expo";
import { ClerkProvider, getClerkInstance } from "@clerk/expo";
import { tokenCache } from "@clerk/expo/token-cache";
import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
import {
reportAtomCommandResult,
settleAsyncResult,
settlePromise,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import * as Effect from "effect/Effect";
import { type ReactNode, useEffect, useRef } from "react";

import { runtime } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
import { useAtomCommand } from "../../state/use-atom-command";
import {
getComposerCloudAccountId,
restoreCloudComposerDrafts,
} from "../../state/use-composer-drafts";
import {
releaseAgentAwarenessRelayTokenProvider,
setAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
import { removeCloudEnvironments } from "./cloud-drafts";

function resetManagedRelayTokenCache() {
return settleAsyncResult(() =>
runtime.runPromiseExit(
ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
),
);
}

export function deactivateCloudRelayAccount(): void {
setAgentAwarenessRelayTokenProvider(null);
setManagedRelaySession(appAtomRegistry, null);
}

export function activateCloudRelayAccount(
accountId: string,
tokenProvider: () => Promise<string | null>,
): void {
setAgentAwarenessRelayTokenProvider(tokenProvider, accountId);
setManagedRelaySession(appAtomRegistry, {
accountId,
readClerkToken: tokenProvider,
});
}

function CloudAuthBridge(props: { readonly children: ReactNode }) {
const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, {
reportFailure: false,
reportDefect: false,
});
const previousTokenProviderRef = useRef<{
readonly userId: string;
readonly provider: () => Promise<string | null>;
} | null>(null);
const observedAccountRef = useRef<string | null | undefined>(undefined);
const accountTransitionRef = useRef<Promise<void> | null>(null);

useEffect(() => {
let cancelled = false;
if (!isLoaded) {
return;
}

const previousObservedAccount = observedAccountRef.current;
const nextAccount = isSignedIn && userId ? userId : null;
observedAccountRef.current = nextAccount;

// Request after activateSession cleanup to avoid listing the previous account; sign-out drops pending requests.
const isAccountTransition =
previousObservedAccount !== undefined && previousObservedAccount !== nextAccount;
if (isAccountTransition && nextAccount === null) {
clearConnectOnboardingRequest();
}

const cleanUpAccount = async (
previous: {
readonly userId: string;
readonly provider: () => Promise<string | null>;
} | null,
accountId: string | null,
) => {
const removal = await removeRelayEnvironments(accountId);
if (removal._tag !== "Success") throw squashAtomCommandFailure(removal);
const cleanup = [
resetManagedRelayTokenCache(),
...(previous
? [
settleAsyncResult(() =>
runtime.runPromiseExit(
unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
),
),
]
: []),
];
const results = await Promise.all(cleanup);
for (const result of results) {
reportAtomCommandResult(result, { label: "cloud account cleanup" });
}
};
const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => {
const previousTransition = accountTransitionRef.current ?? Promise.resolve();
accountTransitionRef.current = previousTransition
.catch(() => {})
.then(() => cleanUpAccount(previous, previousObservedAccount ?? null));
return accountTransitionRef.current;
};

if (!isSignedIn || !userId) {
const previous = previousTokenProviderRef.current;
previousTokenProviderRef.current = null;
deactivateCloudRelayAccount();
if (previousObservedAccount !== null) {
void settlePromise(() => queueAccountCleanup(previous)).then((result) => {
reportAtomCommandResult(result, { label: "cloud account cleanup" });
});
}
return;
}

const previous = previousTokenProviderRef.current;
const tokenProvider = () => getToken(resolveRelayClerkTokenOptions());
const activateSession = () => {
if (cancelled) {
return;
}
previousTokenProviderRef.current = { userId, provider: tokenProvider };
activateCloudRelayAccount(userId, tokenProvider);
if (isAccountTransition) {
requestConnectOnboarding(userId);
}
};
const activateAfterTransition = (transition: Promise<void>) => {
const activation = (async () => {
await transition;
if (cancelled) return;
const storedAccount = await getComposerCloudAccountId();
if (storedAccount !== null && storedAccount !== userId) {
await cleanUpAccount(null, storedAccount);
}
if (cancelled) return;
await restoreCloudComposerDrafts(userId);
activateSession();
})();
accountTransitionRef.current = activation;
void settlePromise(() => activation).then((result) => {
reportAtomCommandResult(result, { label: "cloud account activation" });
});
};
if (
previousObservedAccount !== undefined &&
previousObservedAccount !== null &&
previousObservedAccount !== userId
) {
previousTokenProviderRef.current = null;
deactivateCloudRelayAccount();
activateAfterTransition(queueAccountCleanup(previous));
} else {
activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {}));
}

return () => {
cancelled = true;
};
}, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]);

useEffect(
() => () => {
previousTokenProviderRef.current = null;
// Unmounting does not imply sign-out; preserve registration and lock-screen activities.
releaseAgentAwarenessRelayTokenProvider();
setManagedRelaySession(appAtomRegistry, null);
},
[],
);

return props.children;
import type { ReactNode } from "react";

import { cloudAccountService, deactivateCloudRelayAccount } from "./cloudAccountService";
import { resolveCloudPublicConfig } from "./publicConfig";

const cloudConfig = resolveCloudPublicConfig();
// Build-time constant: null when this build has no cloud config.
const publishableKey = cloudConfig.relay.url ? cloudConfig.clerk.publishableKey : null;

if (publishableKey) {
// The same native singleton ClerkProvider renders with, so the service
// observes it for the app's lifetime without a React subscription.
cloudAccountService.attach(getClerkInstance({ publishableKey }));
} else {
// Without cloud config no account can ever activate, so drop anything a
// previous build with cloud enabled left registered.
deactivateCloudRelayAccount();
}

export function CloudAuthProvider(props: { readonly children: ReactNode }) {
const config = resolveCloudPublicConfig();
const publishableKey = config.clerk.publishableKey;
const relayUrl = config.relay.url;

useEffect(() => {
if (!publishableKey || !relayUrl) {
deactivateCloudRelayAccount();
}
}, [publishableKey, relayUrl]);

if (!publishableKey || !relayUrl) {
if (!publishableKey) {
return props.children;
}

return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<CloudAuthBridge>{props.children}</CloudAuthBridge>
{props.children}
</ClerkProvider>
);
}
108 changes: 108 additions & 0 deletions apps/mobile/src/features/cloud/cloudAccountService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { getClerkInstance } from "@clerk/expo";
import {
createCloudAccountService,
ManagedRelay,
setManagedRelaySession,
} from "@t3tools/client-runtime/relay";
import {
reportAtomCommandResult,
runAtomCommand,
settleAsyncResult,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import * as Effect from "effect/Effect";

import { runtime } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
import {
getComposerCloudAccountId,
restoreCloudComposerDrafts,
} from "../../state/use-composer-drafts";
import {
releaseAgentAwarenessRelayTokenProvider,
setAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { removeCloudEnvironments } from "./cloud-drafts";
import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
import { resolveRelayClerkTokenOptions } from "./publicConfig";

type ClerkInstance = ReturnType<typeof getClerkInstance>;

export function deactivateCloudRelayAccount(): void {
setAgentAwarenessRelayTokenProvider(null);
setManagedRelaySession(appAtomRegistry, null);
}

export function activateCloudRelayAccount(
accountId: string,
tokenProvider: () => Promise<string | null>,
): void {
setAgentAwarenessRelayTokenProvider(tokenProvider, accountId);
setManagedRelaySession(appAtomRegistry, {
accountId,
readClerkToken: tokenProvider,
});
}

function clerkTokenReader(clerk: ClerkInstance): () => Promise<string | null> {
return async () =>
clerk.session ? await clerk.session.getToken(resolveRelayClerkTokenOptions()) : null;
}

const cleanUpCloudAccount = async (
clerk: ClerkInstance,
accountId: string | null,
wasActive: boolean,
) => {
const removal = await runAtomCommand(appAtomRegistry, removeCloudEnvironments, accountId, {
reportFailure: false,
reportDefect: false,
});
if (removal._tag !== "Success") throw squashAtomCommandFailure(removal);
const results = await Promise.all([
settleAsyncResult(() =>
runtime.runPromiseExit(
ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
),
),
...(wasActive
? [
settleAsyncResult(() =>
runtime.runPromiseExit(
unregisterAgentAwarenessDeviceForCurrentUser(clerkTokenReader(clerk)),
),
),
]
: []),
]);
for (const result of results) {
reportAtomCommandResult(result, { label: "cloud account cleanup" });
}
};

export const cloudAccountService = createCloudAccountService<ClerkInstance>({
deactivate: deactivateCloudRelayAccount,
// Detaching is not a sign-out: the user is usually still signed in, so keep
// lock-screen activities and the persisted registration for the next attach.
detach: () => {
releaseAgentAwarenessRelayTokenProvider();
setManagedRelaySession(appAtomRegistry, null);
},
// A not-yet-presented onboarding request belongs to the account that left.
onSignOutTransition: clearConnectOnboardingRequest,
cleanUp: ({ source, accountId, wasActive }) => cleanUpCloudAccount(source, accountId, wasActive),
prepare: async ({ source, accountId, isCurrent }) => {
const storedAccount = await getComposerCloudAccountId();
if (storedAccount !== null && storedAccount !== accountId) {
await cleanUpCloudAccount(source, storedAccount, false);
}
if (!isCurrent()) return;
await restoreCloudComposerDrafts(accountId);
},
activate: ({ source, accountId, isAccountTransition }) => {
activateCloudRelayAccount(accountId, clerkTokenReader(source));
// Issued after cleanup so the sheet never lists the previous account's environments.
if (isAccountTransition) requestConnectOnboarding(accountId);
},
});
4 changes: 3 additions & 1 deletion apps/mobile/src/features/cloud/connectOnboardingOptOut.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { loadPreferences, updatePreferences } from "../../persistence/imperative";

// Keep persistence out of CloudAuthProvider's import graph; it breaks suite loading.
// Lives apart from connectOnboarding.ts so cloudAccountService.ts (which
// imports the request signal) never pulls the persistence adapter into its
// module graph; that breaks CloudAuthProvider.test.ts suite loading.

export async function isConnectOnboardingOptedOut(accountId: string): Promise<boolean> {
const preferences = await loadPreferences();
Expand Down
10 changes: 1 addition & 9 deletions apps/mobile/src/features/cloud/managedRelayState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import { AsyncResult, Atom } from "effect/reactivity";
import { useCallback, useEffect } from "react";
import { useCallback } from "react";

import * as Runtime from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
Expand Down Expand Up @@ -53,14 +53,6 @@ export function useManagedRelayEnvironments() {
: EMPTY_ENVIRONMENTS_ATOM;
const result = useAtomValue(atom);
const snapshot = readManagedRelaySnapshotState(result);
useEffect(() => {
if (snapshot.error) {
console.error("[t3-cloud] Relay environment listing failed", {
message: snapshot.error,
traceId: snapshot.errorTraceId,
});
}
}, [snapshot.error, snapshot.errorTraceId]);
const refresh = useCallback(() => {
if (accountId) {
managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId);
Expand Down
Loading
Loading