From e46c12257e12b155af44ce18daf3b22122b3dde8 Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Thu, 12 Mar 2026 08:48:39 -0400 Subject: [PATCH 1/4] feat: add job manager support for background job execution [AP-68] Implement IJobExecutor backed by Firebase so iframed interactives can create and track background jobs via the LARA interactive API. Includes a Firestore listener for real-time status updates, backfill on page reload via getJobs, and support for both authenticated and anonymous users. Wires JobManager into iframe-runtime.tsx following the existing PubSubManager pattern. - Add src/firebase-job-executor.ts with FirebaseJobExecutor singleton - Export getFirestoreDb() from firebase-db.ts; add emulator support - Wire JobManager into iframe-runtime.tsx (addInteractive/removeInteractive) - Call configure() in app.tsx for authenticated learners and anonymous users - Bump interactive-api-host to ^0.11.0-pre.0, lara-interactive-api to 1.13.0-pre.2 - Add unit tests (21 tests covering all executor methods) --- package-lock.json | 30 +- package.json | 4 +- specs/AP-68-add-job-manager-support.md | 67 ++++ .../managed-interactive/iframe-runtime.tsx | 12 +- .../managed-interactive.test.tsx | 9 +- src/components/app.tsx | 10 + src/firebase-db.ts | 7 + src/firebase-job-executor.test.ts | 312 ++++++++++++++++++ src/firebase-job-executor.ts | 275 +++++++++++++++ 9 files changed, 706 insertions(+), 20 deletions(-) create mode 100644 specs/AP-68-add-job-manager-support.md create mode 100644 src/firebase-job-executor.test.ts create mode 100644 src/firebase-job-executor.ts diff --git a/package-lock.json b/package-lock.json index 01e3b466..f334aa58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@aws-sdk/client-s3": "^3.44.0", "@aws-sdk/s3-request-presigner": "^3.44.0", "@concord-consortium/dynamic-text": "^1.0.6", - "@concord-consortium/interactive-api-host": "0.10.0", - "@concord-consortium/lara-interactive-api": "1.11.0", + "@concord-consortium/interactive-api-host": "^0.11.0-pre.0", + "@concord-consortium/lara-interactive-api": "1.13.0-pre.2", "@concord-consortium/object-storage": "1.0.0-pre.8", "@concord-consortium/text-decorator": "^1.0.2", "@concord-consortium/token-service": "^2.1.0", @@ -6357,9 +6357,10 @@ } }, "node_modules/@concord-consortium/interactive-api-host": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@concord-consortium/interactive-api-host/-/interactive-api-host-0.10.0.tgz", - "integrity": "sha512-1vTajPts5QiaJs35ll0seQ+B6eGvhfDQg6mXyX4VOMhB1HqeOmpoEXU5XW/8MRduP0qDCowntT2RcPoukPG9Pw==", + "version": "0.11.0-pre.0", + "resolved": "https://registry.npmjs.org/@concord-consortium/interactive-api-host/-/interactive-api-host-0.11.0-pre.0.tgz", + "integrity": "sha512-JFhSWGuw1PP1eJIYSOMjBPz+5GwVcmVM++UYRQMj73Hw650nSe2hhqCOIfYfRsKRNC6FEVPrCdWnBoH0N3fdCQ==", + "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.27.0", "@aws-sdk/s3-request-presigner": "^3.27.0", @@ -6367,9 +6368,10 @@ } }, "node_modules/@concord-consortium/lara-interactive-api": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@concord-consortium/lara-interactive-api/-/lara-interactive-api-1.11.0.tgz", - "integrity": "sha512-3cyXRRjnk8AztAl1MEvRirKtuAvSmAEhH114Z/dJKywDjqGqaW5JBvPwfuoBhuwqG2omwGce5HWUq8ioAaXV9Q==", + "version": "1.13.0-pre.2", + "resolved": "https://registry.npmjs.org/@concord-consortium/lara-interactive-api/-/lara-interactive-api-1.13.0-pre.2.tgz", + "integrity": "sha512-nisj3YXRy2XdXsPSrMcTfUbexT+WxT0NzpC/+Yf7/Xj3AVhcT6JGU8E2SsJSqWc/VJh3hfdfiPibYiC/EXG/Gw==", + "license": "MIT", "dependencies": { "iframe-phone": "^1.3.1", "nanoid": "^3.3.7" @@ -32329,9 +32331,9 @@ } }, "@concord-consortium/interactive-api-host": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@concord-consortium/interactive-api-host/-/interactive-api-host-0.10.0.tgz", - "integrity": "sha512-1vTajPts5QiaJs35ll0seQ+B6eGvhfDQg6mXyX4VOMhB1HqeOmpoEXU5XW/8MRduP0qDCowntT2RcPoukPG9Pw==", + "version": "0.11.0-pre.0", + "resolved": "https://registry.npmjs.org/@concord-consortium/interactive-api-host/-/interactive-api-host-0.11.0-pre.0.tgz", + "integrity": "sha512-JFhSWGuw1PP1eJIYSOMjBPz+5GwVcmVM++UYRQMj73Hw650nSe2hhqCOIfYfRsKRNC6FEVPrCdWnBoH0N3fdCQ==", "requires": { "@aws-sdk/client-s3": "^3.27.0", "@aws-sdk/s3-request-presigner": "^3.27.0", @@ -32339,9 +32341,9 @@ } }, "@concord-consortium/lara-interactive-api": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@concord-consortium/lara-interactive-api/-/lara-interactive-api-1.11.0.tgz", - "integrity": "sha512-3cyXRRjnk8AztAl1MEvRirKtuAvSmAEhH114Z/dJKywDjqGqaW5JBvPwfuoBhuwqG2omwGce5HWUq8ioAaXV9Q==", + "version": "1.13.0-pre.2", + "resolved": "https://registry.npmjs.org/@concord-consortium/lara-interactive-api/-/lara-interactive-api-1.13.0-pre.2.tgz", + "integrity": "sha512-nisj3YXRy2XdXsPSrMcTfUbexT+WxT0NzpC/+Yf7/Xj3AVhcT6JGU8E2SsJSqWc/VJh3hfdfiPibYiC/EXG/Gw==", "requires": { "iframe-phone": "^1.3.1", "nanoid": "^3.3.7" diff --git a/package.json b/package.json index 5aba8825..07f04357 100644 --- a/package.json +++ b/package.json @@ -151,8 +151,8 @@ "@aws-sdk/client-s3": "^3.44.0", "@aws-sdk/s3-request-presigner": "^3.44.0", "@concord-consortium/dynamic-text": "^1.0.6", - "@concord-consortium/interactive-api-host": "0.10.0", - "@concord-consortium/lara-interactive-api": "1.11.0", + "@concord-consortium/interactive-api-host": "^0.11.0-pre.0", + "@concord-consortium/lara-interactive-api": "1.13.0-pre.2", "@concord-consortium/object-storage": "1.0.0-pre.8", "@concord-consortium/text-decorator": "^1.0.2", "@concord-consortium/token-service": "^2.1.0", diff --git a/specs/AP-68-add-job-manager-support.md b/specs/AP-68-add-job-manager-support.md new file mode 100644 index 00000000..0ba16515 --- /dev/null +++ b/specs/AP-68-add-job-manager-support.md @@ -0,0 +1,67 @@ +# Add Job Manager Support to Activity Player + +**Jira**: https://concord-consortium.atlassian.net/browse/AP-68 + +**Status**: **Closed** + +## Overview + +Add host-side job manager support to the Activity Player so that iframed interactives (starting with the button interactive) can create and track background jobs via the LARA interactive API. The Activity Player implements `IJobExecutor`, which calls a Firebase Cloud Function per-environment and listens for real-time status updates via Firestore. + +## Requirements + +- The Activity Player must implement `IJobExecutor` in a new `src/firebase-job-executor.ts` file; all Firebase SDK access goes through `firebase-db.ts` imports — no direct Firebase SDK calls in the executor +- `firebase-db.ts` must export a `getFirestoreDb()` helper that returns `app.firestore()`, so the executor can access the already-initialized Firestore instance without coupling to the Firebase SDK directly +- `createJob(request, context?)` must call the Firebase Cloud Function at `https://us-central1-${projectId}.cloudfunctions.net/submitTask` via HTTP POST with a JSON body containing both the `request` fields (task + task params) and the `context` fields (user identity); for authenticated users it must send a Firebase JWT as a Bearer token in the Authorization header; for anonymous users the Authorization header is omitted and the Cloud Function verifies identity via `run_key` in the POST body context; both calls set `Content-Type: application/json` +- The Firebase Cloud Function name is `submitTask` — a named constant in `firebase-job-executor.ts` +- `createJob` must never reject — on any error, it must return `IJobInfo` with `status: "failure"` and a descriptive `result.message`; if the executor has not been configured yet, `createJob` must immediately return a failure job with a clear message rather than throwing or hanging +- The Cloud Function must return a full `IJobInfo` JSON response (at minimum `{ version, id, status, request, createdAt }`) — coordination requirement with the backend team; the executor returns this object directly as the resolved value of `createJob` and uses `id` to construct the Firestore listener path +- After calling the Cloud Function, `createJob` must set up a Firestore document listener at `sources/{sourceKey}/jobs/{id}` (where `id` comes from the Cloud Function response) to receive real-time status updates; the job info is stored under the `jobInfo` key within the document (i.e. `snapshot.data().jobInfo`); updates must be delivered to the registered `onJobUpdate` callback +- If the Firestore document listener fails to establish or encounters an error, the executor must emit a `"failure"` job update for the affected job so the interactive does not hang indefinitely waiting for a status update +- `getJobs(context?)` must return an empty array (not throw) if the executor has not yet been configured, or if `context` is missing or lacks an `interactiveId`; otherwise it must query the `sources/{sourceKey}/jobs` Firestore collection filtered by `interactiveId` plus the appropriate user identity fields from the context to backfill the interactive after page reload; after returning, `getJobs` must set up Firestore listeners for any backfilled jobs with non-final status (`"queued"` or `"running"`) and populate the `jobId → interactiveId` mapping for those jobs using the context passed to `getJobs`; results must be sorted by `jobInfo.createdAt` ascending +- The Firestore job document written by the Cloud Function must have the structure `{ jobInfo: IJobInfo, interactiveId, ...userIdentityFields }` — `jobInfo` holds the full job object; `interactiveId` and user identity fields (`platform_user_id` or `run_key`) are stored at the root for Firestore query filtering — backend coordination requirement +- The Firestore security rules for `sources/{sourceKey}/jobs` must follow the same `studentWorkCreate/Read` pattern as answers: authenticated learners verified via Firebase JWT claims, and anonymous users verified via `run_key` presence — backend coordination requirement +- `cancelJob(jobId)` must call the same Firebase Function URL via HTTP POST with a JSON body `{ action: "cancel", jobId, context }`; `context` is built from the stored `jobId → interactiveId` mapping and `portalData`; `cancelJob` is fire-and-forget and must not reject +- `onJobUpdate(callback)` must register the callback so the executor calls it whenever any job's Firestore document changes +- The executor must be a module-level singleton configured via a `configure({ portalData, getFirebaseJWT })` call when portal data resolves — before any interactive loads; `configure()` is idempotent after the first call — subsequent calls are ignored +- A `JobManager` instance must be created as a module-level singleton in `iframe-runtime.tsx`, wired with the executor, following the `PubSubManager` pattern; `addInteractive(id, phone, context)` must always be called (not guarded by a null check) so job message handlers are always registered +- `addInteractive` must be called in both runtime and report mode so that job info is available for display in report views +- The executor must maintain an internal `jobId → interactiveId` mapping, populated when `createJob` is called, so it can identify and clean up the correct Firestore listeners when `removeInteractive(id)` is called +- `removeInteractive(id)` must be called in the `useEffect` cleanup — the executor must clean up any Firestore listeners for jobs owned by that interactive +- `configure()` must be called for both authenticated learners (with a portal-minted Firebase JWT) and anonymous users (with an empty-string JWT, relying on `run_key` for identity); teacher preview is intentionally excluded — teachers view read-only student work and do not need job execution +- `@concord-consortium/interactive-api-host` must be updated to `^0.11.0-pre.0` and `@concord-consortium/lara-interactive-api` to `1.13.0-pre.2`; when stable versions ship, `package.json` must be manually updated (pre-release `^` semver does not auto-resolve to stable) + +## Technical Notes + +- **Firebase project names**: `"report-service-dev"` (staging) and `"report-service-pro"` (production) — see `FirebaseAppName` in `src/firebase-db.ts` +- **Cloud Function URL**: `https://us-central1-${appName}.cloudfunctions.net/submitTask` (prod) or `http://localhost:5001/${appName}/us-central1/submitTask` (emulator via `?emulator=true` query param) +- **Firebase emulator**: Firestore on port 9090, Functions on port 5001, enabled via `?emulator=true`; `firebase-db.ts` conditionally calls `app.firestore().useEmulator("localhost", 9090)` when the param is set +- **Firestore document path**: `sources/{sourceKey}/jobs/{jobId}` — consistent with existing source-namespaced pattern; job info stored at `jobInfo` key, user identity fields at root +- **User identity context** — based on `createAnswerDoc` fields, with `user_type` added: + ```typescript + // authenticated + { interactiveId, user_type: "authenticated", source_key, resource_url, tool_id, + platform_id, platform_user_id, context_id, resource_link_id, remote_endpoint } + // anonymous + { interactiveId, user_type: "anonymous", source_key, resource_url, tool_id, + run_key, tool_user_id: "anonymous", platform_user_id: portalData.runKey } + ``` +- **`buildJobContext`**: exported from `firebase-job-executor.ts`; imported by `iframe-runtime.tsx` — service module dependency direction (UI depends on service, not vice versa) +- **`handleGetFirebaseJWT`** from `portal-utils.ts` is the correct export for fetching a portal-minted Firebase JWT (not `getFirebaseJwtFromPortal` in `plugin-context.ts`, which is module-private) +- **Composite Firestore indexes required**: `(interactiveId, platform_user_id)` and `(interactiveId, run_key)` in both Firebase projects — backend team must create these for `getJobs` queries to work +- **Relevant files**: + - `src/firebase-job-executor.ts` — new file: `FirebaseJobExecutor` implementing `IJobExecutor` + - `src/firebase-job-executor.test.ts` — new file: 21 unit tests + - `src/components/activity-page/managed-interactive/iframe-runtime.tsx` — `JobManager` wiring + - `src/firebase-db.ts` — added `getFirestoreDb()` export and emulator support + - `src/components/app.tsx` — `configure()` calls after portal data resolves + +## Out of Scope + +- The Firebase Cloud Function itself — this story is host-side wiring only +- UI changes in the Activity Player for job status display (handled by the button interactive) +- The mock/fake executor in the QI demo harness (already implemented in question-interactives) +- Job persistence / retrieval history beyond what the Firebase function provides +- Authorization beyond what the existing portal JWT flow provides +- Host-side gating of `createJob`/`cancelJob` in report mode — interactives are responsible for not calling these in report mode +- Changes to the LARA interactive API client or `JobManager` routing logic (those belong in LARA-210) diff --git a/src/components/activity-page/managed-interactive/iframe-runtime.tsx b/src/components/activity-page/managed-interactive/iframe-runtime.tsx index 886b9556..f337ed98 100644 --- a/src/components/activity-page/managed-interactive/iframe-runtime.tsx +++ b/src/components/activity-page/managed-interactive/iframe-runtime.tsx @@ -11,7 +11,8 @@ import { ITextDecorationInfo, ITextDecorationHandlerInfo, IAttachmentUrlRequest, IAttachmentUrlResponse, IGetInteractiveState, AttachmentInfoMap, IPubSubCreateChannel, IPubSubSubscribe, IPubSubUnsubscribe, IPubSubPublish } from "@concord-consortium/lara-interactive-api"; -import { PubSubManager } from "@concord-consortium/interactive-api-host"; +import { PubSubManager, JobManager } from "@concord-consortium/interactive-api-host"; +import { firebaseJobExecutor, buildJobContext } from "../../../firebase-job-executor"; import { DynamicTextCustomMessageType, DynamicTextMessage, useDynamicTextContext } from "@concord-consortium/dynamic-text"; import { FirebaseObjectStorageConfig, FirebaseObjectStorageUser } from "@concord-consortium/object-storage"; import Shutterbug from "shutterbug"; @@ -90,8 +91,9 @@ interface IProps { log: (logData: any) => void; } -// this is managed outside of the component to persist across component unmount/mount cycles +// these are managed outside of the component to persist across component unmount/mount cycles const pubSubManager = new PubSubManager(); +const jobManager = new JobManager(firebaseJobExecutor); export const IframeRuntime: React.ForwardRefExoticComponent = forwardRef((props, ref) => { const { url, id, authoredState, initialInteractiveState, legacyLinkedInteractiveState, setInteractiveState, linkedInteractives, report, @@ -127,7 +129,10 @@ export const IframeRuntime: React.ForwardRefExoticComponent = forwardRef return; } + const preview = queryValueBoolean("preview") || queryValue("mode")?.toLowerCase() === "teacher-edition"; + pubSubManager.addInteractive(id, phone); + jobManager.addInteractive(id, phone, buildJobContext(id, portalData ?? anonymousPortalData(preview))); // Just to add some type checking to phone post (ServerMessage). const post = (type: ServerMessage, data: any) => phone.post(type, data); @@ -317,7 +322,6 @@ export const IframeRuntime: React.ForwardRefExoticComponent = forwardRef }); // create object storage config - const preview = queryValueBoolean("preview") || queryValue("mode")?.toLowerCase() === "teacher-edition"; const objectStorePortalData = portalData ?? anonymousPortalData(preview); const objectStorageUser: FirebaseObjectStorageUser = @@ -439,6 +443,8 @@ export const IframeRuntime: React.ForwardRefExoticComponent = forwardRef }); pubSubManager.removeInteractive(id); + jobManager.removeInteractive(id); + firebaseJobExecutor.removeInteractive(id); if (phoneRef.current) { phoneRef.current.disconnect(); diff --git a/src/components/activity-page/managed-interactive/managed-interactive.test.tsx b/src/components/activity-page/managed-interactive/managed-interactive.test.tsx index 7a669050..a6276179 100644 --- a/src/components/activity-page/managed-interactive/managed-interactive.test.tsx +++ b/src/components/activity-page/managed-interactive/managed-interactive.test.tsx @@ -100,9 +100,16 @@ jest.mock("@concord-consortium/interactive-api-host", () => { publish = mockPublish; } + class MockJobManager { + addInteractive = jest.fn(); + removeInteractive = jest.fn(); + onJobUpdate = jest.fn(); + } + return { handleGetAttachmentUrl: (...args: any) => mockHandleGetAttachmentUrl(...args), - PubSubManager: MockPubSubManager + PubSubManager: MockPubSubManager, + JobManager: MockJobManager }; }); diff --git a/src/components/app.tsx b/src/components/app.tsx index b29dc55a..34427a9b 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -49,6 +49,8 @@ import { LaraDataContext } from "./lara-data-context"; import { __closeAllPopUps } from "../lara-plugin/plugin-api/popup"; import { IPageChangeNotification, PageChangeNotificationErrorTimeout, PageChangeNotificationStartTimeout } from "./activity-page/page-change-notification"; import { getBearerToken } from "../utilities/auth-utils"; +import { configure as configureJobExecutor } from "../firebase-job-executor"; +import { handleGetFirebaseJWT } from "../portal-utils"; import { ReadAloudContext } from "./read-aloud-context"; import { AccessibilityContext, FontSize, FontType, getFamilyForFontType, getFontSize, getFontSizeInPx, getFontType } from "./accessibility-context"; import { MediaLibraryContext } from "./media-library-context"; @@ -236,6 +238,10 @@ export class App extends React.PureComponent { await signInWithToken(portalData.database.rawFirebaseJWT); this.setState({ portalData }); setPortalData(portalData); + configureJobExecutor({ + portalData, + getFirebaseJWT: (appName: string) => handleGetFirebaseJWT({ firebase_app: appName }, portalData), + }); } else if (portalJWT.user_type === "teacher" || portalJWT.user_type === undefined) { // Logged-in user who is not launching an offering from Portal, most likely teacher. // As of 08/2022, portalJWT doesn't provide user_type when JWT is obtained using token coming from OAuth. @@ -255,6 +261,10 @@ export class App extends React.PureComponent { try { // Anonymous user running AP using a direct link most likely. await initializeAnonymousDB(preview); + configureJobExecutor({ + portalData: getPortalData() as IPortalDataUnion, + getFirebaseJWT: () => Promise.resolve(""), + }); } catch (err) { this.setError("auth", err); } diff --git a/src/firebase-db.ts b/src/firebase-db.ts index 92aa7a04..6d79d2ec 100644 --- a/src/firebase-db.ts +++ b/src/firebase-db.ts @@ -110,6 +110,8 @@ export const onFirestoreSaveAfterTimeout = (handler: () => void) => { }; let app: firebase.app.App; +export const getFirestoreDb = (): firebase.firestore.Firestore => app.firestore(); + export const getConfiguration = (name?: FirebaseAppName): IConfig => { return name ? configurations[name] : configurations["report-service-dev"]; }; @@ -126,6 +128,11 @@ export async function initializeDB({ name, preview }: { name: FirebaseAppName, p ignoreUndefinedProperties: true, }); + if (queryValueBoolean("emulator")) { + console.warn("[FirebaseDB] Using Firebase Firestore emulator at localhost:9090"); + app.firestore().useEmulator("localhost", 9090); + } + // The following flags are useful for tests. It makes it possible to clear the persistence // at the beginning of a test, and enable perisistence on each visit call // this way the tests can run offline but still share firestore state across visits diff --git a/src/firebase-job-executor.test.ts b/src/firebase-job-executor.test.ts new file mode 100644 index 00000000..de616f16 --- /dev/null +++ b/src/firebase-job-executor.test.ts @@ -0,0 +1,312 @@ +import { firebaseJobExecutor, configure } from "./firebase-job-executor"; +import { IJobInfo } from "@concord-consortium/interactive-api-host"; + +const mockUnsubscribe = jest.fn(); +const mockOnSnapshot = jest.fn((successCb, _errorCb) => { + (mockOnSnapshot as any)._successCb = successCb; + (mockOnSnapshot as any)._errorCb = _errorCb; + return mockUnsubscribe; +}); +const mockDocRef = { onSnapshot: mockOnSnapshot }; +const mockQueryGet = jest.fn(); +const mockWhere = jest.fn().mockReturnThis(); +const mockCollection = jest.fn(() => ({ where: mockWhere, get: mockQueryGet })); +const mockDoc = jest.fn(() => mockDocRef); + +jest.mock("./firebase-db", () => ({ + getFirestoreDb: jest.fn(() => ({ + collection: mockCollection, + doc: mockDoc, + })), +})); + +global.fetch = jest.fn(); + +const makeConfig = (type: "authenticated" | "anonymous" = "authenticated") => ({ + portalData: (type === "authenticated" + ? { + type: "authenticated" as const, + database: { appName: "report-service-dev" as const, sourceKey: "test-source", rawFirebaseJWT: "raw-firebase-jwt" }, + resourceUrl: "http://example.com/resource", + toolId: "ap", + platformId: "https://learn.concord.org", + platformUserId: "42", + contextId: "ctx-1", + resourceLinkId: "rl-1", + runRemoteEndpoint: "http://example.com/runs/1", + rawPortalJWT: "raw-jwt", + basePortalUrl: "https://learn.concord.org", + learnerKey: "lk-1", + offering: { id: 1, activityUrl: "", rubricUrl: "", locked: false }, + userType: "learner" as const, + rawClassInfo: null, + } + : { + type: "anonymous" as const, + database: { appName: "report-service-dev" as const, sourceKey: "test-source" }, + resourceUrl: "http://example.com/resource", + toolId: "ap", + runKey: "run-abc", + toolUserId: "anonymous" as const, + userType: "learner" as const, + }) as any, + getFirebaseJWT: jest.fn().mockResolvedValue("test-jwt-token"), +}); + +const makeJobInfo = (overrides: Partial = {}): IJobInfo => ({ + version: 1, + id: "job-1", + status: "queued", + request: { task: "success" }, + createdAt: Date.now(), + ...overrides, +}); + +describe("FirebaseJobExecutor", () => { + beforeEach(() => { + jest.clearAllMocks(); + (firebaseJobExecutor as any).config = null; + (firebaseJobExecutor as any).updateCallback = null; + (firebaseJobExecutor as any).jobIdToInteractiveId = new Map(); + (firebaseJobExecutor as any).jobListeners = new Map(); + }); + + describe("before configure()", () => { + it("createJob returns failure job immediately", async () => { + const job = await firebaseJobExecutor.createJob({ task: "success" }); + expect(job.status).toBe("failure"); + expect(job.result?.message).toMatch(/not configured/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("getJobs returns empty array", async () => { + const jobs = await firebaseJobExecutor.getJobs({ interactiveId: "i-1" }); + expect(jobs).toEqual([]); + }); + + it("cancelJob resolves without throwing", async () => { + await expect(firebaseJobExecutor.cancelJob("job-1")).resolves.toBeUndefined(); + }); + }); + + describe("configure()", () => { + it("is idempotent — second call is ignored", () => { + const config1 = makeConfig(); + const config2 = makeConfig(); + configure(config1); + configure(config2); + expect((firebaseJobExecutor as any).config).toBe(config1); + }); + }); + + describe("createJob()", () => { + beforeEach(() => configure(makeConfig())); + + it("POSTs to Cloud Function with Authorization header and returns IJobInfo", async () => { + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => job, + }); + const result = await firebaseJobExecutor.createJob( + { task: "success" }, + { interactiveId: "i-1", user_type: "authenticated", platform_user_id: "42" } + ); + expect(result).toEqual(job); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("cloudfunctions.net"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Authorization": "Bearer test-jwt-token" }), + }) + ); + }); + + it("sets up Firestore listener after successful job creation", async () => { + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-1" }); + expect(mockDoc).toHaveBeenCalledWith(expect.stringContaining("job-1")); + expect(mockOnSnapshot).toHaveBeenCalled(); + }); + + it("returns failure job on HTTP error without throwing", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + text: async () => "Internal error", + }); + const result = await firebaseJobExecutor.createJob({ task: "success" }); + expect(result.status).toBe("failure"); + expect(result.result?.message).toMatch(/500/); + }); + + it("returns failure job on network error without throwing", async () => { + (fetch as jest.Mock).mockRejectedValue(new Error("Network failure")); + const result = await firebaseJobExecutor.createJob({ task: "success" }); + expect(result.status).toBe("failure"); + }); + }); + + describe("onJobUpdate() + Firestore listener", () => { + beforeEach(() => configure(makeConfig())); + + it("calls registered callback when Firestore document updates", async () => { + const callback = jest.fn(); + firebaseJobExecutor.onJobUpdate(callback); + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }); + + const updatedJob = makeJobInfo({ status: "success" }); + (mockOnSnapshot as any)._successCb({ exists: true, data: () => ({ jobInfo: updatedJob }) }); + expect(callback).toHaveBeenCalledWith(updatedJob); + }); + + it("emits failure job update on listener error", async () => { + const callback = jest.fn(); + firebaseJobExecutor.onJobUpdate(callback); + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }); + (mockOnSnapshot as any)._errorCb(new Error("Permission denied")); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: "failure" })); + }); + + it("cleans up listener and mapping when job reaches final state", async () => { + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-1" }); + + const finalJob = makeJobInfo({ status: "success" }); + (mockOnSnapshot as any)._successCb({ exists: true, data: () => ({ jobInfo: finalJob }) }); + + expect((firebaseJobExecutor as any).jobListeners.has("job-1")).toBe(false); + expect((firebaseJobExecutor as any).jobIdToInteractiveId.has("job-1")).toBe(false); + }); + }); + + describe("getJobs()", () => { + beforeEach(() => configure(makeConfig())); + + it("returns empty array if context has no interactiveId", async () => { + const jobs = await firebaseJobExecutor.getJobs({ user_type: "authenticated" }); + expect(jobs).toEqual([]); + expect(mockCollection).not.toHaveBeenCalled(); + }); + + it("queries Firestore and returns backfilled jobs", async () => { + const job = makeJobInfo({ status: "success" }); + mockQueryGet.mockResolvedValue({ docs: [{ data: () => ({ jobInfo: job }) }] }); + const jobs = await firebaseJobExecutor.getJobs({ + interactiveId: "i-1", + user_type: "authenticated", + platform_user_id: "42", + }); + expect(jobs).toEqual([job]); + }); + + it("sets up Firestore listener for non-final backfilled jobs", async () => { + const runningJob = makeJobInfo({ status: "running" }); + mockQueryGet.mockResolvedValue({ docs: [{ data: () => ({ jobInfo: runningJob }) }] }); + await firebaseJobExecutor.getJobs({ + interactiveId: "i-1", + user_type: "authenticated", + platform_user_id: "42", + }); + expect(mockDoc).toHaveBeenCalled(); + expect(mockOnSnapshot).toHaveBeenCalled(); + }); + + it("does NOT set up listener for final backfilled jobs", async () => { + const successJob = makeJobInfo({ status: "success" }); + mockQueryGet.mockResolvedValue({ docs: [{ data: () => ({ jobInfo: successJob }) }] }); + await firebaseJobExecutor.getJobs({ interactiveId: "i-1" }); + expect(mockOnSnapshot).not.toHaveBeenCalled(); + }); + + it("returns empty array and logs error on Firestore failure", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(jest.fn()); + mockQueryGet.mockRejectedValue(new Error("Permission denied")); + const jobs = await firebaseJobExecutor.getJobs({ interactiveId: "i-1" }); + expect(jobs).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("getJobs failed"), + expect.any(Error) + ); + consoleSpy.mockRestore(); + }); + }); + + describe("cancelJob()", () => { + beforeEach(() => configure(makeConfig())); + + it("POSTs with Authorization header and context for authenticated users", async () => { + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-1" }); + + (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); + await firebaseJobExecutor.cancelJob("job-1"); + // cancelJob is fire-and-forget; flush the microtask queue + await Promise.resolve(); + + const cancelCall = (fetch as jest.Mock).mock.calls[1]; + const body = JSON.parse(cancelCall[1].body); + expect(body.action).toBe("cancel"); + expect(body.jobId).toBe("job-1"); + expect(body.context).toBeDefined(); + expect(cancelCall[1].headers.Authorization).toMatch(/^Bearer /); + }); + + it("omits Authorization header and sends run_key in context for anonymous users", async () => { + (firebaseJobExecutor as any).config = null; + const anonConfig = makeConfig("anonymous"); + anonConfig.getFirebaseJWT.mockResolvedValue(""); + configure(anonConfig); + + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-1" }); + + (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); + await firebaseJobExecutor.cancelJob("job-1"); + await Promise.resolve(); + + const cancelCall = (fetch as jest.Mock).mock.calls[1]; + const body = JSON.parse(cancelCall[1].body); + expect(body.context.run_key).toBe("run-abc"); + expect(cancelCall[1].headers.Authorization).toBeUndefined(); + }); + + it("resolves without throwing even if fetch fails", async () => { + (fetch as jest.Mock).mockRejectedValue(new Error("Network failure")); + await expect(firebaseJobExecutor.cancelJob("job-1")).resolves.toBeUndefined(); + }); + }); + + describe("removeInteractive()", () => { + beforeEach(() => configure(makeConfig())); + + it("unsubscribes Firestore listeners for jobs owned by that interactive", async () => { + const job = makeJobInfo(); + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => job }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-1" }); + expect(mockUnsubscribe).not.toHaveBeenCalled(); + firebaseJobExecutor.removeInteractive("i-1"); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it("does not affect listeners for other interactives", async () => { + const job1 = makeJobInfo({ id: "job-1" }); + const job2 = makeJobInfo({ id: "job-2" }); + (fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: async () => job1 }) + .mockResolvedValueOnce({ ok: true, json: async () => job2 }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-1" }); + await firebaseJobExecutor.createJob({ task: "success" }, { interactiveId: "i-2" }); + firebaseJobExecutor.removeInteractive("i-1"); + expect((firebaseJobExecutor as any).jobListeners.has("job-2")).toBe(true); + }); + }); +}); diff --git a/src/firebase-job-executor.ts b/src/firebase-job-executor.ts new file mode 100644 index 00000000..bc14d737 --- /dev/null +++ b/src/firebase-job-executor.ts @@ -0,0 +1,275 @@ +import { IJobExecutor, IJobInfo } from "@concord-consortium/interactive-api-host"; +import { IPortalData, IAnonymousPortalData } from "./portal-types"; +import { getFirestoreDb } from "./firebase-db"; + +const useEmulator = (new URLSearchParams(window.location.search)).get("emulator") === "true"; +if (useEmulator) { + console.warn("[FirebaseJobExecutor] Using Firebase Functions emulator at localhost:5001"); +} + +const getFunctionUrl = (appName: string) => + useEmulator + ? `http://localhost:5001/${appName}/us-central1/submitTask` + : `https://us-central1-${appName}.cloudfunctions.net/submitTask`; + +interface IConfig { + portalData: IPortalData | IAnonymousPortalData; + getFirebaseJWT: (appName: string) => Promise; +} + +type Unsubscribe = () => void; + +/** + * Builds the user identity context object sent to the Cloud Function and used + * for Firestore query filtering. Exported so iframe-runtime.tsx can pass the + * same context shape to jobManager.addInteractive(). + */ +export const buildJobContext = ( + interactiveId: string, + portalData: IPortalData | IAnonymousPortalData | undefined +): Record | undefined => { + if (!portalData) return undefined; + if (portalData.type === "authenticated") { + return { + interactiveId, + user_type: "authenticated", + source_key: portalData.database.sourceKey, + resource_url: portalData.resourceUrl, + tool_id: portalData.toolId, + platform_id: portalData.platformId, + platform_user_id: portalData.platformUserId.toString(), + context_id: portalData.contextId, + resource_link_id: portalData.resourceLinkId, + remote_endpoint: portalData.runRemoteEndpoint, + }; + } + return { + interactiveId, + user_type: "anonymous", + source_key: portalData.database.sourceKey, + resource_url: portalData.resourceUrl, + tool_id: portalData.toolId, + run_key: portalData.runKey, + tool_user_id: "anonymous", + platform_user_id: portalData.runKey, + }; +}; + +class FirebaseJobExecutor implements IJobExecutor { + private config: IConfig | null = null; + private updateCallback: ((job: IJobInfo) => void) | null = null; + // jobId → interactiveId: needed to route listener cleanup to the right interactive + private jobIdToInteractiveId: Map = new Map(); + // jobId → Firestore unsubscribe function + private jobListeners: Map = new Map(); + + configure(config: IConfig): void { + if (this.config) return; // idempotent — ignore subsequent calls + this.config = config; + } + + async createJob( + request: { task: string } & Record, + context?: Record + ): Promise { + if (!this.config) { + return this.makeFailureJob(request, "Job executor not configured — portal data has not resolved yet."); + } + try { + const { portalData, getFirebaseJWT } = this.config; + const appName = portalData.database.appName; + const token = await getFirebaseJWT(appName); + const url = getFunctionUrl(appName); + + const headers: Record = { "Content-Type": "application/json" }; + // Authenticated users send a Firebase JWT; anonymous users omit the header + // and rely on run_key in the POST body context for Cloud Function verification. + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ request, context }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + let detail = text; + try { + const parsed = JSON.parse(text); + if (parsed?.result?.message) { + detail = parsed.result.message; + } + } catch { /* use raw text */ } + return this.makeFailureJob( + request, + `Cloud Function error (${response.status})${detail ? `: ${detail}` : ""}` + ); + } + + const job: IJobInfo = await response.json(); + + // Populate jobId → interactiveId mapping for listener cleanup + if (context?.interactiveId) { + this.jobIdToInteractiveId.set(job.id, context.interactiveId); + } + + // Set up Firestore listener for real-time status updates + this.subscribeToJob(job.id); + + return job; + } catch (error) { + return this.makeFailureJob(request, `Unexpected error: ${String(error)}`); + } + } + + cancelJob(jobId: string): Promise { + // Fire-and-forget — errors are silently discarded + if (this.config) { + const { portalData, getFirebaseJWT } = this.config; + const appName = portalData.database.appName; + // Recover interactiveId so we can build a full context for CF identity verification. + // Anonymous users rely on run_key in the context body (no Authorization header). + const interactiveId = this.jobIdToInteractiveId.get(jobId) ?? ""; + const context = buildJobContext(interactiveId, portalData); + getFirebaseJWT(appName) + .then(token => { + const headers: Record = { "Content-Type": "application/json" }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return fetch(getFunctionUrl(appName), { + method: "POST", + headers, + body: JSON.stringify({ action: "cancel", jobId, context }), + }); + }) + .catch(() => { /* intentionally silent */ }); + } + return Promise.resolve(); + } + + async getJobs(context?: Record): Promise { + if (!this.config || !context?.interactiveId) { + return []; + } + const { portalData } = this.config; + const { sourceKey } = portalData.database; + try { + const db = getFirestoreDb(); + // Build query: filter by interactiveId plus the key user identity discriminator + let query = db + .collection(`sources/${sourceKey}/jobs`) + .where("interactiveId", "==", context.interactiveId); + + // Scope to this specific user/run to avoid cross-user data leakage + if (context.user_type === "authenticated" && context.platform_user_id) { + query = query.where("platform_user_id", "==", context.platform_user_id); + } else if (context.user_type === "anonymous" && context.run_key) { + query = query.where("run_key", "==", context.run_key); + } + + const snapshot = await query.get(); + const jobs: IJobInfo[] = snapshot.docs + .map(doc => doc.data()?.jobInfo as IJobInfo) + .sort((a, b) => a.createdAt - b.createdAt); + + // Set up listeners for non-final backfilled jobs so status updates arrive + for (const job of jobs) { + if (job.status === "queued" || job.status === "running") { + this.jobIdToInteractiveId.set(job.id, context.interactiveId); + this.subscribeToJob(job.id); + } + } + + return jobs; + } catch (error) { + console.error("[FirebaseJobExecutor] getJobs failed:", error); + return []; + } + } + + onJobUpdate(callback: (job: IJobInfo) => void): void { + this.updateCallback = callback; + } + + /** + * Not part of IJobExecutor — called by iframe-runtime.tsx cleanup alongside + * jobManager.removeInteractive(id) to tear down Firestore listeners for all + * jobs that belong to the given interactive. + */ + removeInteractive(interactiveId: string): void { + for (const [jobId, id] of Array.from(this.jobIdToInteractiveId.entries())) { + if (id === interactiveId) { + this.jobListeners.get(jobId)?.(); + this.jobListeners.delete(jobId); + this.jobIdToInteractiveId.delete(jobId); + } + } + } + + private subscribeToJob(jobId: string): void { + if (!this.config) return; + // Don't create duplicate listeners + if (this.jobListeners.has(jobId)) return; + + const { sourceKey } = this.config.portalData.database; + const db = getFirestoreDb(); + + const unsubscribe = db + .doc(`sources/${sourceKey}/jobs/${jobId}`) + .onSnapshot( + snapshot => { + if (!snapshot.exists) return; + const job = snapshot.data()?.jobInfo as IJobInfo; + this.updateCallback?.(job); + // Auto-clean listener once job reaches a final state + if (job.status !== "queued" && job.status !== "running") { + unsubscribe(); + this.jobListeners.delete(jobId); + this.jobIdToInteractiveId.delete(jobId); + } + }, + error => { + // Emit a failure update so the interactive doesn't hang indefinitely + const failureJob: IJobInfo = { + version: 1, + id: jobId, + status: "failure", + request: { task: "" }, + result: { message: `Listener error: ${error.message}` }, + createdAt: Date.now(), + completedAt: Date.now(), + }; + this.updateCallback?.(failureJob); + this.jobListeners.delete(jobId); + this.jobIdToInteractiveId.delete(jobId); + } + ); + + this.jobListeners.set(jobId, unsubscribe); + } + + private makeFailureJob( + request: { task: string } & Record, + message: string + ): IJobInfo { + const now = Date.now(); + return { + version: 1, + id: `failure-${now}-${Math.random().toString(36).slice(2)}`, + status: "failure", + request, + result: { message }, + createdAt: now, + completedAt: now, + }; + } +} + +export const firebaseJobExecutor = new FirebaseJobExecutor(); + +export const configure = (config: IConfig): void => + firebaseJobExecutor.configure(config); From 1b8e69904956665ae31bdab407d020e31480f32f Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Thu, 12 Mar 2026 15:43:19 -0400 Subject: [PATCH 2/4] fix: add legacy-peer-deps to resolve pre-release lara-interactive-api peer conflict @concord-consortium/dynamic-text@1.0.6 requires lara-interactive-api@">=1.8.0" as a peer dependency, but npm 7+ excludes pre-release versions from semver ranges, causing CI to fail on npm ci. legacy-peer-deps restores the old resolution behavior until stable versions of interactive-api-host and lara-interactive-api ship. --- .npmrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.npmrc b/.npmrc index b6f27f13..d5831dd5 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ engine-strict=true +legacy-peer-deps=true From 9a94672b99efc590f3745bdcc0d496bb8037f443 Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Thu, 12 Mar 2026 16:12:03 -0400 Subject: [PATCH 3/4] fix: address Copilot review comments on firebase-job-executor - Guard useEmulator with typeof window check for non-browser safety - Return [] from getJobs when no valid user scope (user_type + identity field) is present to prevent cross-user job data exposure - Filter out documents missing jobInfo before sorting in getJobs - Guard snapshot handler against missing/incomplete jobInfo - Add clear error message to getFirestoreDb when app not initialized - Update tests to pass complete user context to getJobs --- src/firebase-db.ts | 7 ++++++- src/firebase-job-executor.test.ts | 4 ++-- src/firebase-job-executor.ts | 21 +++++++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/firebase-db.ts b/src/firebase-db.ts index 6d79d2ec..d7501797 100644 --- a/src/firebase-db.ts +++ b/src/firebase-db.ts @@ -110,7 +110,12 @@ export const onFirestoreSaveAfterTimeout = (handler: () => void) => { }; let app: firebase.app.App; -export const getFirestoreDb = (): firebase.firestore.Firestore => app.firestore(); +export const getFirestoreDb = (): firebase.firestore.Firestore => { + if (!app) { + throw new Error("Firebase app has not been initialized. Call initializeDB before calling getFirestoreDb."); + } + return app.firestore(); +}; export const getConfiguration = (name?: FirebaseAppName): IConfig => { return name ? configurations[name] : configurations["report-service-dev"]; diff --git a/src/firebase-job-executor.test.ts b/src/firebase-job-executor.test.ts index de616f16..5d04dc34 100644 --- a/src/firebase-job-executor.test.ts +++ b/src/firebase-job-executor.test.ts @@ -221,14 +221,14 @@ describe("FirebaseJobExecutor", () => { it("does NOT set up listener for final backfilled jobs", async () => { const successJob = makeJobInfo({ status: "success" }); mockQueryGet.mockResolvedValue({ docs: [{ data: () => ({ jobInfo: successJob }) }] }); - await firebaseJobExecutor.getJobs({ interactiveId: "i-1" }); + await firebaseJobExecutor.getJobs({ interactiveId: "i-1", user_type: "authenticated", platform_user_id: "42" }); expect(mockOnSnapshot).not.toHaveBeenCalled(); }); it("returns empty array and logs error on Firestore failure", async () => { const consoleSpy = jest.spyOn(console, "error").mockImplementation(jest.fn()); mockQueryGet.mockRejectedValue(new Error("Permission denied")); - const jobs = await firebaseJobExecutor.getJobs({ interactiveId: "i-1" }); + const jobs = await firebaseJobExecutor.getJobs({ interactiveId: "i-1", user_type: "authenticated", platform_user_id: "42" }); expect(jobs).toEqual([]); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining("getJobs failed"), diff --git a/src/firebase-job-executor.ts b/src/firebase-job-executor.ts index bc14d737..911d1814 100644 --- a/src/firebase-job-executor.ts +++ b/src/firebase-job-executor.ts @@ -2,7 +2,10 @@ import { IJobExecutor, IJobInfo } from "@concord-consortium/interactive-api-host import { IPortalData, IAnonymousPortalData } from "./portal-types"; import { getFirestoreDb } from "./firebase-db"; -const useEmulator = (new URLSearchParams(window.location.search)).get("emulator") === "true"; +const useEmulator = + typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("emulator") === "true" + : false; if (useEmulator) { console.warn("[FirebaseJobExecutor] Using Firebase Functions emulator at localhost:5001"); } @@ -164,16 +167,25 @@ class FirebaseJobExecutor implements IJobExecutor { .collection(`sources/${sourceKey}/jobs`) .where("interactiveId", "==", context.interactiveId); - // Scope to this specific user/run to avoid cross-user data leakage + // Scope to this specific user/run to avoid cross-user data leakage. + // If we cannot determine a valid user identity, do not query at all. + let hasUserScope = false; if (context.user_type === "authenticated" && context.platform_user_id) { query = query.where("platform_user_id", "==", context.platform_user_id); + hasUserScope = true; } else if (context.user_type === "anonymous" && context.run_key) { query = query.where("run_key", "==", context.run_key); + hasUserScope = true; + } + + if (!hasUserScope) { + return []; } const snapshot = await query.get(); const jobs: IJobInfo[] = snapshot.docs - .map(doc => doc.data()?.jobInfo as IJobInfo) + .map(doc => doc.data()?.jobInfo as IJobInfo | undefined) + .filter((job): job is IJobInfo => !!job && typeof job.createdAt === "number") .sort((a, b) => a.createdAt - b.createdAt); // Set up listeners for non-final backfilled jobs so status updates arrive @@ -223,7 +235,8 @@ class FirebaseJobExecutor implements IJobExecutor { .onSnapshot( snapshot => { if (!snapshot.exists) return; - const job = snapshot.data()?.jobInfo as IJobInfo; + const job = snapshot.data()?.jobInfo as IJobInfo | undefined; + if (!job || !job.status) return; this.updateCallback?.(job); // Auto-clean listener once job reaches a final state if (job.status !== "queued" && job.status !== "running") { From 17ddf4c6d98c1b20de8acff06f4c74507aa81b36 Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Fri, 13 Mar 2026 10:50:38 -0400 Subject: [PATCH 4/4] fix: add platform_id and context_id to getJobs query for authenticated users Firestore's emulator evaluates list security rules using query constraints rather than document field values. The learnerOwner() rule checks platform_user_id and platform_id; adding both (plus context_id to match the getAnswerDocsQuery pattern) gives the emulator enough constraint to approve the query without hitting "Property X is undefined" errors. --- src/firebase-job-executor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/firebase-job-executor.ts b/src/firebase-job-executor.ts index 911d1814..3bd3c6e9 100644 --- a/src/firebase-job-executor.ts +++ b/src/firebase-job-executor.ts @@ -171,7 +171,10 @@ class FirebaseJobExecutor implements IJobExecutor { // If we cannot determine a valid user identity, do not query at all. let hasUserScope = false; if (context.user_type === "authenticated" && context.platform_user_id) { - query = query.where("platform_user_id", "==", context.platform_user_id); + query = query + .where("platform_user_id", "==", context.platform_user_id) + .where("platform_id", "==", context.platform_id) + .where("context_id", "==", context.context_id); hasUserScope = true; } else if (context.user_type === "anonymous" && context.run_key) { query = query.where("run_key", "==", context.run_key);