@startsimpli/auth 0.4.15 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,54 +1,38 @@
1
1
  # @startsimpli/auth
2
2
 
3
- Shared authentication package for StartSimpli Next.js applications. Provides JWT-based authentication with Django backend integration.
4
-
5
- ## Features
6
-
7
- - JWT access token authentication
8
- - Automatic token refresh with refresh tokens (httpOnly cookies)
9
- - React Context and hooks for client-side auth
10
- - Server-side session validation for SSR and API routes
11
- - Next.js middleware helpers
12
- - Auth guards for API routes
13
- - Permission/role checks (owner > admin > member > viewer)
14
- - TypeScript support with full type safety
15
-
16
- ## Installation
17
-
18
- This package is part of the StartSimpli monorepo. It uses NPM workspaces and TypeScript path aliases for direct source imports during development.
19
-
20
- ```bash
21
- # In your Next.js app's package.json
22
- {
23
- "dependencies": {
24
- "@startsimpli/auth": "*"
25
- }
26
- }
27
- ```
28
-
29
- ## Django Backend Integration
30
-
31
- This package is designed to work with the StartSimpli Django API (`start-simpli-api`).
32
-
33
- **Required Django endpoints:**
34
- - `POST /api/v1/auth/token/` - Login (returns JWT access token + sets refresh token cookie)
35
- - `POST /api/v1/auth/token/refresh/` - Refresh access token using cookie
36
- - `POST /api/v1/auth/logout/` - Logout
37
- - `GET /api/v1/auth/me/` - Get current user data
38
-
39
- **Token flow:**
40
- 1. Login returns `{ access: "jwt-token", user: {...} }`
41
- 2. Refresh token stored as httpOnly cookie (managed by Django)
42
- 3. Access token stored in memory (not localStorage)
43
- 4. Automatic refresh before expiration (default: 4 minutes)
44
-
45
- ## Usage
46
-
47
- ### Client-Side Authentication
48
-
49
- #### 1. Setup Auth Provider
50
-
51
- Wrap your app with `AuthProvider` in your root layout:
3
+ Shared authentication for every StartSimpli frontend. JWT lifecycle, React
4
+ context, an `authFetch` with transparent 401-retry, Next.js server helpers,
5
+ session-storage adapters for web **and** React Native, and an in-memory mock
6
+ backend for tests/demos.
7
+
8
+ Drives auth in `raise-simpli`, `market-simpli`, `trade-simpli`, `vault-web`,
9
+ and the `examples/mobile-rn` Expo demo on the in-progress
10
+ `react-native-enablement` branch.
11
+
12
+ ## Subpath exports
13
+
14
+ The package ships TypeScript source consumers bundle it directly. The
15
+ `package.json` `exports` map is the contract:
16
+
17
+ | Import | Use from | Notes |
18
+ |---|---|---|
19
+ | `@startsimpli/auth` | anywhere (client-safe) | Re-exports `./client` + `./types` + `./utils` (no `next/headers`). |
20
+ | `@startsimpli/auth/client` | browser / RN | `AuthProvider`, `useAuth`, `authFetch`, session-storage factories, mock backend. |
21
+ | `@startsimpli/auth/server` | Next.js server | `getServerSession`, `requireAuth`, `createAuthMiddleware`, `withAuth`, `withRole`, `getTokenFromRequest`. Uses `next/headers` — never import from a client component. |
22
+ | `@startsimpli/auth/token` | React Native | DOM-free `TokenAuthClient` + `SecureTokenStorage` (Keychain/Keystore via `expo-secure-store`). |
23
+ | `@startsimpli/auth/components` | browser | `GoogleSignInButton`, `OAuthCallback`, `useOAuthCallback`, `OAuthConnectionCard`. |
24
+ | `@startsimpli/auth/email` | server | `createEmailService` (Resend, optional peer). |
25
+ | `@startsimpli/auth/types` | anywhere | Shared types — `Session`, `AuthUser`, `AuthConfig`, `CompanyRole`. |
26
+
27
+ > The native/web split for storage is resolved by **Metro file extensions**
28
+ > (`secure-session-storage.native.ts` / `secure-token-storage.native.ts`), not a
29
+ > `./native` subpath. Import the same `createSecureSessionStorage` /
30
+ > `SecureTokenStorage` from `@startsimpli/auth/client` (or `/token`) and the
31
+ > bundler picks the right file. `expo-secure-store` is an optional peer; when
32
+ > the native module is missing, both adapters degrade to in-memory storage
33
+ > instead of crashing.
34
+
35
+ ## Quick start (Next.js)
52
36
 
53
37
  ```tsx
54
38
  // app/layout.tsx
@@ -63,9 +47,7 @@ export default function RootLayout({ children }) {
63
47
  <AuthProvider
64
48
  config={{
65
49
  apiBaseUrl: process.env.NEXT_PUBLIC_API_URL!,
66
- onSessionExpired: () => {
67
- window.location.href = '/login';
68
- },
50
+ loginPath: '/auth/signin',
69
51
  }}
70
52
  >
71
53
  {children}
@@ -76,409 +58,241 @@ export default function RootLayout({ children }) {
76
58
  }
77
59
  ```
78
60
 
79
- #### 2. Use Authentication Hook
80
-
81
61
  ```tsx
82
- // app/dashboard/page.tsx
62
+ // any client component
83
63
  'use client';
84
64
 
85
65
  import { useAuth } from '@startsimpli/auth/client';
86
66
 
87
- export default function DashboardPage() {
88
- const { user, isLoading, isAuthenticated, logout } = useAuth();
89
-
90
- if (isLoading) {
91
- return <div>Loading...</div>;
92
- }
93
-
94
- if (!isAuthenticated) {
95
- return <div>Please login</div>;
96
- }
97
-
67
+ export function Header() {
68
+ const { user, isAuthenticated, logout } = useAuth();
69
+ if (!isAuthenticated) return null;
98
70
  return (
99
71
  <div>
100
- <h1>Welcome, {user.firstName}!</h1>
101
- <button onClick={logout}>Logout</button>
72
+ Hi {user!.firstName} · <button onClick={logout}>Sign out</button>
102
73
  </div>
103
74
  );
104
75
  }
105
76
  ```
106
77
 
107
- #### 3. Login Form
78
+ ## `authFetch` token attach + 401-retry + base-URL resolution
108
79
 
109
- ```tsx
110
- // app/login/page.tsx
111
- 'use client';
80
+ `authFetch` is the only HTTP helper every app should use for authenticated
81
+ calls. It:
112
82
 
113
- import { useState } from 'react';
114
- import { useAuth } from '@startsimpli/auth/client';
115
- import { useRouter } from 'next/navigation';
116
-
117
- export default function LoginPage() {
118
- const { login } = useAuth();
119
- const router = useRouter();
120
- const [email, setEmail] = useState('');
121
- const [password, setPassword] = useState('');
83
+ 1. Pulls the current access token from storage (see below) and attaches
84
+ `Authorization: Bearer <token>` if no header is set already.
85
+ 2. Resolves relative URLs against `NEXT_PUBLIC_API_URL` (or
86
+ `NEXT_PUBLIC_API_BASE_URL`) via `resolveAuthUrl`.
87
+ 3. Sets `credentials: 'include'` by default so the refresh cookie travels.
88
+ 4. On `401`, calls `refreshAccessToken()` **once** (concurrent 401s share one
89
+ refresh promise) and retries the original request with the new token.
90
+ 5. If the refresh fails or the retried request is still `401`, clears the
91
+ stored token and fires `notifySessionExpired()` — which calls the callback
92
+ registered via `setOnSessionExpired` (wired automatically by
93
+ `AuthProvider`).
122
94
 
123
- const handleSubmit = async (e) => {
124
- e.preventDefault();
125
-
126
- try {
127
- await login(email, password);
128
- router.push('/dashboard');
129
- } catch (error) {
130
- console.error('Login failed:', error);
131
- }
132
- };
95
+ ```ts
96
+ import { authFetch } from '@startsimpli/auth/client';
133
97
 
134
- return (
135
- <form onSubmit={handleSubmit}>
136
- <input
137
- type="email"
138
- value={email}
139
- onChange={(e) => setEmail(e.target.value)}
140
- placeholder="Email"
141
- />
142
- <input
143
- type="password"
144
- value={password}
145
- onChange={(e) => setPassword(e.target.value)}
146
- placeholder="Password"
147
- />
148
- <button type="submit">Login</button>
149
- </form>
150
- );
98
+ const res = await authFetch('/api/v1/me/'); // resolved to NEXT_PUBLIC_API_URL
99
+ if (res.ok) {
100
+ const me = await res.json();
151
101
  }
152
102
  ```
153
103
 
154
- #### 4. Permission Checks
104
+ `@startsimpli/api`'s `FetchWrapper` routes its own 401-after-refresh through
105
+ the same `notifySessionExpired` sink so every consumer hits a single redirect
106
+ path.
155
107
 
156
- ```tsx
157
- 'use client';
108
+ ### Refresh-token classification
158
109
 
159
- import { usePermissions } from '@startsimpli/auth/client';
110
+ `refreshAccessToken` distinguishes "session is dead" from "backend is sick":
160
111
 
161
- export default function SettingsPage() {
162
- const { isAdmin, canEdit, currentRole } = usePermissions();
112
+ | Status | Result |
113
+ |---|---|
114
+ | `401` / `403` / `400` | Clear the stored token, return `null`. |
115
+ | `5xx` / network error | Throw `TransientRefreshError`. Do **NOT** log the user out — the access token still lives, callers should surface a retry. |
116
+ | `200` | Return the new access token; persist via `setAccessToken`. |
163
117
 
164
- return (
165
- <div>
166
- <h1>Settings</h1>
167
- <p>Your role: {currentRole}</p>
168
-
169
- {isAdmin() && (
170
- <button>Admin Settings</button>
171
- )}
172
-
173
- {canEdit() ? (
174
- <button>Edit</button>
175
- ) : (
176
- <p>View only</p>
177
- )}
178
- </div>
179
- );
118
+ ## Storage adapters (`SessionStorage`)
119
+
120
+ Backends that own their session (the mock backend, the React Native
121
+ `TokenAuthClient`, offline-first clients) need somewhere to persist it across
122
+ reloads/app restarts. The shared contract:
123
+
124
+ ```ts
125
+ interface SessionStorage {
126
+ load(): Promise<Session | null>;
127
+ save(session: Session): Promise<void>;
128
+ clear(): Promise<void>;
180
129
  }
181
130
  ```
182
131
 
183
- ### Server-Side Authentication
132
+ Factories in `@startsimpli/auth/client`:
184
133
 
185
- #### 1. Protect Server Components
134
+ - `createMemorySessionStorage()` the safe default for SSR + tests.
135
+ - `createWebSessionStorage({ key?, storage? })` — `localStorage` by default;
136
+ silently falls back to memory when no Storage is available.
137
+ - `createSecureSessionStorage(key?)` — **Metro-resolved**. On the web it
138
+ delegates to `createWebSessionStorage`; on React Native it persists to the
139
+ iOS Keychain / Android Keystore via `expo-secure-store`. Falls back to
140
+ in-memory when the native module isn't in the build.
141
+ - `createRememberAwareSessionStorage(persistent, shouldRemember, transient?)`
142
+ — wraps two storages. When `shouldRemember()` is true at save time, the
143
+ session goes to `persistent` and `transient` is cleared; otherwise the
144
+ reverse. `load` always reads `persistent`, so a session restores only if
145
+ the last save was "remembered".
186
146
 
187
- ```tsx
188
- // app/dashboard/page.tsx
189
- import { getServerSession } from '@startsimpli/auth/server';
190
- import { redirect } from 'next/navigation';
147
+ The web Django client doesn't take a `SessionStorage` at all — its refresh
148
+ token lives in an httpOnly cookie managed by the browser. The token-mode
149
+ client (`@startsimpli/auth/token`) is the one that needs storage on RN.
191
150
 
192
- export default async function DashboardPage() {
193
- const session = await getServerSession(process.env.API_BASE_URL!);
151
+ ### React Native parity (`TokenAuthClient`)
194
152
 
195
- if (!session) {
196
- redirect('/login');
197
- }
153
+ ```ts
154
+ // On RN (Expo)
155
+ import { TokenAuthClient, SecureTokenStorage } from '@startsimpli/auth/token';
198
156
 
199
- return (
200
- <div>
201
- <h1>Welcome, {session.user.firstName}!</h1>
202
- </div>
203
- );
204
- }
157
+ const auth = new TokenAuthClient({
158
+ apiBaseUrl: 'https://api.example.com',
159
+ storage: new SecureTokenStorage(),
160
+ });
205
161
  ```
206
162
 
207
- #### 2. Next.js Middleware
163
+ This is the same lifecycle as the web `AuthClient` (login, refresh, logout,
164
+ getCurrentUser) but DOM-free: no cookies, no `window`/`document`. It opts
165
+ into the backend's token mode via `X-Auth-Mode: token` and persists the
166
+ refresh token through the injected `TokenStorage`. See
167
+ `examples/mobile-rn/App.tsx` for the wiring used by the Expo demo.
208
168
 
209
- Protect routes with authentication middleware:
169
+ ## Server helpers (Next.js)
210
170
 
211
171
  ```ts
212
172
  // middleware.ts
213
173
  import { createAuthMiddleware } from '@startsimpli/auth/server';
214
174
 
215
175
  export const middleware = createAuthMiddleware({
216
- apiBaseUrl: process.env.API_BASE_URL!,
217
- publicPaths: ['/login', '/register', '/forgot-password'],
218
- loginPath: '/login',
176
+ loginPath: '/auth/signin',
177
+ publicPaths: ['/auth/signin', '/auth/signup', '/auth/forgot-password'],
219
178
  });
220
179
 
221
180
  export const config = {
222
- matcher: [
223
- '/((?!api|_next/static|_next/image|favicon.ico).*)',
224
- ],
181
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
225
182
  };
226
183
  ```
227
184
 
228
- #### 3. API Route Protection
185
+ The middleware uses a two-tier check: it lets the request through if either
186
+ a fresh access cookie (`auth_session` / `access_token`) is present OR a
187
+ `refresh_token` cookie is present (the client will refresh on mount). This
188
+ avoids the every-30-minute redirect dance when the access JWT expires
189
+ mid-session (see raise-simpli-qcw).
229
190
 
230
- Protect API routes with auth guards:
191
+ ### Route-handler guards
231
192
 
232
193
  ```ts
233
- // app/api/users/route.ts
234
- import { NextRequest } from 'next/server';
194
+ // app/api/me/route.ts
195
+ import { NextResponse, type NextRequest } from 'next/server';
235
196
  import { withAuth } from '@startsimpli/auth/server';
236
197
 
237
- export const GET = withAuth(async (request: NextRequest, token: string) => {
238
- const response = await fetch(`${process.env.API_BASE_URL}/api/v1/users/`, {
239
- headers: {
240
- Authorization: `Bearer ${token}`,
241
- },
198
+ export const GET = withAuth(async (req: NextRequest, token: string) => {
199
+ const res = await fetch(`${process.env.API_BASE_URL}/api/v1/auth/me/`, {
200
+ headers: { Authorization: `Bearer ${token}` },
242
201
  });
243
-
244
- const data = await response.json();
245
- return NextResponse.json(data);
202
+ return NextResponse.json(await res.json());
246
203
  });
247
204
  ```
248
205
 
249
- #### 4. Role-Based API Protection
206
+ `withRole(requiredRole, getUserRole, handler)` adds an `owner > admin >
207
+ member > viewer` check on top. Both extract the token via
208
+ `getRequestToken(req)` (NextRequest).
250
209
 
251
- ```ts
252
- // app/api/admin/users/route.ts
253
- import { NextRequest } from 'next/server';
254
- import { withRole } from '@startsimpli/auth/server';
210
+ ### Framework-agnostic token extraction
255
211
 
256
- async function getUserRole() {
257
- // Fetch user's current role from your API
258
- return 'admin';
259
- }
212
+ `getTokenFromRequest(req: Request)` works on any `Request`-compatible
213
+ object (Node, Edge, Bun). It checks `Authorization: Bearer ...` then falls
214
+ back to an `access_token` cookie. It returns the raw token string without
215
+ expiry validation — used by `vault-web` API routes that hand the token
216
+ straight to a downstream Django call.
260
217
 
261
- export const GET = withRole(
262
- 'admin',
263
- getUserRole,
264
- async (request: NextRequest, token: string) => {
265
- // Only accessible to admins
266
- return NextResponse.json({ message: 'Admin data' });
267
- }
268
- );
269
- ```
270
-
271
- ### Making Authenticated API Calls
218
+ ### Server components
272
219
 
273
220
  ```tsx
274
- 'use client';
275
-
276
- import { useAuth } from '@startsimpli/auth/client';
277
-
278
- export default function DataFetcher() {
279
- const { getAccessToken } = useAuth();
280
-
281
- const fetchData = async () => {
282
- const token = await getAccessToken();
283
-
284
- if (!token) {
285
- console.error('No access token');
286
- return;
287
- }
288
-
289
- const response = await fetch('/api/data', {
290
- headers: {
291
- Authorization: `Bearer ${token}`,
292
- },
293
- });
294
-
295
- return response.json();
296
- };
297
-
298
- return <button onClick={fetchData}>Fetch Data</button>;
299
- }
300
- ```
301
-
302
- ## API Reference
303
-
304
- ### Client Exports
305
-
306
- #### `AuthProvider`
307
-
308
- React context provider for authentication.
309
-
310
- ```tsx
311
- interface AuthProviderProps {
312
- children: ReactNode;
313
- config: AuthConfig;
314
- initialSession?: Session | null;
315
- }
316
- ```
317
-
318
- #### `useAuth()`
319
-
320
- Hook to access authentication state and methods.
321
-
322
- ```tsx
323
- interface UseAuthReturn {
324
- user: AuthUser | null;
325
- session: Session | null;
326
- isLoading: boolean;
327
- isAuthenticated: boolean;
328
- login: (email: string, password: string) => Promise<void>;
329
- logout: () => Promise<void>;
330
- refreshUser: () => Promise<void>;
331
- getAccessToken: () => Promise<string | null>;
332
- }
333
- ```
334
-
335
- #### `usePermissions()`
336
-
337
- Hook for permission/role checks.
338
-
339
- ```tsx
340
- interface UsePermissionsReturn {
341
- hasRole: (requiredRole: CompanyRole, companyId?: string) => boolean;
342
- isOwner: (companyId?: string) => boolean;
343
- isAdmin: (companyId?: string) => boolean;
344
- canEdit: (companyId?: string) => boolean;
345
- canView: (companyId?: string) => boolean;
346
- currentRole: CompanyRole | null;
347
- currentCompanyId: string | null;
348
- }
349
- ```
350
-
351
- #### `AuthClient`
352
-
353
- Low-level authentication client (used internally by hooks).
221
+ // app/dashboard/page.tsx
222
+ import { getServerSession } from '@startsimpli/auth/server';
223
+ import { redirect } from 'next/navigation';
354
224
 
355
- ```tsx
356
- class AuthClient {
357
- constructor(config: AuthConfig);
358
- login(email: string, password: string): Promise<Session>;
359
- logout(): Promise<void>;
360
- refreshToken(): Promise<string>;
361
- getCurrentUser(): Promise<AuthUser>;
362
- getSession(): Session | null;
363
- getAccessToken(): Promise<string | null>;
225
+ export default async function Dashboard() {
226
+ const session = await getServerSession(process.env.API_BASE_URL!);
227
+ if (!session) redirect('/auth/signin');
228
+ return <div>Welcome, {session.user.firstName}</div>;
364
229
  }
365
230
  ```
366
231
 
367
- ### Server Exports
368
-
369
- #### `getServerSession()`
370
-
371
- Get authenticated session from server-side cookies.
372
-
373
- ```tsx
374
- async function getServerSession(apiBaseUrl: string): Promise<Session | null>;
375
- ```
376
-
377
- #### `validateSession()`
378
-
379
- Validate session and return user or null.
380
-
381
- ```tsx
382
- async function validateSession(apiBaseUrl: string): Promise<AuthUser | null>;
383
- ```
384
-
385
- #### `requireAuth()`
386
-
387
- Require authenticated session (throws if not authenticated).
388
-
389
- ```tsx
390
- async function requireAuth(apiBaseUrl: string): Promise<Session>;
391
- ```
392
-
393
- #### `createAuthMiddleware()`
394
-
395
- Create Next.js middleware for route protection.
396
-
397
- ```tsx
398
- function createAuthMiddleware(config: AuthMiddlewareConfig): Middleware;
399
- ```
400
-
401
- #### `withAuth()`
402
-
403
- HOF to wrap API routes with auth guard.
404
-
405
- ```tsx
406
- function withAuth<T>(
407
- handler: (request: NextRequest, token: string) => Promise<NextResponse<T>>
408
- ): (request: NextRequest) => Promise<NextResponse<T>>;
409
- ```
410
-
411
- #### `withRole()`
232
+ `validateSession(apiBaseUrl)` and `requireAuth(apiBaseUrl)` are the
233
+ shorter forms. `refreshServerToken(apiBaseUrl)` hits the refresh endpoint
234
+ from a server context with cookies forwarded.
412
235
 
413
- HOF to wrap API routes with role-based guard.
236
+ ## Mock backend (tests, Storybook, offline demos)
414
237
 
415
- ```tsx
416
- function withRole<T>(
417
- requiredRole: CompanyRole,
418
- getUserRole: () => Promise<CompanyRole | null>,
419
- handler: (request: NextRequest, token: string) => Promise<NextResponse<T>>
420
- ): (request: NextRequest) => Promise<NextResponse<T>>;
421
- ```
422
-
423
- ### Types
238
+ `createMockAuthBackend()` returns a full `AuthBackend` implementation —
239
+ exactly what `AuthProvider` needs, with no network. Pair it with any
240
+ `SessionStorage` to survive reloads.
424
241
 
425
- ```tsx
426
- type CompanyRole = 'owner' | 'admin' | 'member' | 'viewer';
427
-
428
- interface AuthUser {
429
- id: string;
430
- email: string;
431
- firstName: string;
432
- lastName: string;
433
- isEmailVerified: boolean;
434
- createdAt: string;
435
- updatedAt: string;
436
- companies?: Array<{
437
- id: string;
438
- name: string;
439
- role: CompanyRole;
440
- }>;
441
- currentCompanyId?: string;
442
- }
443
-
444
- interface Session {
445
- user: AuthUser;
446
- accessToken: string;
447
- expiresAt: number;
448
- }
242
+ ```ts
243
+ import {
244
+ AuthProvider,
245
+ createMockAuthBackend,
246
+ createWebSessionStorage,
247
+ } from '@startsimpli/auth/client';
248
+
249
+ const backend = createMockAuthBackend({
250
+ accounts: [
251
+ { email: 'demo@example.com', password: 'hunter2',
252
+ user: { id: 'u1', email: 'demo@example.com', firstName: 'Demo',
253
+ lastName: 'User', isEmailVerified: true,
254
+ createdAt: '', updatedAt: '' } },
255
+ ],
256
+ storage: createWebSessionStorage(),
257
+ });
449
258
 
450
- interface AuthConfig {
451
- apiBaseUrl: string;
452
- tokenRefreshInterval?: number;
453
- onSessionExpired?: () => void;
454
- onUnauthorized?: () => void;
259
+ export default function App() {
260
+ return <AuthProvider backend={backend}>{/* ... */}</AuthProvider>;
455
261
  }
456
262
  ```
457
263
 
458
- ## Role Hierarchy
264
+ Pass `backend` *instead of* `config` and `AuthProvider` skips constructing
265
+ the Django `AuthClient` entirely. The mock surface adds `requestPasswordReset`,
266
+ `resetPassword`, `requestEmailVerification`, `verifyEmail`, and
267
+ `upsertAccount` on top of the base contract — see
268
+ `packages/billing/src/mock` for an example consumer.
459
269
 
460
- Roles follow a hierarchy where higher roles inherit permissions from lower roles:
270
+ ## Verification
461
271
 
272
+ ```bash
273
+ pnpm --filter @startsimpli/auth test # 140 tests across 17 files
274
+ pnpm --filter @startsimpli/auth type-check
462
275
  ```
463
- owner (4) > admin (3) > member (2) > viewer (1)
464
- ```
465
-
466
- Use `hasRolePermission(userRole, requiredRole)` to check if a user has sufficient permissions.
467
276
 
468
- ## Testing
277
+ The suite covers `auth-client`, `authFetch` retry, middleware, the mock
278
+ backend, the secure-storage adapters (native + web), the token-auth core,
279
+ permissions, and validation.
469
280
 
470
- ```bash
471
- npm test # Run tests
472
- npm run test:watch # Watch mode
473
- npm run test:coverage # Coverage report
474
- ```
281
+ **Browser verification is not optional.** Any sign-in / sign-out / OAuth
282
+ flow change MUST be driven through a localhost dev server with
283
+ `mcp__debugg-ai__check_app_in_browser` type-checks and unit tests do not
284
+ prove a redirect chain works. See the MCP-default rule in
285
+ `/Users/qosha/Repos/start-simpli/CLAUDE.md` (rule 10 / the
286
+ "feedback_verify_ui_with_mcp" memory).
475
287
 
476
- ## Development
288
+ ## Shared-package policy
477
289
 
478
- ```bash
479
- npm run type-check # TypeScript validation
480
- ```
290
+ Per `CLAUDE.md` rule 9, every app-side auth helper that another app might
291
+ plausibly want belongs here — **not** in `apps/*/src`. No new
292
+ `AuthProvider`, no app-local `authFetch` wrapper, no per-app `useAuth`
293
+ hook. Extend this package instead. The current consumers all go through
294
+ the public surface above.
481
295
 
482
296
  ## License
483
297
 
484
- Private package for StartSimpli monorepo.
298
+ Private package for the StartSimpli monorepo.