Backend functionality to re-implement after ejecting from Base44 · Last updated 2026-07-03
This page is the migration checklist for moving off Base44 onto the React + Postgres stack. Base44 currently owns authentication, the database/entity layer, file storage, and the Core AI integrations — all of which must be rebuilt. This list is kept current as new backend functionality is added to the app.
Base44 owns auth today (tokens, sessions, identity, the User entity). All of this must be rebuilt on the Postgres side.
Login, signup, session/token issuance and verification. Currently handled entirely by the Base44 platform (base44.auth).
Users table with id, email, full_name, role (admin/user) plus custom profile fields (display_name, dark_mode, onboarding_complete).
Invite a user by email with an assigned role.
Admin ability to view the platform as another user. Currently a client-side localStorage flag; needs a secure server-backed equivalent.
Every Base44 entity becomes a Postgres table with equivalent CRUD, filtering, sorting and bulk operations.
Create / read / update / delete, list with sort+limit, filter, bulkCreate, bulkUpdate, updateMany, deleteMany for all entities.
CREATE TABLE for all ~25 entities: AIModel, AIMarketplaceProduct, Dataset, Experiment, ExperimentRun, ClinicalObjective, InferenceRun, ModelTest, ValidationReport, ModelVersion, ModelDraft, Notification, PaymentMethod, MarketplaceListing, ListingReview, LeasedDataset, LeaseRequest, LeaseConfiguration, LeaseTransaction, DataConnection, ComplianceRule, DataQualityReport, DeanonymizationRequest, AuditLog, TestCase, AdminPermission, Revision, MosaicChat.
id, created_date, updated_date, created_by_id auto-populated on every row.
Live create/update/delete events used in the UI (e.g. entity.subscribe). Needs websockets / Postgres LISTEN-NOTIFY or polling.
User-scoped vs service-role data access (admins see all, users see their own).
Base44 Core integrations used across the app. Each needs a direct provider integration after ejection.
AI generation used for dataset analysis, quality reports, marketing copy, Mosaic Magic chat, field generation. Needs a direct OpenAI/Anthropic/Gemini integration with JSON-schema responses and optional web-search context.
Uploads dataset files, model files, avatars; returns a public file_url.
Private storage with time-limited signed download URLs.
Parse CSV/XLSX/PDF/image into structured JSON against a schema (dataset import, schema extraction).
App emails (contact form, notifications). Resend key already exists — wire it directly.
AI image generation where used in the UI.
Existing Deno backend functions. These are already standalone HTTP handlers and translate fairly directly into API routes.
Runs model inference (feature ordering + padding) — core ML path.
Polls Replicate prediction status (handles canceled + 10-min timeout).
Converts uploaded models to TensorFlow.js for browser inference.
Parses uploaded model files and extracts HDF5 metadata.
Stripe payment processing and the marketplace purchase flow (ownership checks). Stripe keys already exist.
Notification creation + email dispatch (incl. 2FA notifications, contact form).
Aggregated experiment retrieval for the current user.
Tests external data-source connections (Postgres, MySQL, Snowflake, etc.).
Admin/dev tooling functions — lower priority for migration.
The Connections UI is now backend-agnostic: all calls route through src/components/connections/helpers/connectionService.js — the ONLY file to change at ejection. The new backend must implement the contract below. Today Base44 only does a TCP reachability check (no auth, no table count).
RESOLVED on current platform: the old 'cram every identifier into host' hack is gone. Each provider's real fields (Snowflake account_identifier, Databricks workspace_url, S3 bucket/region, Azure account_name/container, Mongo connection_uri) are collected and mapped explicitly by deriveConnectionMeta() for display metadata, while buildConnectionTarget() is the SINGLE source of truth for 'what endpoint (if any) do I actually connect to'. TESTABILITY classifies each type as 'tcp' (relational — real host:port probe) or 'driver_only' (object stores/warehouses/SRV — no valid TCP target). NEW BACKEND: keep deriveConnectionMeta for metadata but drive your real driver off buildConnectionTarget so a bucket name / account id is never treated as a hostname.
Replace the TCP-only check with a genuine handshake per driver (Postgres, MySQL, MongoDB, Snowflake, BigQuery, S3, Azure Blob, Databricks): authenticate, verify the database/bucket exists, return table/collection count + latency. PLUG-AND-PLAY: the client already normalizes every result through interpretTestResult() into verified/reachable/failed and expects { success, authenticated, tables_available }. Today only 'tcp' types get a reachability probe; 'driver_only' types (and mongodb+srv, which resolves via SRV DNS to non-27017 hosts) return an honest 'reachable — pending backend verification' state (driver_verification_required:true) instead of a misleading DNS/port failure. The moment the backend returns authenticated:true it auto-promotes to 'verified' with NO client changes. All test/sync UI shares one interpreter, so the two-path inconsistency cannot recur.
PLUG-AND-PLAY: the client splits secrets from metadata (splitCredentials), records which credential fields were provided (DataConnection.credential_fields — names only, never values), and hands secrets to connectionService.storeConnectionSecrets(). That function is the ONLY change point: today it returns { stored:false } (Base44 has no per-record vault) and the UI honestly shows 'provided but not yet vaulted'; swap its body for POST /connections/:id/secrets (envelope-encrypt via KMS/pgcrypto) + flip credentials_stored:true and the UI auto-shows 'securely stored'. Secrets are NEVER persisted client-side or returned to the browser.
PLUG-AND-PLAY: the UI (SchemaBrowserSheet, opened from each connection card) already renders the normalized shape from connectionService.browseSchema() — { available, reason?, tables:[{ name, rowCount?, columns:[{ name, type }] }] }. Today browseSchema() returns available:false with an honest 'pending backend' reason (no fabricated tables). New backend: implement POST /connections/:id/schema to introspect the live catalog via the vaulted driver and return the tables/columns array — swap the one line in browseSchema() and real tables render with ZERO UI change. tables_available on the card is likewise threaded through interpretTestResult.
RESOLVED on current backend: functions/testDbConnection now (1) rejects localhost/RFC1918/link-local/CGNAT literal hosts, (2) RESOLVES the hostname and rejects if ANY resolved A/AAAA record is private/internal (defeats DNS-rebinding to 169.254.169.254 / 10.x), returning 403 blocked:true, and (3) enforces an in-memory per-user rate limit (20/min) returning 429 rate_limited:true. Client also blocks early via isBlockedHost. NEW BACKEND: keep this exact allow/deny model at the network boundary and replace the in-memory limiter with a durable token bucket (Redis/Postgres) keyed by user+IP so it survives restarts and spans instances; ideally also disable HTTP redirects and pin the resolved IP for the actual connect.
CRUD for DataConnection routes through connectionService (listConnections/createConnection/updateConnection/deleteConnection) — swap the four Base44 calls for REST. Full EDIT is now wired: the card's edit button opens ConnectionForm in edit mode (explodeConnectionMeta pre-fills stored metadata, type is locked, secrets are re-entered since they're never stored). The connection_type enum is reconciled across the entity, the form, the icon map, and the backend (postgresql, mysql, mongodb, snowflake, bigquery, s3, azure_blob, databricks) — no stale gcs/redshift references remain.
RESOLVED on current platform: sync no longer fires two separate update mutations (the old 'testing' write then a result write, which could interleave on rapid re-sync or unmount and persist a stale status). connectionService.syncConnection() now does test-then-single-write, and the page guards concurrent syncs per-connection via a syncingIds set. NEW BACKEND: expose this as one POST /connections/:id/sync that runs the driver test and updates the row in a single transaction.
RESOLVED (client scheduler): the Connections page periodically re-tests connections whose last check is stale (isHealthCheckDue, 5-min interval) via connectionService.healthCheckConnection(), which stamps DataConnection.last_checked (distinct from user-initiated last_sync) and reports DRIFT via detectDrift() (was 'active' → now 'error') as a warning toast. All health logic is pure in connectionHealth.js. NEW BACKEND: move the scheduler server-side — a cron worker calls POST /connections/:id/sync per due connection and returns the drift flag; the client effect then just reflects server state (or drops entirely).
RESOLVED (client): on save, findDuplicateConnection() (connectionHealth.js) fingerprints type+host+database and warns if an identical connection already exists. Pure/deterministic. NEW BACKEND: enforce as a real uniqueness guard — a partial unique index on (owner, connection_type, host, database) or a 409 from POST /connections — so duplicates are blocked server-side, not just warned client-side.
RESOLVED (client + schema): DataConnection.ssl_mode (disable/require/verify-ca/verify-full, default require) is collected in ConnectionForm for driver types (SSL_APPLIES_TO = postgres/mysql/mongodb; object stores/warehouses always use fixed HTTPS) and shown on the card and detail sheet. It's stored but NOT yet enforced (today's test is TCP-only). NEW BACKEND: pass ssl_mode into each driver's TLS config at connect time (libpq sslmode, mysql2 ssl, mongo tls) and fail closed on verify-ca/verify-full cert errors.
PLUG-AND-PLAY: QueryPreviewSheet renders the normalized shape from connectionService.queryPreview() — { available, reason?, columns:[], rows:[] }. Today available:false with an honest 'pending backend' reason (never fabricated rows). NEW BACKEND: POST /connections/:id/preview { table?, limit } runs a bounded, READ-ONLY SELECT ... LIMIT n through the vaulted driver and returns columns+rows — swap the one line in queryPreview(). Enforce read-only + a hard row cap server-side.
RESOLVED (client seam) + schema: Dataset gained source_connection_id. connectionService.getConnectionUsage(id) queries datasets by it, and DeleteConnectionDialog shows dependent datasets BEFORE a delete so a connection can't be blindly removed. NEW BACKEND: back getConnectionUsage with GET /connections/:id/usage (SELECT id,name,record_count FROM datasets WHERE source_connection_id=:id) and ENSURE the import pipeline stamps source_connection_id on every dataset it creates from a connection.
RESOLVED (client, pure): connectionFilters.js (filterAndSortConnections + usedConnectionTypes) drives a ConnectionsToolbar (search by name/host/db, filter by type + status, sort by name/status/last-sync) — no backend needed today. Per-type empty guidance (ConnectionsEmptyState lists Databases/Warehouses/Object storage) and a distinct 'no results after filter' state (ConnectionsNoResults) replace the single generic empty prompt. The dormant ConnectionDetailSheet is now wired in (click a card name or the info button) for status/metadata/credentials/SSL/errors, with Sync/Edit/Preview delegated to the page so double-submit guarding and the atomic sync path stay in one place. NEW BACKEND: push filterAndSortConnections into GET /connections query params (search/type/status/sort) for server-side paging when lists grow large.
RESOLVED (client UX): Save is disabled while the create/edit mutation is in flight (isPending threaded into ConnectionForm), Sync is guarded per-connection via a syncingIds set, and the form's test button shows a truthful 'Testing…' (a REAL backend reachability call) — the old fake 'Validating…' delay is gone. Credential copy is now migration-honest: 'never kept in your browser; used only at connection time and not persisted today; encrypted at rest once the backend is live.' No backend work — listed so the new team keeps the disabled-while-pending contract and swaps the copy the moment secrets are actually vaulted.
Environment configuration currently managed by Base44 secrets.
RESEND_API_KEY, REPLICATE_API_KEY, STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY — move to the new app's environment/secret manager.
CTO review before merging into the patient-data platform · Last updated 2026-08-03
This app was built against demo data. Several flows are unsafe the moment real patient data is present.
Uploads go through Core.UploadFile and Dataset.file_url is a public URL — anyone with the link can download the raw file. With patient data, ALL dataset/model uploads must move to private storage with short-lived signed URLs (UploadPrivateFile/CreateFileSignedUrl pattern, or the host platform's object store).
PARTIALLY FIXED: masking no longer touches the LLM — deidentifyDataset() is fully deterministic and local. Still open: runAnalytics() sends the first 6,000 characters of the uploaded file plus 15 sample rows to InvokeLLM for schema parsing and quality scoring. With PHI that remains an impermissible third-party disclosure without a BAA — switch analytics to the same deterministic parser (parseTabular) plus rule-based quality checks before merge.
FIXED: de-identification now parses the entire file locally (deidentify/maskingEngine.js — deterministic CSV/JSON parse, no LLM), applies each field's strategy to every row, uploads the masked file and repoints Dataset.file_url at it, with a real row count. Remaining work for the host platform: (1) run this server-side on private storage instead of in the browser, (2) replace the dataset-id salt with a server-held HMAC key, (3) purge/lifecycle the pre-masking object, and (4) emit an immutable audit record of each de-id run.
applyQualityFix() asks the LLM to 'generate a summary of what was fixed' — no record is actually changed — then shows a success toast. This is a demo affordance; it must either be implemented for real or removed before clinicians see it.
Ownership is enforced in the browser, not the backend. The host platform must enforce all of this server-side (RLS / WHERE owner).
Open items from SECURITY_AUDIT.md are still live: DatasetDetail fetches DataQualityReport.list() and AIModel.list() with no owner filter; BuildModelDialog and RecentsGrid have the same pattern. Any user can receive other users' models/reports over the wire even if the UI hides them. Audit every .list() call and scope it, and add row-level security so it cannot regress.
DatasetDetail/ModelDetail throw 'Unauthorized' inside the React query function after the record is already fetched. Once the code talks to your API directly, these checks are trivially bypassed — enforce ownership in the API layer.
'View As' stores the target user in localStorage and the client filters accordingly. Any user can set that key. Needs a server-backed, audited impersonation session in the host platform.
External DB credentials are intentionally NOT stored (connectionService.storeConnectionSecrets is a stub) and ssl_mode is collected but never enforced — the 'test' is a TCP reachability probe only. Already documented as plug-and-play seams in the feature list; the vault + real driver handshake are prerequisites for connecting anything to production data.
testDbConnection's SSRF guard is solid, but its 20/min limiter resets on restart and doesn't span instances. Move to a durable token bucket (Redis/Postgres) keyed by user+IP.
Features that look real but are mocked or partially wired. Each must be implemented for real, feature-flagged off, or clearly labeled before merge.
mockPurchaseModel exists alongside stripePayment — confirm which path each purchase button uses and remove the mock path (or gate it to non-production) before the merged app can take payments.
browseSchema() and queryPreview() honestly return 'pending backend' — good — but the backend endpoints (/schema, /preview with read-only + row caps) must exist before the Connections module is migrated.
success_rate, ValidationReport contents and some analytics figures originate from LLM generation or seeds rather than measured runs. Before these numbers appear next to clinical data, tag each metric with its provenance (measured vs. estimated) or hide unverified ones.
The host platform sets up clinical alerts — this app's notification layer must be reconciled with it, not merged blindly.
Notification.page_url stores routes like '/DatasetDetail?id=…'. Once embedded module-by-module into the host app these links break. Introduce a route-mapping layer (or store entity_type + entity_id and resolve the URL at render time).
Priority is derived ad-hoc (e.g. quality score < 70 → 'high'). Align these thresholds with the host platform's clinical alerting rules so data-quality noise can't compete with patient-safety alerts.
sendEmail/sendContactEmail move to direct Resend after ejection. Review every email body template for PHI (dataset names, patient counts, error strings) and route through the host platform's compliant mailer.
Long-running statuses are driven by the browser; there is no server-side supervisor.
Dataset can stick in 'de-identifying'/'analyzing' (one was manually repaired on 2026-08-01) and InferenceRun 'running' is only reconciled to 'cancelled' on page load. The host backend needs job supervision: server-side timeouts and a reconciliation sweep for any status older than its SLA.
Model↔dataset links are TWO arrays (AIModel.linked_dataset_ids and Dataset.linked_model_ids) that can drift; Dataset.source_connection_id is unenforced. In Postgres these become join tables / FKs with cascade rules — decide the canonical side before migrating either entity.
record_count, field_count and sensitive_fields_detected come from LLM parsing. Recompute deterministically at import time in the new pipeline.
Seams already exist for some modules; wrap the rest the same way before moving them. Feature-flag each migrated module and run contract tests against the live API docs.
Stand up the ~28 tables and PRIVATE file storage in the host DB, read-only mirrored from Base44, so every later module lands on compliant storage from day one.
Map Base44 users (email, role, profile fields) to host-platform identities before any module that does ownership checks migrates. Kill the localStorage 'View As' at this step.
Migrate dataset import/preview/quality with deterministic parsing and rule-based masking replacing the LLM calls — this is where patient data first flows, so it inherits items from the PHI section.
connectionService.js is the only file to change — implement the documented contract (driver handshake, vault, schema, preview, atomic sync) and swap it. The UI needs zero changes.
Inference (Replicate + ONNX) migrates once model files are on private storage. Marketplace/payments go last — they touch money and depend on everything else being stable.
Datasets, models, marketplace and notifications still call base44.* directly from pages/helpers. Before migrating each, extract its calls into a per-domain service module (the connectionService pattern) so cutover is a one-file swap with a feature flag.
Smaller issues found during review.
The Dataset Detail models toggle showed 'Incompatible' for what is actually the partial-match bucket (<50% overlap but ≥1 matching column; zero-match models are excluded). Renamed to 'Partial' with corrected description.
The layout polls window.__pageTitle every 100ms to pick up page-title overrides. Replace with React context/state before merging into the host shell.
The header search uses a hardcoded allPages array in Layout.jsx that isn't derived from the router — entries go stale as pages change. Derive it from the route config.
UTC-parse/local-display was fixed for inference runs; other timestamps (last_sync, notifications, audit log) should get the same treatment for consistency.
Effort estimates (Small / Medium / Large) are rough re-implementation sizing.