Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions homeflow/firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
"location": "nam5",
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
}
}
107 changes: 82 additions & 25 deletions homeflow/firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,101 @@ service cloud.firestore {

// ── Helpers ───────────────────────────────────────────────────────────────

// Returns the caller's Throne-internal user ID from their profile doc.
// Returns null if the profile doesn't exist or the field isn't set.
function callerThroneUserId() {
let profile = get(/databases/$(database)/documents/users/$(request.auth.uid));
return profile.data.get('throneUserId', null);
// True only for authenticated users accessing their own data.
function isOwner(uid) {
return request.auth != null && request.auth.uid == uid;
}

// ── User profiles ─────────────────────────────────────────────────────────

match /users/{uid} {
// Users can only read and write their own profile.
allow read, write: if request.auth != null && request.auth.uid == uid;
}
// Owner can read their own profile document.
allow read: if isOwner(uid);

// ── Throne sessions ───────────────────────────────────────────────────────
// Only allow writing the Throne device-link fields to the root profile
// document. All other app data lives in named subcollections below.
// This prevents a client from writing arbitrary fields to the root doc
// (e.g. spoofing another participant's throneUserId).
allow write: if isOwner(uid)
&& request.resource.data.keys().hasOnly(['throneUserId', 'throneUserIdSetAt']);

match /sessions/{sessionId} {
// A user may only read sessions whose Throne userId matches theirs.
// Writes come exclusively from the Cloud Function (Admin SDK bypasses rules).
allow read: if request.auth != null
&& callerThroneUserId() != null
&& resource.data.userId == callerThroneUserId();
allow write: if false;
}
// ── Throne data (written by Cloud Function, read by owner) ──────────────

match /throne_sessions/{sessionId} {
allow read: if isOwner(uid);
allow write: if false; // Cloud Function (Admin SDK) only
}

match /throne_metrics/{metricId} {
allow read: if isOwner(uid);
allow write: if false; // Cloud Function (Admin SDK) only
}

match /throne_sync/{docId} {
allow read: if isOwner(uid);
allow write: if false; // Cloud Function (Admin SDK) only
}

// ── HealthKit data (written by device, read by owner) ───────────────────

match /hk_heartRate/{sampleId} {
allow read, write: if isOwner(uid);
}

match /hk_stepCount/{sampleId} {
allow read, write: if isOwner(uid);
}

match /hk_sleepAnalysis/{sampleId} {
allow read, write: if isOwner(uid);
}

match /hk_heartRateVariabilitySDNN/{sampleId} {
allow read, write: if isOwner(uid);
}

// ── Throne metrics ────────────────────────────────────────────────────────
match /hk_sync/{metricType} {
allow read, write: if isOwner(uid);
}

match /metrics/{metricId} {
// Metrics are fetched only by sessionId, which callers can only know
// from their own sessions query (guarded above). Require authentication.
allow read: if request.auth != null;
allow write: if false;
// ── Clinical data ────────────────────────────────────────────────────────

match /clinical_notes/{noteId} {
allow read, write: if isOwner(uid);
}

match /medical_history_prefill/{docId} {
allow read, write: if isOwner(uid);
}

match /medical_history/{docId} {
allow read, write: if isOwner(uid);
}

// ── Study metadata ───────────────────────────────────────────────────────

match /surgery_date/{docId} {
allow read, write: if isOwner(uid);
}

// Placeholder — logic to be added later
match /consent_response/{docId} {
allow read, write: if isOwner(uid);
}

// Placeholder — logic to be added later
match /study_timeline/{docId} {
allow read, write: if isOwner(uid);
}
}

// ── Throne sync state ─────────────────────────────────────────────────────
// ── Admin-only collections (Cloud Function / Admin SDK bypass rules) ───────

match /throneSync/{studyId} {
// Admin / Cloud Function only — no client access.
allow read, write: if false;
}

match /throneUserMap/{throneUserId} {
allow read, write: if false;
}
}
Expand Down
38 changes: 18 additions & 20 deletions homeflow/functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/**
* StreamSync Cloud Functions
*
* - throneIngestDaily: Scheduled daily at 3 AM PT, syncs Throne data to Firestore
* - syncThroneNow: HTTP trigger for manual/dev sync (requires x-admin-token header)
* - throneIngestDaily: Scheduled daily at 3 AM PT, syncs Throne data to Firestore
* - syncThroneNow: HTTP trigger for manual/dev sync (requires x-admin-token header)
* - syncThroneUserMap: Firestore trigger — keeps throneUserMap in sync when a user's
* throneUserId field is set or changed by the study coordinator
*
* Config is read from functions/.env (deployed with the function bundle).
* Required env vars:
* Required env vars (functions/.env):
* THRONE_API_KEY, THRONE_BASE_URL, THRONE_STUDY_ID, ADMIN_TOKEN
* Optional:
* THRONE_TIMEZONE (defaults to America/Los_Angeles)
Expand All @@ -15,16 +16,15 @@ import {setGlobalOptions} from "firebase-functions/v2";
import {onRequest} from "firebase-functions/v2/https";
import {onSchedule} from "firebase-functions/v2/scheduler";
import * as admin from "firebase-admin";
import * as crypto from "crypto";
import * as logger from "firebase-functions/logger";
import {runThroneIngestion, ThroneConfig} from "./throneIngestion";

// Initialize Firebase Admin
admin.initializeApp();

// setGlobalOptions must be called before any function definitions
setGlobalOptions({maxInstances: 10});

// ─── Config from .env ────────────────────────────────────────────────────────
// ─── Config ──────────────────────────────────────────────────────────────────

function requireEnv(name: string): string {
const val = process.env[name];
Expand All @@ -45,7 +45,7 @@ function getThroneConfig(): ThroneConfig {

export const throneIngestDaily = onSchedule(
{
schedule: "0 3 * * *", // 3:00 AM every day
schedule: "0 3 * * *",
timeZone: "America/Los_Angeles",
},
async () => {
Expand All @@ -59,7 +59,6 @@ export const throneIngestDaily = onSchedule(
} catch (err) {
logger.error("Throne ingestion failed", err);

// Record error in sync state
const db = admin.firestore();
await db.collection("throneSync").doc(studyId).set({
lastRunAt: new Date().toISOString(),
Expand All @@ -72,19 +71,22 @@ export const throneIngestDaily = onSchedule(
},
);

// ─── Manual HTTP Trigger (dev/admin) ─────────────────────────────────────────
// ─── Manual HTTP Trigger ─────────────────────────────────────────────────────

export const syncThroneNow = onRequest(async (req, res) => {
// Only accept POST
if (req.method !== "POST") {
res.status(405).send("Method not allowed");
return;
}

// Verify admin token
const expected = process.env.ADMIN_TOKEN;
const token = req.headers["x-admin-token"];
if (!expected || !token || token !== expected) {
const expected = process.env.ADMIN_TOKEN ?? "";
const token = typeof req.headers["x-admin-token"] === "string"
? req.headers["x-admin-token"]
: "";
const validToken = expected.length > 0 &&
token.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
if (!validToken) {
res.status(401).send("Unauthorized: invalid or missing x-admin-token");
return;
}
Expand All @@ -95,11 +97,7 @@ export const syncThroneNow = onRequest(async (req, res) => {
const config = getThroneConfig();
const fullSync = req.body?.fullSync === true;
const result = await runThroneIngestion(config, {fullSync});
res.status(200).json({
status: "ok",
fullSync,
...result,
});
res.status(200).json({status: "ok", fullSync, ...result});
} catch (err) {
logger.error("Manual Throne sync failed", err);
res.status(500).json({
Expand Down
Loading
Loading