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
49 changes: 49 additions & 0 deletions docs/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Known Issues

## `@guildpass/sdk` cannot be resolved in `tests/api.test.ts`

Vite cannot resolve the entry point for the `@guildpass/sdk` package, which
prevents `tests/api.test.ts` from running.

This issue predates the API-layer refactor in issue #218. It was reproduced
against the original repository state after stashing all refactor changes, and
then reproduced again after restoring them.

Resolving the SDK package entry point is outside the scope of refactor #218 and
does not block that work. It should be investigated separately.

## Project typecheck fails on pre-existing JSX syntax errors

Running `tsc --noEmit` fails with multiple JSX syntax errors in:

- `app/access-check.tsx`
- `app/access-scanner.tsx`
- `app/deep-link-error.tsx`
- `app/pending-changes.tsx`
- `app/profile.tsx`

These errors predate this PR. The contents of all five files were verified to
match `upstream/main` byte for byte, and none of the errors are related to the
files changed by this PR.

This does not block the API-layer refactor, but the
`pnpm typecheck passes` checklist item cannot be marked honestly until the
base-branch errors are resolved separately.

## GuildPass SDK does not support app session token injection

The GuildPass SDK does not currently expose a mechanism to inject the app's
session token or refresh it. Current Guilds SDK requests are public and
anonymous, so this does not block the Guilds migration pilot.

If an SDK endpoint requires app session authentication in the future, the SDK
or the `guildpassClient.ts` integration will need to be extended to accept and
refresh session credentials.

## Refactor #218 - Progress

- Guilds: migrated to the centralized service layer and verified.
- Membership: pending.
- Access: pending.
- Notifications: pending.
- Attestation: pending.
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@
"react-native-passkeys": "^0.4.1",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"react-native-get-random-values": "^1.11.0",
"react-native-passkeys": "^0.4.1",
"react-native-webview": "13.6.4",
"viem": "^2.55.2",
"zod": "^3.23.8",
Expand Down
24 changes: 8 additions & 16 deletions src/features/guilds/useGuilds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { onlineManager, useQuery, useQueryClient, type QueryClient } from "@tans
import { guildPassClient } from "../../lib/guildpassClient";
import { appConfig } from "../../config/appConfig";
import { queryKeys } from "../../lib/queryKeys";
import {
GuildNotFoundError,
guildsService,
} from "../../services/guilds/guildsService";
import { getCachedMembershipSummaries, type GuildPassStatus } from "../passes/passCache";

export type GuildListItem = {
Expand All @@ -13,12 +17,7 @@ export type GuildListItem = {
lastSyncedAt?: number;
};

export class GuildNotFoundError extends Error {
constructor(guildId: string) {
super(`Guild not found: ${guildId}`);
this.name = "GuildNotFoundError";
}
}
export { GuildNotFoundError };

export const walletGuildsQueryKey = (walletAddress: string | null | undefined) =>
queryKeys.walletGuilds.byWallet(walletAddress ?? "");
Expand Down Expand Up @@ -107,14 +106,7 @@ export const useGuilds = () => {
return cached as any;
}

try {
return await guildPassClient.guilds.getGuild({ guildId });
} catch (error) {
if (error instanceof Error && /not found/i.test(error.message)) {
throw new GuildNotFoundError(guildId);
}
throw error;
}
return guildsService.getGuild(guildId);
},
enabled: !!guildId,
networkMode: "offlineFirst",
Expand All @@ -133,7 +125,7 @@ export const useGuilds = () => {
return cached as any;
}

return guildPassClient.guilds.getGuildConfig({ guildId });
return guildsService.getGuildConfig(guildId);
},
enabled: !!guildId,
networkMode: "offlineFirst",
Expand All @@ -152,7 +144,7 @@ export const useGuilds = () => {
return cached as any;
}

return guildPassClient.roles.getRoles({ guildId });
return guildsService.getRoles(guildId);
},
enabled: !!guildId,
networkMode: "offlineFirst",
Expand Down
13 changes: 11 additions & 2 deletions src/features/session/session.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { SessionAdapter } from "./session.types";

/**
* No-op adapter β€” wallet connected state is treated as authenticated.
* Replace with a real SIWE or backend adapter when backend support is ready.
* Lightweight adapter that keeps the app's current behavior while exposing the
* contract required by the centralized API client.
*/
export const noopSessionAdapter: SessionAdapter = {
async getAccessToken() {
return null;
},
async signIn(walletAddress) {
return { token: `noop:${walletAddress}`, expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000 };
},
Expand All @@ -14,4 +17,10 @@ export const noopSessionAdapter: SessionAdapter = {
async signOut(_token) {
// nothing to do
},
async invalidateSession() {
// nothing to do
},
isAuthenticated() {
return false;
},
};
3 changes: 3 additions & 0 deletions src/features/session/session.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export interface Session {

/** Adapter interface β€” implement to add SIWE, WalletConnect auth, or backend sessions */
export interface SessionAdapter {
getAccessToken?(): Promise<string | null>;
signIn(walletAddress: string): Promise<{ token: string; expiresAt: number }>;
refresh(token: string): Promise<{ token: string; expiresAt: number }>;
signOut(token: string): Promise<void>;
invalidateSession?(): Promise<void>;
isAuthenticated?(): boolean;
}
5 changes: 4 additions & 1 deletion src/lib/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ export const queryClient = new QueryClient({
}),
defaultOptions: {
queries: {
retry: 2,
retry: 1,
staleTime: QUERY_STALE_TIME_MS,
gcTime: QUERY_GC_TIME_MS,
networkMode: "offlineFirst",
},
mutations: {
retry: false,
},
},
});
133 changes: 133 additions & 0 deletions src/services/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ApiError } from "./errors";
import { applyAuthInterceptor, type AuthConfig } from "./interceptors/authInterceptor";
import { defaultRetryConfig, retryWithBackoff, type RetryConfig } from "./retry";
import { parseJsonResponse } from "./response";

export interface ApiClientConfig {
baseUrl: string;
timeoutMs?: number;
headers?: Record<string, string>;
auth?: AuthConfig;
retryConfig?: RetryConfig;
feature?: string;
}

export interface RequestOptions {
path: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
headers?: Record<string, string>;
parseJson?: boolean;
feature?: string;
operation?: string;
}

function buildUrl(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
}

function buildHeaders(headers: Record<string, string> | undefined, authHeader?: string): HeadersInit {
const merged: Record<string, string> = {
"content-type": "application/json",
...(headers ?? {}),
};

if (authHeader) {
merged.Authorization = authHeader;
}

return merged;
}

export function createApiClient(config: ApiClientConfig) {
const timeoutMs = config.timeoutMs ?? 10000;
const retryConfig = config.retryConfig ?? defaultRetryConfig;

const request = async <T = unknown>(options: RequestOptions): Promise<T> => {
const url = buildUrl(config.baseUrl, options.path);
const feature = options.feature ?? config.feature;
const operation = options.operation;

const requestFn = async (token: string | null) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try {
const headers = buildHeaders(options.headers, token ?? undefined);

const response = await fetch(url, {
method: options.method ?? "GET",
headers,
body: options.body != null ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});

clearTimeout(timeoutId);

if (!response.ok) {
const errorPayload = await parseJsonResponse<unknown>(response);
const errorMessage =
typeof errorPayload.data === "object" && errorPayload.data != null && "message" in errorPayload.data
? String((errorPayload.data as { message?: string }).message)
: response.statusText || "Request failed";

throw new ApiError({
code: response.status === 401 ? "unauthorized" : response.status === 403 ? "forbidden" : response.status === 404 ? "not_found" : response.status >= 500 ? "server" : "unknown",
message: errorMessage,
userMessage: errorMessage,
status: response.status,
retryable: response.status === 429 || (response.status >= 500 && response.status < 600),
cause: errorPayload.data,
feature,
operation,
});
}

const parsed = await parseJsonResponse<T>(response);
return parsed.data;
} catch (error) {
clearTimeout(timeoutId);

if (error instanceof ApiError) {
throw error;
}

const normalizedError = error instanceof Error && error.name === "AbortError"
? new ApiError({
code: "timeout",
message: "Request timed out",
userMessage: "The request timed out. Please try again.",
status: undefined,
retryable: true,
cause: error,
feature,
operation,
})
: new ApiError({
code: "network",
message: error instanceof Error ? error.message : "Network request failed",
userMessage: "We could not complete the request. Please try again.",
retryable: true,
cause: error,
feature,
operation,
});

throw normalizedError;
}
};

return retryWithBackoff(
() =>
applyAuthInterceptor(
requestFn,
{ auth: config.auth },
feature,
operation,
),
retryConfig,
);
};

return { request };
}
Loading