@kaiz11/stack-client
v0.0.15
Published
A standalone TypeScript client for Supabase stack services. Supports both platform-level and tenant-level operations with automatic token management.
Readme
@kaiz11/stack-client
A standalone TypeScript client for Supabase stack services. Supports both platform-level and tenant-level operations with automatic token management.
Installation
pnpm add @kaiz11/stack-clientQuick Start
import { createTenantClient } from "@kaiz11/stack-client";
const client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "my-tenant",
});
// Sign in
await client.auth.signInWithPassword({
email: "[email protected]",
password: "password123",
});
// Access the user
const user = client.auth.getUser();Client Modes
Tenant Mode
For applications serving a single tenant. Endpoints use /auth/{tenantId}/*.
import { createTenantClient } from "@kaiz11/stack-client";
const client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "acme-corp",
});Platform Mode
For platform-level operations (admin dashboards, multi-tenant management). Endpoints use /auth/_platform/*.
import { createPlatformClient } from "@kaiz11/stack-client";
const client = createPlatformClient({
baseUrl: "https://stack.example.com",
});Generic Mode
When you need to specify the mode dynamically, use createClient() with tenantId for tenant mode, or without it for platform mode:
import { createClient } from "@kaiz11/stack-client";
// Tenant mode (tenantId present)
const tenantClient = createClient({
baseUrl: "https://stack.example.com",
tenantId: "acme-corp",
});
// Platform mode (no tenantId)
const platformClient = createClient({
baseUrl: "https://stack.example.com",
});Configuration
interface ClientConfig {
/** Base URL of the stack server */
baseUrl: string;
/** Token storage type: "memory" | "localStorage" | custom TokenStore */
tokenStore?: TokenStoreType;
/** Storage key prefix (default: "stack") */
storagePrefix?: string;
/** Request timeout in milliseconds (default: 30000) */
timeout?: number;
/** Enable mock mode for testing (no HTTP requests) */
mock?: boolean;
/** Mock options (latency simulation, etc.) */
mockOptions?: MockOptions;
}
interface TenantClientConfig extends ClientConfig {
/** Tenant identifier */
tenantId: string;
}Token Storage
Tokens need to be stored somewhere. The client supports pluggable storage backends:
Memory Store (Default)
Tokens are stored in memory. Lost on page refresh. Good for server-side usage.
import { createTenantClient, MemoryTokenStore } from "@kaiz11/stack-client";
const client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "my-tenant",
tokenStore: new MemoryTokenStore(), // This is the default
});LocalStorage Store
Tokens persist in browser localStorage. Survives page refreshes.
import {
createTenantClient,
LocalStorageTokenStore,
} from "@kaiz11/stack-client";
const client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "my-tenant",
tokenStore: new LocalStorageTokenStore("my-app-auth"),
});Custom Store
Implement the TokenStore interface for custom backends (cookies, IndexedDB, etc.):
import { TokenStore } from "@kaiz11/stack-client";
class CookieTokenStore implements TokenStore {
getAccessToken(): string | null {
return getCookie("access_token");
}
getRefreshToken(): string | null {
return getCookie("refresh_token");
}
setTokens(accessToken: string, refreshToken: string): void {
setCookie("access_token", accessToken, { httpOnly: true, secure: true });
setCookie("refresh_token", refreshToken, { httpOnly: true, secure: true });
}
clearTokens(): void {
deleteCookie("access_token");
deleteCookie("refresh_token");
}
}Cleanup
When done with the client, call destroy() to clear auto-refresh timers:
const client = createTenantClient({ ... });
// When cleaning up (e.g., component unmount)
client.destroy();Server-Side Usage
The same client works on both frontend and backend. For server-side usage, use tokenStore: "memory" and pass accessToken directly.
Note: The client does not verify tokens passed via
accessToken. It assumes the token is valid and uses it for outgoing API calls. Verification happens server-side when the request reaches PostgREST, Storage, or other backend services. If you need to verify incoming requests, use the Auth Middleware.
Service Role (Admin Access)
Use a service role token for admin operations that bypass RLS:
import { createTenantClient } from "@kaiz11/stack-client";
const adminClient = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "acme-corp",
tokenStore: "memory",
accessToken: process.env.SERVICE_ROLE_TOKEN,
});
// Admin operations - bypasses RLS
await adminClient.storage.from("uploads").upload("file.pdf", data);
await adminClient.storage.from("private").list(); // Can access all filesUser Context (Respects RLS)
Pass the user's access token (from a verified request) to make calls scoped to that user:
import { createTenantClient } from "@kaiz11/stack-client";
// In your API handler, after verifying the request
const userClient = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "acme-corp",
tokenStore: "memory",
accessToken: userAccessToken, // From verified JWT
});
// User-scoped operations - respects RLS
await userClient.storage.from("user-files").list(); // Only sees their files
await userClient.accounts.list(); // Only sees their accountsAuth Middleware (Verifying Incoming Requests)
Use the auth middleware to verify JWTs on incoming requests. The middleware automatically fetches the JWKS from GoTrue for ES256 verification.
import { Hono } from "hono";
import { createStackAuthMiddleware } from "@kaiz11/stack-client/auth/server";
const app = new Hono();
// For tenant APIs
app.use(
"*",
createStackAuthMiddleware({
baseUrl: "https://stack.example.com",
tenantId: "acme-corp",
excludePaths: [/^\/health$/], // Optional: skip auth for these paths
}),
);
// For platform APIs
app.use(
"*",
createStackAuthMiddleware({
baseUrl: "https://stack.example.com",
// No tenantId = platform mode
}),
);
// Access verified user in handlers
app.get("/api/me", (c) => {
const user = c.get("user"); // { id, email, role, aal, ... }
const token = c.get("accessToken"); // Raw JWT for downstream calls
return c.json({ user });
});Optional Auth Middleware
For routes that work with or without authentication:
import { optionalStackAuthMiddleware } from "@kaiz11/stack-client/auth/server";
app.use(
"*",
optionalStackAuthMiddleware({
baseUrl: "https://stack.example.com",
tenantId: "acme-corp",
}),
);
app.get("/api/feed", (c) => {
const user = c.get("user"); // undefined if not authenticated
if (user) {
return c.json({ feed: getPersonalizedFeed(user.id) });
}
return c.json({ feed: getPublicFeed() });
});Role & MFA Guards
Add additional guards after the auth middleware:
import {
createStackAuthMiddleware,
requireRoleMiddleware,
requireMfaMiddleware,
} from "@kaiz11/stack-client/auth/server";
// All routes require authentication
app.use("*", createStackAuthMiddleware({ baseUrl, tenantId }));
// Admin routes require service_role
app.use("/admin/*", requireRoleMiddleware("service_role"));
// Sensitive routes require MFA (AAL2)
app.use("/settings/security/*", requireMfaMiddleware());Complete Server Example
import { Hono } from "hono";
import { createTenantClient } from "@kaiz11/stack-client";
import { createStackAuthMiddleware } from "@kaiz11/stack-client/auth/server";
const app = new Hono();
const baseUrl = "https://stack.example.com";
const tenantId = "acme-corp";
// Verify incoming requests
app.use("/api/*", createStackAuthMiddleware({ baseUrl, tenantId }));
// User uploads a file (respects RLS)
app.post("/api/upload", async (c) => {
const userClient = createTenantClient({
baseUrl,
tenantId,
tokenStore: "memory",
accessToken: c.get("accessToken"),
});
const file = await c.req.blob();
const { data, error } = await userClient.storage
.from("uploads")
.upload(`${c.get("user").id}/file.pdf`, file);
return c.json({ data, error });
});
// Admin generates a report (bypasses RLS)
app.post("/api/admin/report", async (c) => {
const adminClient = createTenantClient({
baseUrl,
tenantId,
tokenStore: "memory",
accessToken: process.env.SERVICE_ROLE_TOKEN,
});
// Can access all users' files
const { data } = await adminClient.storage.from("uploads").list();
return c.json({ files: data });
});
export default app;Testing
The client includes built-in mock mode for testing without HTTP requests.
Mock Mode
Enable mock mode for unit tests or local development:
const client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "test-tenant",
mock: true,
});
// All auth methods work without network requests
await client.auth.signIn({ email: "[email protected]", password: "password" });
const user = client.auth.getUser(); // Returns mock userSimulating Latency
Add artificial delay to simulate network conditions:
const client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "test-tenant",
mock: true,
mockOptions: {
latency: 100, // 100ms delay on all operations
},
});Controlling Mock Behavior
Use mockState to simulate errors and edge cases:
import { createTenantClient, mockState } from "@kaiz11/stack-client";
const client = createTenantClient({ ..., mock: true });
// Simulate sign-in failure
mockState.shouldFailSignIn = true;
await client.auth.signIn({ email, password }); // Throws AuthError
// Simulate sign-up failure (user exists)
mockState.shouldFailSignUp = true;
await client.auth.signUp({ email, password }); // Throws AuthError
// Simulate token refresh failure
mockState.shouldFailRefresh = true;
await client.auth.refreshSession(); // Throws AuthError
// Reset to default behavior
mockState.reset();Tracking Request Counts
Use requestCounts to verify operations were called:
import { createTenantClient, requestCounts } from "@kaiz11/stack-client";
const client = createTenantClient({ ..., mock: true });
await client.auth.signIn({ email, password });
console.log(requestCounts.signIn); // 1
await client.auth.signOut();
console.log(requestCounts.signOut); // 1
// Reset counts between tests
requestCounts.reset();Vitest Example
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createTenantClient,
mockState,
requestCounts,
} from "@kaiz11/stack-client";
describe("auth", () => {
let client: ReturnType<typeof createTenantClient>;
beforeEach(() => {
client = createTenantClient({
baseUrl: "https://stack.example.com",
tenantId: "test",
mock: true,
});
});
afterEach(() => {
client.destroy();
mockState.reset();
requestCounts.reset();
});
it("signs in user", async () => {
await client.auth.signIn({ email: "[email protected]", password: "pass" });
expect(client.auth.isAuthenticated()).toBe(true);
expect(requestCounts.signIn).toBe(1);
});
it("handles sign-in error", async () => {
mockState.shouldFailSignIn = true;
await expect(
client.auth.signIn({ email: "[email protected]", password: "wrong" }),
).rejects.toThrow();
});
});Known Limitations
Some client features require backend configuration or are not yet supported by the self-hosted Stack platform.
Features Requiring Backend Configuration
| Feature | Requirement | Status |
| --------------------------- | --------------------------------------------- | ---------------------------- |
| Phone/SMS Auth | Twilio configuration in GoTrue | Not configured |
| Manual Identity Linking | GOTRUE_SECURITY_MANUAL_LINKING_ENABLED=true | Disabled by default |
| TUS Resumable Uploads | Storage service TUS endpoint | Returns 500 (platform issue) |
Features Requiring Browser Interaction
These features work correctly but cannot be fully automated in tests:
| Feature | Reason | | -------------------------- | ------------------------------------- | | OAuth Sign-In | Requires browser redirect to provider | | OAuth Identity Linking | Requires browser OAuth flow | | PKCE OAuth Flow | Requires browser for code exchange |
API Differences from Official Supabase
| Method | Difference |
| ------------------------ | ------------------------------------------------- |
| auth.mfa.listFactors() | Returns 405 (endpoint not available in GoTrue) |
| auth.reauthenticate() | Sends OTP email, returns void (not { nonce }) |
Workarounds
For reauthenticate(): The OTP sent to email should be used as the nonce parameter in updatePassword():
// Request reauthentication (sends OTP to email)
await client.auth.reauthenticate();
// User receives OTP via email, enters it
const otp = "123456"; // from email
// Use OTP as nonce for password update
await client.auth.updatePassword({
password: "new-password",
nonce: otp,
});Modules
| Module | Description | Documentation |
| ----------- | --------------------------------------------------- | ------------------------------------------- |
| auth | Authentication (sign in, sign up, sign out, tokens) | Auth README |
| accounts | Multi-tenant accounts (teams, roles, members) | Accounts README |
| storage | File storage (buckets, uploads, signed URLs, TUS) | Storage README |
| functions | Edge functions | Coming soon |
| realtime | Realtime subscriptions | Coming soon |
TypeScript Types
import type {
// Client
StackClient,
ClientConfig,
TenantClientConfig,
PlatformClientConfig,
ClientMode,
// Token storage
TokenStore,
TokenStoreType,
// Mock mode
MockOptions,
MockState,
RequestCounts,
} from "@kaiz11/stack-client";License
Copyright © 2026 Kai Zhao. All rights reserved.
This software is proprietary and confidential. Unauthorized copying, distribution, modification, or use of this software, via any medium, is strictly prohibited without prior written permission from the copyright holder.
