Add optional PocketBase backend for cloud sync and accounts - #18
Add optional PocketBase backend for cloud sync and accounts#18prayashm wants to merge 6 commits into
Conversation
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.
There was a problem hiding this comment.
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.
| 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." }); | ||
| } |
There was a problem hiding this comment.
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." });
}| 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(); |
There was a problem hiding this comment.
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.
| const profile = await getProfile(); | |
| const profile = await local.getProfile(); |
| import QRCode from 'qrcode'; | ||
| import type { Prescription } from '../schemas/prescription'; | ||
| import type { DoctorProfile } from '../schemas/profile'; | ||
| import { pocketBaseEnabled } from './pb'; |
| 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 }); | ||
| } |
There was a problem hiding this comment.
Append the doctor's user ID (u) to the verification URL in cloud mode to ensure global uniqueness when verifying prescriptions.
| 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 }); | |
| } |
| interface VerifyProps { | ||
| path?: string; | ||
| data?: string; | ||
| id?: string; | ||
| } |
There was a problem hiding this comment.
| status: string; | ||
| } | ||
|
|
||
| export function Verify({ data, id }: VerifyProps) { |
| 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]); |
There was a problem hiding this comment.
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]);
| export async function listPrescriptions(status?: string): Promise<Prescription[]> { | ||
| if (useCloud()) { | ||
| try { | ||
| const filter = status ? `status="${status}"` : ''; |
There was a problem hiding this comment.
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.
| const filter = status ? `status="${status}"` : ''; | |
| const filter = status ? pb.filter('status = {:status}', { status }) : ''; |
| const res = await fetch(rx.pdfUrl); | ||
| const blob = await res.blob(); |
There was a problem hiding this comment.
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.
| 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(); |
Deploying scribrx with
|
| Latest commit: |
c0360af
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://56a42d42.scribrx.pages.dev |
| Branch Preview URL: | https://claude-pocketbase-scribrx-in.scribrx.pages.dev |
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 hasmainre-merged, so it applies cleanly on top of the cleaned-upmain, and it includes the review fixes from #14.Activation
Controlled by
VITE_PB_URL(seerxpad/.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 onVITE_PB_URL.lib/store.ts— repository wrapping PocketBase + IndexedDB with the same surface asdb.ts, so pages only swapped their import path.pages/Login.tsx— email/password + Google OAuth (cloud mode only);app.tsxgates routing on auth, keeps/verifyand/auth/callbackpublic.gemini.ts—parsePrescriptionViaServer()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.Backend (
backend/)pb_hooks/main.pb.js—POST /api/parse-rx(Gemini server-side, key never shipped) and publicGET /api/verify/{rxId}.migrations/— creates theprofiles/prescriptionscollections with owner-scoped API rules on first start.Dockerfile,fly.toml, andREADME.md(collections, OAuth, CORS, Cloudflare R2 free-tier file storage).Review fixes from #14 (included)
getProfile()inprescriptionToFormData(fixes blankdoctorName/regNoon a fresh device).pb.filter()parameter binding for all dynamic filters.getNextRxIdfetches only the latest same-day record instead of the full list.ensurePdfBlobwrites the fetched PDF back to the local cache.Type-check, the full test suite (22 passing), and the production build all pass on the merged branch.
https://claude.ai/code/session_01EWPrWETycUCJKFiBJFLg4L