Implement PocketBase auth helpers (P0.1)
Add authentication utilities to pocketbase.ts for server-side auth: - createPocketBaseClient() - factory for fresh instances per request - isAuthenticated(pb) - checks authStore validity - getCurrentUser(pb) - returns typed User from authStore - loadAuthFromCookies(pb, cookies) - loads auth from Next.js cookies Includes 9 unit tests covering all auth state scenarios and cookie loading. This unblocks P0.2 (auth middleware) and all downstream API/page work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
||||
| `encryption.ts` | **Complete** | AES-256-GCM encrypt/decrypt implemented. **MISSING: tests** |
|
||||
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
|
||||
| `garmin.ts` | **Minimal (~30%)** | Has fetchGarminData, isTokenExpired, daysUntilExpiry. **MISSING: fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes** |
|
||||
| `pocketbase.ts` | **Basic** | Has pb client only. **MISSING: getCurrentUser(), isAuthenticated(), loadAuthFromCookies()** |
|
||||
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
|
||||
|
||||
### Missing Infrastructure Files (CONFIRMED NOT EXIST)
|
||||
- `src/lib/auth-middleware.ts` - Does NOT exist, needs creation
|
||||
@@ -65,12 +65,12 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
||||
|-----------|--------|
|
||||
| `src/lib/cycle.test.ts` | **EXISTS** - 9 tests |
|
||||
| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) |
|
||||
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
|
||||
| `src/lib/nutrition.test.ts` | **MISSING** |
|
||||
| `src/lib/email.test.ts` | **MISSING** |
|
||||
| `src/lib/ics.test.ts` | **MISSING** |
|
||||
| `src/lib/encryption.test.ts` | **MISSING** |
|
||||
| `src/lib/garmin.test.ts` | **MISSING** |
|
||||
| `src/lib/pocketbase.test.ts` | **MISSING** |
|
||||
| API route tests | **NONE** |
|
||||
| E2E tests | **NONE** |
|
||||
|
||||
@@ -87,12 +87,12 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
||||
|
||||
These must be completed first - nothing else works without them.
|
||||
|
||||
### P0.1: PocketBase Auth Helpers
|
||||
- [ ] Add authentication utilities to pocketbase.ts
|
||||
### P0.1: PocketBase Auth Helpers ✅ COMPLETE
|
||||
- [x] Add authentication utilities to pocketbase.ts
|
||||
- **Files:**
|
||||
- `src/lib/pocketbase.ts` - Add `getCurrentUser()`, `isAuthenticated()`, `loadAuthFromCookies()`
|
||||
- `src/lib/pocketbase.ts` - Added `createPocketBaseClient()`, `getCurrentUser()`, `isAuthenticated()`, `loadAuthFromCookies()`
|
||||
- **Tests:**
|
||||
- `src/lib/pocketbase.test.ts` - Test auth state management, cookie loading
|
||||
- `src/lib/pocketbase.test.ts` - 9 tests covering auth state management, cookie loading
|
||||
- **Why:** Every protected route and page depends on these helpers
|
||||
- **Blocking:** P0.2, P0.4, P1.1-P1.7, P2.2-P2.13
|
||||
|
||||
@@ -462,6 +462,7 @@ P2.14 Mini calendar
|
||||
### Library
|
||||
- [x] **cycle.ts** - Complete with 9 tests (`getCycleDay`, `getPhase`, `getPhaseConfig`, `getPhaseLimit`)
|
||||
- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
|
||||
- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`)
|
||||
|
||||
### Components
|
||||
- [x] **DecisionCard** - Displays decision status, icon, and reason
|
||||
@@ -479,7 +480,7 @@ P2.14 Mini calendar
|
||||
- [ ] `src/lib/auth-middleware.ts` does not exist - must be created in P0.2
|
||||
- [ ] `src/app/middleware.ts` does not exist - must be created in P0.2
|
||||
- [ ] `garmin.ts` is only ~30% complete - missing specific biometric fetchers
|
||||
- [ ] `pocketbase.ts` missing all auth helper functions
|
||||
- [x] ~~`pocketbase.ts` missing all auth helper functions~~ - FIXED in P0.1
|
||||
|
||||
---
|
||||
|
||||
|
||||
185
src/lib/pocketbase.test.ts
Normal file
185
src/lib/pocketbase.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// ABOUTME: Unit tests for PocketBase authentication helper utilities.
|
||||
// ABOUTME: Tests isAuthenticated, getCurrentUser, and loadAuthFromCookies functions.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createPocketBaseClient,
|
||||
getCurrentUser,
|
||||
isAuthenticated,
|
||||
loadAuthFromCookies,
|
||||
} from "./pocketbase";
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
it("returns false when authStore is not valid", () => {
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: false,
|
||||
model: null,
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
expect(isAuthenticated(mockPb as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when authStore is valid", () => {
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: true,
|
||||
model: { id: "user123" },
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
expect(isAuthenticated(mockPb as any)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentUser", () => {
|
||||
it("returns null when not authenticated", () => {
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: false,
|
||||
model: null,
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
expect(getCurrentUser(mockPb as any)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when authStore is valid but model is null", () => {
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: true,
|
||||
model: null,
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
expect(getCurrentUser(mockPb as any)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns mapped user when authenticated", () => {
|
||||
const mockRecord = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
garminConnected: true,
|
||||
garminOauth1Token: "encrypted1",
|
||||
garminOauth2Token: "encrypted2",
|
||||
garminTokenExpiresAt: "2025-06-01T00:00:00Z",
|
||||
calendarToken: "cal-token-123",
|
||||
lastPeriodDate: "2025-01-01",
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-06-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: true,
|
||||
model: mockRecord,
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = getCurrentUser(mockPb as any);
|
||||
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.id).toBe("user123");
|
||||
expect(user?.email).toBe("test@example.com");
|
||||
expect(user?.garminConnected).toBe(true);
|
||||
expect(user?.cycleLength).toBe(28);
|
||||
expect(user?.activeOverrides).toEqual(["flare"]);
|
||||
expect(user?.timezone).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("handles empty activeOverrides array", () => {
|
||||
const mockRecord = {
|
||||
id: "user456",
|
||||
email: "user@test.com",
|
||||
garminConnected: false,
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "2025-01-15",
|
||||
cycleLength: 31,
|
||||
notificationTime: "08:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: true,
|
||||
model: mockRecord,
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = getCurrentUser(mockPb as any);
|
||||
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.activeOverrides).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthFromCookies", () => {
|
||||
it("does nothing when pb_auth cookie is not present", () => {
|
||||
const loadFromCookie = vi.fn();
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: false,
|
||||
model: null,
|
||||
loadFromCookie,
|
||||
},
|
||||
};
|
||||
|
||||
const mockCookieStore = {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
loadAuthFromCookies(mockPb as any, mockCookieStore as any);
|
||||
|
||||
expect(mockCookieStore.get).toHaveBeenCalledWith("pb_auth");
|
||||
expect(loadFromCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads auth from cookie when pb_auth is present", () => {
|
||||
const loadFromCookie = vi.fn();
|
||||
const mockPb = {
|
||||
authStore: {
|
||||
isValid: false,
|
||||
model: null,
|
||||
loadFromCookie,
|
||||
},
|
||||
};
|
||||
|
||||
const mockCookieStore = {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValue({ name: "pb_auth", value: "token-value-here" }),
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
loadAuthFromCookies(mockPb as any, mockCookieStore as any);
|
||||
|
||||
expect(mockCookieStore.get).toHaveBeenCalledWith("pb_auth");
|
||||
expect(loadFromCookie).toHaveBeenCalledWith("pb_auth=token-value-here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPocketBaseClient", () => {
|
||||
it("creates a new PocketBase instance", () => {
|
||||
const client = createPocketBaseClient();
|
||||
expect(client).toBeDefined();
|
||||
expect(client.authStore).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,82 @@
|
||||
// ABOUTME: PocketBase client initialization and utilities.
|
||||
// ABOUTME: PocketBase client initialization and authentication utilities.
|
||||
// ABOUTME: Provides typed access to the PocketBase backend for auth and data.
|
||||
import PocketBase from "pocketbase";
|
||||
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||
import PocketBase, { type RecordModel } from "pocketbase";
|
||||
|
||||
import type { OverrideType, User } from "@/types";
|
||||
|
||||
const POCKETBASE_URL = process.env.POCKETBASE_URL || "http://localhost:8090";
|
||||
|
||||
/**
|
||||
* Singleton PocketBase client for client-side usage.
|
||||
* For server-side, use createPocketBaseClient() to get a fresh instance.
|
||||
*/
|
||||
export const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
// Disable auto-cancellation for server-side usage
|
||||
pb.autoCancellation(false);
|
||||
|
||||
/**
|
||||
* Creates a new PocketBase client instance.
|
||||
* Use this in server components and API routes to get a fresh client
|
||||
* that can be loaded with request-specific auth.
|
||||
*/
|
||||
export function createPocketBaseClient(): PocketBase {
|
||||
const client = new PocketBase(POCKETBASE_URL);
|
||||
client.autoCancellation(false);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the PocketBase client has a valid authenticated session.
|
||||
*/
|
||||
export function isAuthenticated(pbClient: PocketBase): boolean {
|
||||
return pbClient.authStore.isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current authenticated user from the PocketBase auth store.
|
||||
* Returns null if not authenticated or if the model is not available.
|
||||
*/
|
||||
export function getCurrentUser(pbClient: PocketBase): User | null {
|
||||
if (!pbClient.authStore.isValid || !pbClient.authStore.model) {
|
||||
return null;
|
||||
}
|
||||
return mapRecordToUser(pbClient.authStore.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads authentication state from Next.js cookies into the PocketBase client.
|
||||
* Call this in server components/routes before checking auth.
|
||||
*/
|
||||
export function loadAuthFromCookies(
|
||||
pbClient: PocketBase,
|
||||
cookieStore: ReadonlyRequestCookies,
|
||||
): void {
|
||||
const cookie = cookieStore.get("pb_auth");
|
||||
if (cookie) {
|
||||
pbClient.authStore.loadFromCookie(`pb_auth=${cookie.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a PocketBase record to our typed User interface.
|
||||
*/
|
||||
function mapRecordToUser(record: RecordModel): User {
|
||||
return {
|
||||
id: record.id,
|
||||
email: record.email as string,
|
||||
garminConnected: record.garminConnected as boolean,
|
||||
garminOauth1Token: record.garminOauth1Token as string,
|
||||
garminOauth2Token: record.garminOauth2Token as string,
|
||||
garminTokenExpiresAt: new Date(record.garminTokenExpiresAt as string),
|
||||
calendarToken: record.calendarToken as string,
|
||||
lastPeriodDate: new Date(record.lastPeriodDate as string),
|
||||
cycleLength: record.cycleLength as number,
|
||||
notificationTime: record.notificationTime as string,
|
||||
timezone: record.timezone as string,
|
||||
activeOverrides: (record.activeOverrides as OverrideType[]) || [],
|
||||
created: new Date(record.created as string),
|
||||
updated: new Date(record.updated as string),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user