Skip to content

Add optional PocketBase backend for cloud sync and accounts - #18

Open
prayashm wants to merge 6 commits into
mainfrom
claude/pocketbase-scribrx-integration-IXiSN
Open

Add optional PocketBase backend for cloud sync and accounts#18
prayashm wants to merge 6 commits into
mainfrom
claude/pocketbase-scribrx-integration-IXiSN

Conversation

@prayashm

@prayashm prayashm commented May 29, 2026

Copy link
Copy Markdown
Owner

Adds an optional PocketBase backend to ScribRx — accounts, cross-device sync, a server-side AI proxy, and hosted QR verification — while staying fully functional as the original local-only PWA when no backend is configured.

This PR supersedes the earlier #14 (which was reverted from main). The branch has main re-merged, so it applies cleanly on top of the cleaned-up main, and it includes the review fixes from #14.

Activation

Controlled by VITE_PB_URL (see rxpad/.env.example). Empty → original IndexedDB-only / BYOK / client-HMAC behaviour. Set → cloud features turn on, with IndexedDB kept as an offline cache.

App-side

  • lib/pb.ts — PocketBase client singleton + auth helpers, gated on VITE_PB_URL.
  • lib/store.ts — repository wrapping PocketBase + IndexedDB with the same surface as db.ts, so pages only swapped their import path.
  • pages/Login.tsx — email/password + Google OAuth (cloud mode only); app.tsx gates routing on auth, keeps /verify and /auth/callback public.
  • gemini.tsparsePrescriptionViaServer() routes parsing through /api/parse-rx, reusing the existing sanitisers; BYOK retained as fallback.
  • qr.ts + Verify.tsx — id-based QR verified server-side, with the HMAC payload as fallback.
  • Onboarding/Settings adapt to cloud mode (skip BYOK step, add account/sign-out).

Backend (backend/)

  • pb_hooks/main.pb.jsPOST /api/parse-rx (Gemini server-side, key never shipped) and public GET /api/verify/{rxId}.
  • migrations/ — creates the profiles/prescriptions collections with owner-scoped API rules on first start.
  • Dockerfile, fly.toml, and README.md (collections, OAuth, CORS, Cloudflare R2 free-tier file storage).

Review fixes from #14 (included)

  • Cloud-first getProfile() in prescriptionToFormData (fixes blank doctorName/regNo on a fresh device).
  • pb.filter() parameter binding for all dynamic filters.
  • getNextRxId fetches only the latest same-day record instead of the full list.
  • ensurePdfBlob writes the fetched PDF back to the local cache.

⚠️ Enabling the backend stores patient data server-side — see the compliance notes in backend/README.md.

Type-check, the full test suite (22 passing), and the production build all pass on the merged branch.

https://claude.ai/code/session_01EWPrWETycUCJKFiBJFLg4L

claude added 5 commits May 28, 2026 21:02
Introduce an opt-in cloud backend (PocketBase) that adds accounts, cross-device
sync, a server-side AI proxy, and hosted QR verification — while preserving the
original zero-backend, on-device behaviour when no backend is configured.

Activation is controlled by VITE_PB_URL: when empty the app runs exactly as
before (IndexedDB-only, BYOK AI key, client-side HMAC QR). When set, cloud
features turn on with IndexedDB kept as an offline cache.

App-side:
- lib/pb.ts: PocketBase client singleton + auth helpers (gated on VITE_PB_URL)
- lib/store.ts: repository wrapping PocketBase + IndexedDB with the same surface
  as db.ts, so pages only swapped their import path
- pages/Login.tsx: email/password + Google OAuth sign-in (cloud mode only)
- app.tsx: gate routing on auth, keep /verify and /auth/callback public
- gemini.ts: parsePrescriptionViaServer() routes parsing through /api/parse-rx,
  reusing the existing sanitisers; BYOK path retained as fallback
- qr.ts + Verify.tsx: id-based QR verified server-side, HMAC payload as fallback
- Onboarding/Settings adapt to cloud mode (skip BYOK step, add account section)

Backend (backend/):
- pb_hooks/main.pb.js: POST /api/parse-rx (Gemini server-side, key never shipped)
  and public GET /api/verify/{rxId}
- Dockerfile, fly.toml, and README with collection schemas, API rules, Google
  OAuth, CORS, and Cloudflare R2 (free-tier) file-storage setup

Existing tests, type-check, and production build all pass.
Replace manual Admin-UI collection setup with a bundled migration that creates
the profiles and prescriptions collections and their owner-scoped API rules on
first start. The Dockerfile copies migrations/ into pb_migrations/, and the
backend README now documents the migration (with the manual steps kept as a
reference).

prescriptions uses a composite unique index on (user, rxId) since the RX id
counter is scoped per doctor.
Introduce an opt-in cloud backend (PocketBase) that adds accounts, cross-device
sync, a server-side AI proxy, and hosted QR verification — while preserving the
original zero-backend, on-device behaviour when no backend is configured.

Activation is controlled by VITE_PB_URL: when empty the app runs exactly as
before (IndexedDB-only, BYOK AI key, client-side HMAC QR). When set, cloud
features turn on with IndexedDB kept as an offline cache.

App-side:
- lib/pb.ts: PocketBase client singleton + auth helpers (gated on VITE_PB_URL)
- lib/store.ts: repository wrapping PocketBase + IndexedDB with the same surface
  as db.ts, so pages only swapped their import path
- pages/Login.tsx: email/password + Google OAuth sign-in (cloud mode only)
- app.tsx: gate routing on auth, keep /verify and /auth/callback public
- gemini.ts: parsePrescriptionViaServer() routes parsing through /api/parse-rx,
  reusing the existing sanitisers; BYOK path retained as fallback
- qr.ts + Verify.tsx: id-based QR verified server-side, HMAC payload as fallback
- Onboarding/Settings adapt to cloud mode (skip BYOK step, add account section)

Backend (backend/):
- pb_hooks/main.pb.js: POST /api/parse-rx (Gemini server-side, key never shipped)
  and public GET /api/verify/{rxId}
- Dockerfile, fly.toml, and README with collection schemas, API rules, Google
  OAuth, CORS, and Cloudflare R2 (free-tier) file-storage setup

Existing tests, type-check, and production build all pass.
Replace manual Admin-UI collection setup with a bundled migration that creates
the profiles and prescriptions collections and their owner-scoped API rules on
first start. The Dockerfile copies migrations/ into pb_migrations/, and the
backend README now documents the migration (with the manual steps kept as a
reference).

prescriptions uses a composite unique index on (user, rxId) since the RX id
counter is scoped per doctor.
…, cache write-back

From the automated review on PR #14:
- Use pb.filter() parameter binding for all dynamic filter values instead of
  string interpolation (profiles + prescriptions lookups).
- prescriptionToFormData now reads the cloud-first getProfile() so verification
  fields aren't blanked out on a fresh device with a cold local cache.
- getNextRxId fetches only the latest same-day record (sort -rxId) instead of
  the full day's list.
- ensurePdfBlob writes the lazily-fetched PDF back to the local cache so
  re-shares stay offline-first.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an optional PocketBase cloud backend to ScribRx, enabling accounts, cross-device sync, server-side AI parsing via Gemini, and hosted QR verification. The feedback highlights critical security and robustness improvements. Specifically, a potential Insecure Direct Object Reference (IDOR) vulnerability was identified in the public verification route because prescription IDs are only unique per doctor; scoping verification queries with the doctor's user ID resolves this. Additionally, the reviewer noted a potential query injection risk in store.ts that can be fixed by using pb.filter for safe parameter binding, a performance issue where the profile is unnecessarily fetched from the cloud on every save, and a bug where failed PDF fetches could cache corrupted error pages.

Comment on lines +147 to +154
routerAdd("GET", "/api/verify/{rxId}", (e) => {
const rxId = e.request.pathValue("rxId");
let rec;
try {
rec = $app.findFirstRecordByFilter("prescriptions", "rxId = {:rxId}", { rxId });
} catch (err) {
return e.json(404, { error: "Not found." });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Since rxId is only unique per doctor (as defined by the composite unique index idx_presc_user_rxid on (user, rxId)), multiple doctors can have prescriptions with the same rxId (e.g., RX-20250302-0001). Querying solely by rxId in this public verification route can return another doctor's prescription, leading to incorrect verification details. To resolve this, we should pass the doctor's user ID as a query parameter (u) and filter by both rxId and user.

routerAdd("GET", "/api/verify/{rxId}", (e) => {
  const rxId = e.request.pathValue("rxId");
  const info = e.requestInfo();
  const userId = info.query.u || "";
  let rec;
  try {
    if (userId) {
      rec = $app.findFirstRecordByFilter("prescriptions", "rxId = {:rxId} && user = {:userId}", { rxId, userId });
    } else {
      rec = $app.findFirstRecordByFilter("prescriptions", "rxId = {:rxId}", { rxId });
    }
  } catch (err) {
    return e.json(404, { error: "Not found." });
  }

Comment thread rxpad/src/lib/store.ts
const userId = pb.authStore.record?.id ?? '';
// Cloud-first: on a fresh device the local cache may be empty even though the
// profile exists remotely, which would blank out the verification fields.
const profile = await getProfile();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Fetching the profile from the cloud on every prescription save introduces unnecessary network latency and potential failures on a critical path. Since checkSetup already runs on app load and populates the local cache, we can safely use the local cache directly here.

Suggested change
const profile = await getProfile();
const profile = await local.getProfile();

Comment thread rxpad/src/lib/qr.ts
import QRCode from 'qrcode';
import type { Prescription } from '../schemas/prescription';
import type { DoctorProfile } from '../schemas/profile';
import { pocketBaseEnabled } from './pb';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Import currentUserId to allow appending the doctor's user ID to the verification URL.

Suggested change
import { pocketBaseEnabled } from './pb';
import { pocketBaseEnabled, currentUserId } from './pb';

Comment thread rxpad/src/lib/qr.ts
Comment on lines +61 to 68
export async function generateQRCode(payload: string, rxId?: string): Promise<string> {
// Cloud mode: encode just the prescription id and verify server-side (the
// server is the source of truth). Local mode: embed the HMAC-signed payload.
const url = pocketBaseEnabled && rxId
? `${window.location.origin}/verify?id=${encodeURIComponent(rxId)}`
: `${window.location.origin}/verify?data=${encodeURIComponent(btoa(payload))}`;
return QRCode.toDataURL(url, { width: 120, margin: 1 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Append the doctor's user ID (u) to the verification URL in cloud mode to ensure global uniqueness when verifying prescriptions.

Suggested change
export async function generateQRCode(payload: string, rxId?: string): Promise<string> {
// Cloud mode: encode just the prescription id and verify server-side (the
// server is the source of truth). Local mode: embed the HMAC-signed payload.
const url = pocketBaseEnabled && rxId
? `${window.location.origin}/verify?id=${encodeURIComponent(rxId)}`
: `${window.location.origin}/verify?data=${encodeURIComponent(btoa(payload))}`;
return QRCode.toDataURL(url, { width: 120, margin: 1 });
}
export async function generateQRCode(payload: string, rxId?: string): Promise<string> {
// Cloud mode: encode just the prescription id and verify server-side (the
// server is the source of truth). Local mode: embed the HMAC-signed payload.
const userId = currentUserId();
const url = pocketBaseEnabled && rxId && userId
? `${window.location.origin}/verify?id=${encodeURIComponent(rxId)}&u=${encodeURIComponent(userId)}`
: `${window.location.origin}/verify?data=${encodeURIComponent(btoa(payload))}`;
return QRCode.toDataURL(url, { width: 120, margin: 1 });
}

Comment on lines 4 to 8
interface VerifyProps {
path?: string;
data?: string;
id?: string;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add the optional u (user ID) prop to VerifyProps to support scoped server-side verification.

Suggested change
interface VerifyProps {
path?: string;
data?: string;
id?: string;
}
interface VerifyProps {
path?: string;
data?: string;
id?: string;
u?: string;
}

status: string;
}

export function Verify({ data, id }: VerifyProps) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Destructure the u prop in the Verify component.

Suggested change
export function Verify({ data, id }: VerifyProps) {
export function Verify({ data, id, u }: VerifyProps) {

Comment on lines +34 to +49
useEffect(() => {
if (!id) return;
let cancelled = false;
(async () => {
try {
if (!pocketBaseEnabled) throw new Error('verification unavailable');
const res = await pb.send(`/api/verify/${encodeURIComponent(id)}`, { method: 'GET' });
if (!cancelled) setFetched(res as VerificationPayload);
} catch {
if (!cancelled) setFetched(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [id]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Pass the u query parameter to the server-side verification API call if available.

  useEffect(() => {
    if (!id) return;
    let cancelled = false;
    (async () => {
      try {
        if (!pocketBaseEnabled) throw new Error('verification unavailable');
        const url = u
          ? `/api/verify/${encodeURIComponent(id)}?u=${encodeURIComponent(u)}`
          : `/api/verify/${encodeURIComponent(id)}`;
        const res = await pb.send(url, { method: 'GET' });
        if (!cancelled) setFetched(res as VerificationPayload);
      } catch {
        if (!cancelled) setFetched(null);
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, [id, u]);

Comment thread rxpad/src/lib/store.ts
export async function listPrescriptions(status?: string): Promise<Prescription[]> {
if (useCloud()) {
try {
const filter = status ? `status="${status}"` : '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Use pb.filter for safe parameter binding instead of string interpolation to prevent potential injection issues and maintain consistency with other queries in this file.

Suggested change
const filter = status ? `status="${status}"` : '';
const filter = status ? pb.filter('status = {:status}', { status }) : '';

Comment thread rxpad/src/lib/store.ts
Comment on lines +212 to +213
const res = await fetch(rx.pdfUrl);
const blob = await res.blob();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Check res.ok before calling res.blob(). If the server returns a non-200 status code (such as a 404 or 500 HTML error page), fetch does not throw an error, and calling res.blob() will succeed and cache the HTML error page as a corrupted PDF blob.

Suggested change
const res = await fetch(rx.pdfUrl);
const blob = await res.blob();
const res = await fetch(rx.pdfUrl);
if (!res.ok) throw new Error(`Failed to fetch PDF: ${res.statusText}`);
const blob = await res.blob();

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 29, 2026

Copy link
Copy Markdown

Deploying scribrx with  Cloudflare Pages  Cloudflare Pages

Latest commit: c0360af
Status: ✅  Deploy successful!
Preview URL: https://56a42d42.scribrx.pages.dev
Branch Preview URL: https://claude-pocketbase-scribrx-in.scribrx.pages.dev

View logs

@prayashm prayashm changed the title Apply PR #14 review fixes to store.ts Add optional PocketBase backend for cloud sync and accounts May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants