diff --git a/__tests__/base-url.test.js b/__tests__/base-url.test.js
new file mode 100644
index 0000000..f91adf6
--- /dev/null
+++ b/__tests__/base-url.test.js
@@ -0,0 +1,39 @@
+/**
+ * @jest-environment node
+ *
+ * The deployment URL shown in the API reference and threaded into the code
+ * samples (#246). The test site used to hand out samples that wrote to
+ * production, because nothing read NEXT_PUBLIC_BASE_URL.
+ */
+
+import {
+ PRODUCTION_BASE_URL,
+ resolveBaseURL,
+ sampleBaseURL,
+} from "../lib/base-url";
+
+describe("resolveBaseURL", () => {
+ it("uses the configured deployment", () => {
+ expect(resolveBaseURL("https://datapipe-test.web.app")).toBe("https://datapipe-test.web.app");
+ });
+
+ it("drops trailing slashes so `${base}/api/...` never doubles one", () => {
+ expect(resolveBaseURL("https://datapipe-test.web.app//")).toBe("https://datapipe-test.web.app");
+ });
+
+ it("falls back to production when unset or blank", () => {
+ expect(resolveBaseURL(undefined)).toBe(PRODUCTION_BASE_URL);
+ expect(resolveBaseURL("")).toBe(PRODUCTION_BASE_URL);
+ expect(resolveBaseURL(" / ")).toBe(PRODUCTION_BASE_URL);
+ });
+});
+
+describe("sampleBaseURL", () => {
+ it("adds no override on production, where the packages' default is right", () => {
+ expect(sampleBaseURL(PRODUCTION_BASE_URL)).toBeNull();
+ });
+
+ it("names every other deployment explicitly", () => {
+ expect(sampleBaseURL("https://datapipe-test.web.app")).toBe("https://datapipe-test.web.app");
+ });
+});
diff --git a/__tests__/extension-snippet.test.js b/__tests__/extension-snippet.test.js
index fc4ddf8..61cff14 100644
--- a/__tests__/extension-snippet.test.js
+++ b/__tests__/extension-snippet.test.js
@@ -71,6 +71,13 @@ describe("the jsPsych code sample", () => {
expect(calls.run).toHaveLength(1);
});
+ it("passes base_url to the extension when the build is not production", async () => {
+ const { calls } = await execute(extensionSnippet("EXP123", "https://datapipe-test.web.app"));
+ expect(calls.initialize).toEqual([
+ { experiment_id: "EXP123", filename: "p42.csv", base_url: "https://datapipe-test.web.app" },
+ ]);
+ });
+
it("appends no save trial: the extension owns the submission", async () => {
const { calls } = await execute(code);
expect(calls.run[0]).toHaveLength(0);
diff --git a/components/dashboard/CodeHints.js b/components/dashboard/CodeHints.js
index ed0ad20..b7bd3cf 100644
--- a/components/dashboard/CodeHints.js
+++ b/components/dashboard/CodeHints.js
@@ -13,6 +13,14 @@ import { ChevronDown } from "lucide-react";
import CodeBlock from "../CodeBlock";
import { extensionSnippet } from "./extension-snippet";
import { EXTENSION_PIPE_SCRIPT, DATAPIPE_CLIENT_SCRIPT } from "./script-tags";
+import { SAMPLE_BASE_URL } from "../../lib/base-url";
+
+// Off production, every sample names this build's deployment. The published
+// packages default to pipe.jspsych.org, so a sample copied from the test site
+// without an override sends its data to production (#246).
+const EXTENSION_OPTIONS = SAMPLE_BASE_URL ? `, { base_url: "${SAMPLE_BASE_URL}" }` : "";
+const CLIENT_OPTION = SAMPLE_BASE_URL ? `\n baseURL: "${SAMPLE_BASE_URL}",` : "";
+const CLIENT_INLINE_OPTION = SAMPLE_BASE_URL ? `, baseURL: "${SAMPLE_BASE_URL}"` : "";
export default function CodeHints({ expId }) {
const [language, setLanguage] = useState("jsPsych v8");
@@ -92,7 +100,7 @@ export default function CodeHints({ expId }) {
{EXTENSION_PIPE_SCRIPT}
- {extensionSnippet(expId)}
+ {extensionSnippet(expId, SAMPLE_BASE_URL)}
Each trial is sent as it happens, so a participant who closes the tab partway through does not take all of their data with them: their completed trials arrive as a separate file ending in .partial.json, and do not count toward your session limit. A participant who finishes produces one ordinary file.
@@ -117,7 +125,7 @@ export default function CodeHints({ expId }) {
recording_duration: 15000,
on_finish: async function(data){
const filename = \`\${subject_id}_\${jsPsych.getProgress().current_trial_global}_audio.webm\`;
- await jsPsychExtensionPipe.saveBase64Data("${expId}", filename, data.response);
+ await jsPsychExtensionPipe.saveBase64Data("${expId}", filename, data.response${EXTENSION_OPTIONS});
data.response = filename;
}
};`}
@@ -140,7 +148,7 @@ export default function CodeHints({ expId }) {
async function createExperiment(){
let condition;
try {
- condition = await jsPsychExtensionPipe.getCondition("${expId}");
+ condition = await jsPsychExtensionPipe.getCondition("${expId}"${EXTENSION_OPTIONS});
} catch (error) {
document.body.innerHTML = "
The experiment could not be started.
";
throw error;
@@ -181,7 +189,7 @@ export default function CodeHints({ expId }) {
const result = await DataPipe.saveData({
experiment_id: "${expId}",
filename: "UNIQUE_FILENAME.csv",
- data: dataAsString,
+ data: dataAsString,${CLIENT_OPTION}
});
if (!result.ok) {
@@ -209,7 +217,7 @@ export default function CodeHints({ expId }) {
const filename = "UNIQUE_FILENAME.csv";
const session = DataPipe.createSession({
experiment_id: "${expId}",
- filename: filename,
+ filename: filename,${CLIENT_OPTION}
});
// ...after each trial:
@@ -220,7 +228,7 @@ export default function CodeHints({ expId }) {
experiment_id: "${expId}",
filename: filename,
data: dataAsString,
- session: session,
+ session: session,${CLIENT_OPTION}
});
await session.close({ submitted: result.ok });`}
@@ -242,7 +250,7 @@ export default function CodeHints({ expId }) {
const result = await DataPipe.saveBase64Data({
experiment_id: "${expId}",
filename: "UNIQUE_FILENAME.webm",
- data: base64DataString,
+ data: base64DataString,${CLIENT_OPTION}
});`}
@@ -259,7 +267,7 @@ export default function CodeHints({ expId }) {
{`
let condition;
try {
- condition = await DataPipe.getCondition({ experiment_id: "${expId}" });
+ condition = await DataPipe.getCondition({ experiment_id: "${expId}"${CLIENT_INLINE_OPTION} });
} catch (error) {
document.body.innerHTML = "The experiment could not be started.
";
throw error;
diff --git a/components/dashboard/extension-snippet.js b/components/dashboard/extension-snippet.js
index 0f02d13..1a69d67 100644
--- a/components/dashboard/extension-snippet.js
+++ b/components/dashboard/extension-snippet.js
@@ -17,16 +17,21 @@
// which time the declaration has run. The test covers this, because a reader
// copying the sample is likely to "simplify" it back into a string.
//
+// `baseURL` is set only on builds that are not production (lib/base-url.js),
+// where the extension's built-in default would send the data to the wrong
+// deployment.
+//
// Flush-left on purpose: CodeBlock strips the first line's indentation from
// every line, and there is none here to strip.
-export function extensionSnippet(expId) {
+export function extensionSnippet(expId, baseURL = null) {
+ const baseURLParam = baseURL ? `,\n base_url: "${baseURL}"` : "";
return `const jsPsych = initJsPsych({
extensions: [
{
type: jsPsychExtensionPipe,
params: {
experiment_id: "${expId}",
- filename: () => \`\${subject_id}.csv\`
+ filename: () => \`\${subject_id}.csv\`${baseURLParam}
}
}
]
diff --git a/components/docs/ApiPrimitives.js b/components/docs/ApiPrimitives.js
index f2c2bae..649e8ca 100644
--- a/components/docs/ApiPrimitives.js
+++ b/components/docs/ApiPrimitives.js
@@ -1,4 +1,5 @@
import { Stack, Heading, Text, Table, Badge, Code } from "@chakra-ui/react";
+import { BASE_URL } from "../../lib/base-url";
/**
* ApiPrimitives
@@ -12,8 +13,10 @@ import { Stack, Heading, Text, Table, Badge, Code } from "@chakra-ui/react";
*
* Package D (docs IA plan §4) reuses these unchanged for /docs/api's moved
* API reference content.
+ *
+ * BASE_URL is the deployment this build serves (lib/base-url.js), so the test
+ * site's reference names the test site rather than production.
*/
-export const BASE_URL = "https://pipe.jspsych.org";
export function EndpointHeading({ method, path, children }) {
return (
diff --git a/functions/src/__tests__/create-experiment-log-redaction.test.js b/functions/src/__tests__/create-experiment-log-redaction.test.js
new file mode 100644
index 0000000..8d639c7
--- /dev/null
+++ b/functions/src/__tests__/create-experiment-log-redaction.test.js
@@ -0,0 +1,58 @@
+/**
+ * @jest-environment node
+ *
+ * createExperimentHandler logs a failed createDataContainer to Cloud Logging.
+ * Auth rides in request headers, but a provider can still echo the token in
+ * its error text (a Dataverse installation answering "Bad api key "), so
+ * the logged stack must be scrubbed. The 502 detail still goes back to the
+ * researcher unchanged: it is their own token, on their own request.
+ */
+
+const TOKEN = "dv-secret-token-1234";
+
+jest.mock("../../lib/app.js", () => ({
+ db: { doc: () => ({ get: async () => ({ data: () => ({}) }) }) },
+}));
+jest.mock("../../lib/connect-provider.js", () => ({
+ verifyOwnership: async () => ({ ok: true }),
+}));
+jest.mock("../../lib/resolve-token.js", () => ({
+ __esModule: true,
+ default: async () => ({ success: true, token: TOKEN, serverUrl: "https://dataverse.mock.test" }),
+}));
+jest.mock("../../lib/providers/index.js", () => ({
+ listProviders: () => ["dataverse"],
+ getProvider: () => ({
+ containerInput: [],
+ createDataContainer: async () => {
+ throw new Error(`Dataverse dataset creation failed: 401 Bad api key ${TOKEN}`);
+ },
+ }),
+}));
+
+const { createExperimentHandler } = require("../../lib/create-experiment.js");
+
+function mockRes() {
+ const res = {};
+ res.status = jest.fn(() => res);
+ res.json = jest.fn(() => res);
+ return res;
+}
+
+it("scrubs the provider token from the logged error", async () => {
+ const error = jest.spyOn(console, "error").mockImplementation(() => {});
+ const res = mockRes();
+
+ await createExperimentHandler(
+ { method: "POST", body: { provider: "dataverse", title: "t", uid: "u1", idToken: "x" } },
+ res
+ );
+
+ expect(res.status).toHaveBeenCalledWith(502);
+ expect(error).toHaveBeenCalledTimes(1);
+ const logged = JSON.stringify(error.mock.calls[0]);
+ expect(logged).not.toContain(TOKEN);
+ expect(logged).toContain("Bad api key [redacted]");
+ expect(logged).toContain("create-experiment"); // the stack survives
+ error.mockRestore();
+});
diff --git a/functions/src/__tests__/experiment-id-validation-emulator.test.js b/functions/src/__tests__/experiment-id-validation-emulator.test.js
new file mode 100644
index 0000000..2b1ce03
--- /dev/null
+++ b/functions/src/__tests__/experiment-id-validation-emulator.test.js
@@ -0,0 +1,245 @@
+/**
+ * @jest-environment node
+ *
+ * Regression coverage for the reserved/invalid-experimentID 500 bug.
+ *
+ * Production logs showed a participant site POSTing
+ * experimentID: "__DATAPIPE_STUDY1_ID__" -- an unfilled template placeholder
+ * -- to /api/data. db.collection("experiments").doc(experimentID).get()
+ * throws "3 INVALID_ARGUMENT: Resource id ... is invalid because it is
+ * reserved" for that shape, and the throw escaped as an unhandled 500 instead
+ * of the ordinary 400 EXPERIMENT_NOT_FOUND a nonexistent-but-valid id already
+ * gets.
+ *
+ * getExperiment() (experiment-id.ts) -- built on isValidDocumentId() -- now
+ * gates every endpoint that looks an experiment up by a client-supplied id
+ * before the Firestore call that would otherwise throw, so each endpoint
+ * folds the reserved-id case into the not-found branch it already had. This
+ * suite covers all of them:
+ * - the unauthenticated, participant-facing endpoints that answer 400
+ * EXPERIMENT_NOT_FOUND for both a reserved id and a nonexistent one:
+ * /api/data, /api/base64, /api/condition, /api/session.
+ * - the authenticated, researcher-facing dashboard endpoints that answer
+ * 403 Access denied for a reserved id, a nonexistent id, and someone
+ * else's experiment alike (the same convention each of them already used
+ * for "doesn't exist" vs. "not yours"): /api/finalize, /api/clearerrors,
+ * /api/ensurederivedpaths, and /api/queuestatus's own experimentID
+ * parameter.
+ * - /api/queuestatus's separate `download` query parameter, which is
+ * checked with the same isValidDocumentId() gate but answers its own
+ * shape (404 "Queue entry not found") since a queue entry, not the
+ * experiment, is what a reserved or slash-containing id there would
+ * otherwise 500 trying to look up.
+ */
+
+import { initializeApp, getApp } from "firebase-admin/app";
+import { getFirestore } from "firebase-admin/firestore";
+import { randomUUID } from "crypto";
+import MESSAGES from "../api-messages";
+import { fnUrl } from "./helpers/fn-url.js";
+
+process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";
+
+const config = { projectId: "datapipe-test" };
+const AUTH_EMULATOR_SIGNUP_URL =
+ "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake";
+
+// Exactly the placeholder observed in production logs -- also exactly
+// Firestore's reserved /^__.*__$/ shape.
+const RESERVED_ID = "__DATAPIPE_STUDY1_ID__";
+// A shorter reserved id, used for api-queue-status's `download` param below
+// -- any /^__.*__$/ shape triggers the same Firestore throw, so this only
+// needs to be reserved, not the exact production placeholder.
+const RESERVED_DOWNLOAD_ID = "__x__";
+
+jest.setTimeout(30000);
+
+let db;
+
+beforeAll(() => {
+ let app;
+ try {
+ app = getApp("experiment-id-validation-test");
+ } catch {
+ app = initializeApp(config, "experiment-id-validation-test");
+ }
+ db = getFirestore(app);
+});
+
+async function signUpEmulatorUser() {
+ const email = `experiment-id-validation-${randomUUID()}@example.test`;
+ const res = await fetch(AUTH_EMULATOR_SIGNUP_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password: "Password123!", returnSecureToken: true }),
+ });
+ const body = await res.json();
+ if (!res.ok) {
+ throw new Error(`Auth emulator signUp failed (${res.status}): ${JSON.stringify(body)}`);
+ }
+ return { uid: body.localId, idToken: body.idToken };
+}
+
+async function postJSON(url, body, idToken) {
+ const headers = { "Content-Type": "application/json", Accept: "*/*" };
+ if (idToken !== undefined) {
+ headers.Authorization = `Bearer ${idToken}`;
+ }
+ const response = await fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+ const message = await response.json();
+ return { status: response.status, body: message };
+}
+
+async function getJSON(url, idToken) {
+ const response = await fetch(url, {
+ headers: idToken !== undefined ? { Authorization: `Bearer ${idToken}` } : {},
+ });
+ const message = await response.json();
+ return { status: response.status, body: message };
+}
+
+describe("reserved/invalid experimentID does not 500 (unauthenticated participant endpoints)", () => {
+ it("POST /api/data returns 400 EXPERIMENT_NOT_FOUND, not a 500", async () => {
+ const { status, body } = await postJSON(fnUrl("/api/data"), {
+ experimentID: RESERVED_ID,
+ filename: "data.csv",
+ data: "trial_type\nhtml-keyboard-response\n",
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(MESSAGES.EXPERIMENT_NOT_FOUND);
+ });
+
+ it("POST /api/base64 returns 400 EXPERIMENT_NOT_FOUND, not a 500", async () => {
+ const { status, body } = await postJSON(fnUrl("/api/base64"), {
+ experimentID: RESERVED_ID,
+ filename: "image.png",
+ data: "data:image/png;base64,aGVsbG8=",
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(MESSAGES.EXPERIMENT_NOT_FOUND);
+ });
+
+ it("POST /api/condition returns 400 EXPERIMENT_NOT_FOUND, not a 500", async () => {
+ const { status, body } = await postJSON(fnUrl("/api/condition"), {
+ experimentID: RESERVED_ID,
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(MESSAGES.EXPERIMENT_NOT_FOUND);
+ });
+
+ it("POST /api/session returns 400 EXPERIMENT_NOT_FOUND, not a 500", async () => {
+ const { status, body } = await postJSON(fnUrl("/api/session"), {
+ experimentID: RESERVED_ID,
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(MESSAGES.EXPERIMENT_NOT_FOUND);
+ });
+});
+
+describe("reserved/invalid experimentID does not 500 (authenticated dashboard endpoints)", () => {
+ it("POST /api/finalize returns 403 Access denied, not a 500", async () => {
+ const { idToken } = await signUpEmulatorUser();
+ const { status, body } = await postJSON(
+ fnUrl("/api/finalize"),
+ { experimentID: RESERVED_ID },
+ idToken
+ );
+
+ expect(status).toBe(403);
+ expect(body).toEqual({ error: "Access denied" });
+ });
+
+ it("POST /api/clearerrors returns 403 Access denied, not a 500", async () => {
+ const { idToken } = await signUpEmulatorUser();
+ const { status, body } = await postJSON(
+ fnUrl("/api/clearerrors"),
+ { experimentID: RESERVED_ID },
+ idToken
+ );
+
+ expect(status).toBe(403);
+ expect(body).toEqual({ error: "Access denied" });
+ });
+
+ it("POST /api/ensurederivedpaths returns 403 Access denied, not a 500", async () => {
+ const { idToken } = await signUpEmulatorUser();
+ const { status, body } = await postJSON(
+ fnUrl("/api/ensurederivedpaths"),
+ { experimentID: RESERVED_ID },
+ idToken
+ );
+
+ expect(status).toBe(403);
+ expect(body).toEqual({ error: "Access denied" });
+ });
+
+ it("GET /api/queuestatus?experimentID=__X__ returns 403 Access denied, not a 500", async () => {
+ const { idToken } = await signUpEmulatorUser();
+ const { status, body } = await getJSON(
+ `${fnUrl("/api/queuestatus")}?experimentID=${RESERVED_ID}`,
+ idToken
+ );
+
+ expect(status).toBe(403);
+ expect(body).toEqual({ error: "Access denied" });
+ });
+});
+
+describe("api-queue-status: reserved/slash-containing `download` id does not 500", () => {
+ const createdExperimentIds = [];
+
+ afterEach(async () => {
+ if (createdExperimentIds.length === 0) return;
+ const batch = db.batch();
+ for (const experimentID of createdExperimentIds) {
+ batch.delete(db.collection("experiments").doc(experimentID));
+ }
+ await batch.commit();
+ createdExperimentIds.length = 0;
+ });
+
+ async function seedOwnedExperiment(uid) {
+ const experimentID = `queue-status-reserved-download-${randomUUID()}`;
+ createdExperimentIds.push(experimentID);
+ await db.collection("experiments").doc(experimentID).set({
+ owner: uid,
+ active: true,
+ storageProvider: "gdrive",
+ });
+ return experimentID;
+ }
+
+ it("returns 404 Queue entry not found for a reserved __x__ download id, on an experiment the caller owns", async () => {
+ const { uid, idToken } = await signUpEmulatorUser();
+ const experimentID = await seedOwnedExperiment(uid);
+
+ const { status, body } = await getJSON(
+ `${fnUrl("/api/queuestatus")}?experimentID=${experimentID}&download=${RESERVED_DOWNLOAD_ID}`,
+ idToken
+ );
+
+ expect(status).toBe(404);
+ expect(body).toEqual({ error: "Queue entry not found" });
+ });
+
+ it("returns 404 Queue entry not found for a slash-containing download id (a%2Fb), on an experiment the caller owns", async () => {
+ const { uid, idToken } = await signUpEmulatorUser();
+ const experimentID = await seedOwnedExperiment(uid);
+
+ const { status, body } = await getJSON(
+ `${fnUrl("/api/queuestatus")}?experimentID=${experimentID}&download=${encodeURIComponent("a/b")}`,
+ idToken
+ );
+
+ expect(status).toBe(404);
+ expect(body).toEqual({ error: "Queue entry not found" });
+ });
+});
diff --git a/functions/src/__tests__/experiment-id-validation.test.js b/functions/src/__tests__/experiment-id-validation.test.js
new file mode 100644
index 0000000..740bcd8
--- /dev/null
+++ b/functions/src/__tests__/experiment-id-validation.test.js
@@ -0,0 +1,73 @@
+/**
+ * @jest-environment node
+ *
+ * isValidDocumentId (experiment-id.ts) -- the gate between a client-supplied
+ * id and db.collection(...).doc(id). Named for what it actually checks (any
+ * Firestore document id), not just the experiments collection: api-queue-
+ * status.ts runs its `download` queue-entry id through the same check, and
+ * write-log.ts its logs/{experimentID} id. It was renamed from
+ * isValidExperimentId to isValidDocumentId to reflect that; the logic itself
+ * is unchanged.
+ *
+ * The bug this guards against: Firestore does not treat an invalid document
+ * id as a lookup miss, it THROWS synchronously out of doc()/get() for a
+ * handful of specific shapes -- most commonly a participant site that never
+ * filled in a template placeholder, e.g. "__DATAPIPE_STUDY1_ID__", which
+ * matches Firestore's own reserved __...__ pattern. That throw was escaping
+ * every public endpoint that looked an experiment up by id as an unhandled
+ * 500, instead of the ordinary 400 EXPERIMENT_NOT_FOUND a nonexistent id
+ * already gets.
+ *
+ * Unlike isValidSessionId (staging.ts), this is deliberately NOT pinned to
+ * the nanoid alphabet create-experiment.ts mints new ids from -- older
+ * experiment ids may use other formats, so this only has to reject what
+ * Firestore itself would reject.
+ */
+
+const { isValidDocumentId } = require("../../lib/experiment-id.js");
+
+describe("isValidDocumentId", () => {
+ it.each([
+ ["a 12-char nanoid-style id, as create-experiment.ts mints", "aB3xY9kLm2Qz"],
+ ["an id with mixed alphanumeric, hyphen and underscore characters", "abc-DEF_123"],
+ ])("accepts %s", (_label, value) => {
+ expect(isValidDocumentId(value)).toBe(true);
+ });
+
+ it.each([
+ ["the empty string", ""],
+ ["a reserved __...__ id (the unfilled-template-placeholder case)", "__DATAPIPE_STUDY1_ID__"],
+ ["a reserved __...__ id with nothing in between", "____"],
+ ["an id containing a forward slash", "abc/def"],
+ ["a leading slash", "/abc"],
+ ["exactly a single period", "."],
+ ["exactly a double period", ".."],
+ ["longer than 1500 bytes in UTF-8", "a".repeat(1501)],
+ // A multi-byte character pushes this over 1500 BYTES despite being under
+ // 1500 JS string characters -- proves the check is byte-length, not
+ // string-length.
+ ["over 1500 bytes via multi-byte characters, under 1500 JS characters", "é".repeat(751)],
+ ])("rejects %s", (_label, value) => {
+ expect(isValidDocumentId(value)).toBe(false);
+ });
+
+ it.each([
+ ["null", null],
+ ["undefined", undefined],
+ ["a number", 123],
+ ["a plain object", {}],
+ ["an array", ["a"]],
+ ["a boolean", true],
+ ])("rejects %s (not a string)", (_label, value) => {
+ expect(isValidDocumentId(value)).toBe(false);
+ });
+
+ it("accepts an id exactly at the 1500-byte boundary", () => {
+ expect(isValidDocumentId("a".repeat(1500))).toBe(true);
+ });
+
+ it("accepts a period or double period as part of a longer id, not standing alone", () => {
+ expect(isValidDocumentId("a.b")).toBe(true);
+ expect(isValidDocumentId("a..b")).toBe(true);
+ });
+});
diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js
index f9d0284..3f6ae89 100644
--- a/functions/src/__tests__/providers-dataverse.test.js
+++ b/functions/src/__tests__/providers-dataverse.test.js
@@ -9,6 +9,8 @@
// and the docblock + node-fetch mock convention is kept for consistency with
// every other adapter suite (see commit 0664bd5/313abbf/9008f67).
+import { Readable } from "stream";
+
const mockFetch = jest.fn();
jest.mock("node-fetch", () => ({
@@ -30,6 +32,7 @@ function mockResponse({ status, statusText, jsonBody, textBody }) {
statusText,
json: () => Promise.resolve(jsonBody),
text: () => Promise.resolve(textBody),
+ body: textBody === undefined ? null : Readable.from([Buffer.from(textBody)]),
};
}
@@ -1351,4 +1354,83 @@ describe("9. validateStaticToken", () => {
expect(result).toBe(false);
});
+
+ it("logs the status and body of a non-200, with the token scrubbed", async () => {
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ mockFetch.mockResolvedValueOnce(
+ mockResponse({
+ status: 403,
+ statusText: "Forbidden",
+ textBody: '{"status":"ERROR","message":"Bad api key test-token"}',
+ })
+ );
+
+ await dataverseProvider.validateStaticToken(auth);
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ const [message] = warn.mock.calls[0];
+ expect(message).toContain("403");
+ expect(message).toContain(SERVER_URL);
+ expect(message).toContain("Bad api key [redacted]");
+ expect(message).not.toContain("test-token");
+ warn.mockRestore();
+ });
+
+ it("reads only a bounded prefix of a huge body and drops the connection", async () => {
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ let pulled = 0;
+ // An endless 64KB-chunk body: text() would never finish buffering it.
+ const body = new Readable({
+ read() {
+ pulled += 1;
+ this.push(Buffer.alloc(64 * 1024, "x"));
+ },
+ });
+ mockFetch.mockResolvedValueOnce({ status: 403, statusText: "Forbidden", body });
+
+ const result = await dataverseProvider.validateStaticToken(auth);
+
+ expect(result).toBe(false);
+ expect(body.destroyed).toBe(true);
+ expect(pulled).toBeLessThan(5);
+ const [message] = warn.mock.calls[0];
+ expect(message.length).toBeLessThan(500);
+ warn.mockRestore();
+ });
+
+ it("gives up on a body that stalls, keeping what arrived", async () => {
+ jest.useFakeTimers();
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ const body = new Readable({ read() {} });
+ body.push("partial block page");
+ mockFetch.mockResolvedValueOnce({ status: 503, statusText: "Service Unavailable", body });
+
+ const pending = dataverseProvider.validateStaticToken(auth);
+ await jest.advanceTimersByTimeAsync(5000);
+ const result = await pending;
+
+ expect(result).toBe(false);
+ expect(body.destroyed).toBe(true);
+ expect(warn.mock.calls[0][0]).toContain("503: partial block page");
+ warn.mockRestore();
+ jest.useRealTimers();
+ });
+
+ it("flattens a multi-line body onto one log line", async () => {
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ mockFetch.mockResolvedValueOnce(
+ mockResponse({
+ status: 403,
+ statusText: "Forbidden",
+ textBody: "\n \n Request blocked\n \n",
+ })
+ );
+
+ await dataverseProvider.validateStaticToken(auth);
+
+ const [message] = warn.mock.calls[0];
+ expect(message).not.toContain("\n");
+ expect(message).toContain(" Request blocked ");
+ warn.mockRestore();
+ });
});
diff --git a/functions/src/__tests__/providers-osf.test.js b/functions/src/__tests__/providers-osf.test.js
index 0f21296..edc2fb5 100644
--- a/functions/src/__tests__/providers-osf.test.js
+++ b/functions/src/__tests__/providers-osf.test.js
@@ -152,6 +152,8 @@ describe("osfProvider.listFiles", () => {
it("returns name/id pairs and filters out folder entries", async () => {
mockFetch.mockResolvedValueOnce({
+ status: 200,
+ statusText: "OK",
json: () =>
Promise.resolve({
data: [
@@ -174,6 +176,81 @@ describe("osfProvider.listFiles", () => {
expect.objectContaining({ method: "GET" })
);
});
+
+ // Regression coverage: a non-OK response (403/404/410/429/...) has no
+ // `data` field -- OSF returns a JSON:API error object instead -- so this
+ // must be checked before the body is parsed. Left unchecked,
+ // folder["data"].filter throws an opaque "Cannot read properties of
+ // undefined (reading 'filter')" instead of naming OSF's real status, which
+ // is exactly what reached production through the collision cache as
+ // "Collision cache rehydration failed: ... Cannot read properties of
+ // undefined (reading 'filter')" with no clue it was really an OSF 403.
+ it.each([
+ [403, "Forbidden"],
+ [404, "Not Found"],
+ [410, "Gone"],
+ [429, "Too Many Requests"],
+ ])("throws with the status and statusText on a %i response with no detail in the body", async (status, statusText) => {
+ mockFetch.mockResolvedValueOnce({
+ status,
+ statusText,
+ json: () => Promise.resolve({ errors: [{}] }),
+ });
+
+ await expect(osfProvider.listFiles(auth, container)).rejects.toThrow(
+ `OSF listing failed: ${status} ${statusText}`
+ );
+ });
+
+ // OSF's JSON:API error body carries its own explanation in errors[0].detail
+ // -- surfaced in preference to the generic statusText whenever it's present,
+ // since "403 Forbidden" alone gives a researcher nothing actionable while
+ // OSF's own detail says exactly what went wrong.
+ it("throws with OSF's own errors[0].detail on a 403 response, not just the statusText", async () => {
+ mockFetch.mockResolvedValueOnce({
+ status: 403,
+ statusText: "Forbidden",
+ json: () =>
+ Promise.resolve({
+ errors: [{ detail: "You do not have permission to perform this action." }],
+ }),
+ });
+
+ await expect(osfProvider.listFiles(auth, container)).rejects.toThrow(
+ "OSF listing failed: 403 You do not have permission to perform this action."
+ );
+ });
+
+ // Some error responses (a proxy timeout page, an HTML 5xx) are not JSON at
+ // all -- .json() rejects instead of resolving with a body that merely lacks
+ // `errors`. Must fall back to statusText rather than let that rejection
+ // propagate as an unrelated, confusing failure.
+ it("falls back to statusText when the error body is not JSON", async () => {
+ mockFetch.mockResolvedValueOnce({
+ status: 502,
+ statusText: "Bad Gateway",
+ json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON at position 0")),
+ });
+
+ await expect(osfProvider.listFiles(auth, container)).rejects.toThrow(
+ "OSF listing failed: 502 Bad Gateway"
+ );
+ });
+
+ // Same failure class as above, but with no status code to blame: a 200
+ // whose body doesn't have the expected `data` array. Must not crash inside
+ // the filter/map with an unhelpful TypeError.
+ it("throws a clear error on a 200 response with no `data` array", async () => {
+ mockFetch.mockResolvedValueOnce({
+ status: 200,
+ statusText: "OK",
+ json: () => Promise.resolve({ unexpected: "shape" }),
+ });
+
+ await expect(osfProvider.listFiles(auth, container)).rejects.toThrow(
+ "OSF listing failed: response body had no file list"
+ );
+ });
});
// Real experiment documents store osfFilesLink as the osfstorage root's
@@ -197,6 +274,8 @@ describe("osfProvider file URLs against a real osfstorage upload link", () => {
it("updateFile addresses a listFiles id without doubling the provider segment", async () => {
mockFetch.mockResolvedValueOnce({
+ status: 200,
+ statusText: "OK",
json: () =>
Promise.resolve({
data: [
diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts
index 9b35c11..c8ca477 100644
--- a/functions/src/api-base64.ts
+++ b/functions/src/api-base64.ts
@@ -1,7 +1,6 @@
import type { Request } from "firebase-functions/v2/https";
import type { Response } from "express";
import { randomUUID } from "crypto";
-import { DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore";
import { db } from "./app.js";
import writeLog from "./write-log.js";
import isBase64 from "is-base64";
@@ -13,6 +12,7 @@ import { getProviderForExperiment, claimNameFor } from "./providers/index.js";
import { WriteResult, ResolvedAuth } from "./providers/types.js";
import { claimFilename, claimFilenameWithoutCredentials, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js";
import { isCompactionInFlight, COMPACTION_HOLD_REASON } from "./compaction-gate.js";
+import { getExperiment } from "./experiment-id.js";
import { ExperimentData, UserData } from './interfaces';
// NO onRequest OPTIONS HERE ANY MORE. apiBase64Handler used to also back a
@@ -33,15 +33,17 @@ export async function apiBase64Handler(req: Request, res: Response): Promise = db.collection("experiments").doc(experimentID);
- const exp_doc: DocumentSnapshot = await exp_doc_ref.get();
+ // null for a missing experiment AND for an id Firestore would reject
+ // outright (e.g. an unfilled "__X__" placeholder); see experiment-id.ts.
+ const exp_doc = await getExperiment(experimentID);
- if (!exp_doc.exists) {
+ if (!exp_doc) {
res.status(400).json(MESSAGES.EXPERIMENT_NOT_FOUND);
await writeLog(experimentID, "logError", MESSAGES.EXPERIMENT_NOT_FOUND);
return;
}
+ const exp_doc_ref = exp_doc.ref;
const exp_data: ExperimentData = exp_doc.data() as ExperimentData;
if (!exp_data) {
diff --git a/functions/src/api-condition.ts b/functions/src/api-condition.ts
index 1a80bcf..85122b5 100644
--- a/functions/src/api-condition.ts
+++ b/functions/src/api-condition.ts
@@ -1,9 +1,9 @@
import type { Request } from "firebase-functions/v2/https";
import type { Response } from "express";
-import { DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore";
import { db } from "./app.js";
import writeLog from "./write-log.js";
import MESSAGES from "./api-messages.js";
+import { getExperiment } from "./experiment-id.js";
import { ExperimentData } from './interfaces';
// Plain handler, dispatched from participant-api.ts alongside
@@ -18,15 +18,17 @@ export async function apiConditionHandler(req: Request, res: Response): Promise<
return;
}
- const exp_doc_ref: DocumentReference = db.collection("experiments").doc(experimentID);
- const exp_doc: DocumentSnapshot = await exp_doc_ref.get();
+ // null for a missing experiment AND for an id Firestore would reject
+ // outright (e.g. an unfilled "__X__" placeholder); see experiment-id.ts.
+ const exp_doc = await getExperiment(experimentID);
- if (!exp_doc.exists) {
+ if (!exp_doc) {
res.status(400).json(MESSAGES.EXPERIMENT_NOT_FOUND);
await writeLog(experimentID, "logError", MESSAGES.EXPERIMENT_NOT_FOUND);
return;
}
+ const exp_doc_ref = exp_doc.ref;
const exp_data: ExperimentData = exp_doc.data() as ExperimentData;
if (!exp_data) {
diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts
index 23dfddb..1bebe0b 100644
--- a/functions/src/api-data.ts
+++ b/functions/src/api-data.ts
@@ -21,6 +21,7 @@ import { WriteResult, ResolvedAuth } from "./providers/types.js";
import { claimFilename, claimFilenameWithoutCredentials, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js";
import { isCompactionInFlight, COMPACTION_HOLD_REASON } from "./compaction-gate.js";
import { discardSession, isValidSessionId } from "./staging.js";
+import { getExperiment } from "./experiment-id.js";
import { ExperimentData, UserData, RequestBody } from './interfaces';
import { apiBase64Handler } from "./api-base64.js";
@@ -185,16 +186,18 @@ export async function apiDataHandler(req: Request, res: Response): Promise
if (isValidSessionId(sessionId)) await discardSession(sessionId);
};
- const exp_doc_ref: DocumentReference = db.collection("experiments").doc(experimentID);
- const exp_doc: DocumentSnapshot = await exp_doc_ref.get();
+ // null for a missing experiment AND for an id Firestore would reject
+ // outright (e.g. an unfilled "__X__" placeholder); see experiment-id.ts.
+ const exp_doc = await getExperiment(experimentID);
- if (!exp_doc.exists) {
+ if (!exp_doc) {
res.status(400).json(MESSAGES.EXPERIMENT_NOT_FOUND);
await writeLog(experimentID, "logError", MESSAGES.EXPERIMENT_NOT_FOUND);
return;
}
+ const exp_doc_ref = exp_doc.ref;
const exp_data: ExperimentData = exp_doc.data() as ExperimentData;
if (!exp_data) {
diff --git a/functions/src/api-finalize.ts b/functions/src/api-finalize.ts
index 7d8c5cf..9bab8cc 100644
--- a/functions/src/api-finalize.ts
+++ b/functions/src/api-finalize.ts
@@ -48,6 +48,7 @@ import { db, functions } from "./app.js";
import { finalizeExperiment } from "./finalization.js";
import { ExperimentData, FinalizationState } from "./interfaces.js";
import { requireUser } from "./require-user.js";
+import { getExperiment } from "./experiment-id.js";
// Task-queue functions cap at 1800s (see module header). finalizeExperiment
// streams rather than buffers (docs/finalization-spec.md's "why streaming,
@@ -87,16 +88,17 @@ export async function apiFinalizeHandler(req: Request, res: Response): Promise {
if (req.method !== "GET") {
@@ -32,9 +33,10 @@ export const apiQueueStatus = onRequest({ cors: true }, async (req, res) => {
return;
}
- // Verify the user owns this experiment
- const expDoc = await db.doc(`experiments/${experimentID}`).get();
- if (!expDoc.exists || expDoc.data()?.owner !== uid) {
+ // Verify the user owns this experiment. getExperiment's null also covers an
+ // id Firestore would reject outright, which would otherwise throw as a 500.
+ const expDoc = await getExperiment(experimentID);
+ if (!expDoc || expDoc.data()?.owner !== uid) {
res.status(403).json({ error: "Access denied" });
return;
}
@@ -42,7 +44,13 @@ export const apiQueueStatus = onRequest({ cors: true }, async (req, res) => {
const download = req.query.download as string | undefined;
if (download) {
- // Return a signed download URL for a specific queue entry
+ // Return a signed download URL for a specific queue entry. The id is
+ // checked first for the same reason as experimentID: doc() throws on a
+ // reserved "__X__" id, and a "/" would make this a collection path.
+ if (!isValidDocumentId(download)) {
+ res.status(404).json({ error: "Queue entry not found" });
+ return;
+ }
const queueDoc = await db.doc(`uploadQueue/${download}`).get();
if (!queueDoc.exists || queueDoc.data()?.experimentID !== experimentID) {
res.status(404).json({ error: "Queue entry not found" });
diff --git a/functions/src/api-session-start.ts b/functions/src/api-session-start.ts
index 1e9562c..2635b4e 100644
--- a/functions/src/api-session-start.ts
+++ b/functions/src/api-session-start.ts
@@ -45,10 +45,9 @@
import type { Request } from "firebase-functions/v2/https";
import type { Response } from "express";
-import { DocumentSnapshot } from "firebase-admin/firestore";
-import { db } from "./app.js";
import writeLog from "./write-log.js";
import MESSAGES from "./api-messages.js";
+import { getExperiment } from "./experiment-id.js";
import { ExperimentData } from "./interfaces.js";
import {
openSession,
@@ -100,12 +99,11 @@ export async function apiSessionStartHandler(req: Request, res: Response): Promi
return;
}
- const exp_doc: DocumentSnapshot = await db
- .collection("experiments")
- .doc(experimentID)
- .get();
+ // null for a missing experiment AND for an id Firestore would reject
+ // outright (e.g. an unfilled "__X__" placeholder); see experiment-id.ts.
+ const exp_doc = await getExperiment(experimentID);
- if (!exp_doc.exists) {
+ if (!exp_doc) {
res.status(400).json(MESSAGES.EXPERIMENT_NOT_FOUND);
await writeLog(experimentID, "logError", MESSAGES.EXPERIMENT_NOT_FOUND);
return;
diff --git a/functions/src/clear-errors.ts b/functions/src/clear-errors.ts
index dc61462..e7caec8 100644
--- a/functions/src/clear-errors.ts
+++ b/functions/src/clear-errors.ts
@@ -29,10 +29,7 @@ import { FieldValue } from "firebase-admin/firestore";
import { db } from "./app.js";
import MESSAGES from "./api-messages.js";
import { requireUser } from "./require-user.js";
-
-function experimentRef(experimentID: string) {
- return db.collection("experiments").doc(experimentID);
-}
+import { getExperiment } from "./experiment-id.js";
function logsRef(experimentID: string) {
return db.collection("logs").doc(experimentID);
@@ -57,12 +54,13 @@ export async function clearErrorsHandler(req: Request, res: Response): Promise"), so the stack is scrubbed before it
+ // reaches Cloud Logging.
+ const trace = e instanceof Error ? (e.stack ?? e.message) : String(e);
+ const code = (e as { code?: unknown } | null)?.code;
+ console.error(
+ `Error creating storage container for provider ${provider}, user ${uid}:`,
+ redactSecret(trace, auth.token),
+ ...(code !== undefined ? [{ code }] : [])
+ );
res.status(502).json({ error: "Failed to create storage container", detail });
return;
}
diff --git a/functions/src/ensure-derived-paths.ts b/functions/src/ensure-derived-paths.ts
index ed16ba0..6b99f37 100644
--- a/functions/src/ensure-derived-paths.ts
+++ b/functions/src/ensure-derived-paths.ts
@@ -29,10 +29,7 @@ import { getProviderForExperiment } from "./providers/index.js";
import { ResolvedAuth, StorageProvider, ContainerRef } from "./providers/types.js";
import { ExperimentData, UserData } from "./interfaces.js";
import { requireUser } from "./require-user.js";
-
-function experimentRef(experimentID: string) {
- return db.collection("experiments").doc(experimentID);
-}
+import { getExperiment } from "./experiment-id.js";
// Same test as firestore.rules' hasCollectedData() and MetadataControl.js's
// own hasCollectedData() -- see either comment for why `sessions` and
@@ -63,11 +60,11 @@ export async function ensureDerivedPathsHandler(req: Request, res: Response): Pr
return;
}
- const expRef = experimentRef(experimentID);
- const expSnap = await expRef.get();
+ const expSnap = await getExperiment(experimentID);
// Same 403-for-both convention as api-finalize.ts: a nonexistent
// experiment and someone else's experiment get the identical response.
- if (!expSnap.exists || expSnap.data()?.owner !== uid) {
+ // (getExperiment's null also covers an id Firestore would reject outright.)
+ if (!expSnap || expSnap.data()?.owner !== uid) {
res.status(403).json({ error: "Access denied" });
return;
}
diff --git a/functions/src/experiment-id.ts b/functions/src/experiment-id.ts
new file mode 100644
index 0000000..b9c84bd
--- /dev/null
+++ b/functions/src/experiment-id.ts
@@ -0,0 +1,62 @@
+import { DocumentSnapshot } from "firebase-admin/firestore";
+import { db } from "./app.js";
+
+// Whether a client-supplied experimentID is even a value Firestore will
+// accept as a document id -- checked BEFORE it is ever spliced into
+// db.collection("experiments").doc(experimentID). Firestore does not treat an
+// invalid id as a lookup miss: it THROWS synchronously for a handful of
+// specific shapes ("3 INVALID_ARGUMENT: Resource id ... is invalid because it
+// is reserved"), and that throw was reaching participant-facing endpoints
+// unhandled as a 500. The commonest real case is a participant site that
+// never filled in a template placeholder -- e.g. "__DATAPIPE_STUDY1_ID__" --
+// which happens to be exactly the reserved __...__ shape below.
+//
+// Kept in its own module rather than folded into staging.ts: it has nothing
+// to do with the RTDB staging tier that file owns, it just happens to be the
+// same kind of gate as isValidSessionId there -- the one check a
+// client-controlled string has to clear before it is trusted as a Firestore
+// document id.
+//
+// Deliberately NOT restricted to the nanoid alphabet create-experiment.ts
+// mints new ids from: older experiment ids may use other formats, and the
+// only thing this function has to guarantee is that whatever it accepts, a
+// Firestore doc() call will accept too. (Contrast isValidSessionId in
+// staging.ts, which DOES pin the exact format the server mints, because a
+// sessionId is never anything but server-generated.)
+//
+// Firestore's own restrictions on a document id (see
+// https://firebase.google.com/docs/firestore/quotas#collections_documents):
+// - must be a non-empty string
+// - may not contain "/"
+// - may not be exactly "." or ".."
+// - may not match /^__.*__$/ (reserved for Firestore's own use)
+// - may not exceed 1500 bytes in UTF-8
+const RESERVED_ID_PATTERN = /^__.*__$/;
+const MAX_ID_BYTES = 1500;
+
+/**
+ * Whether `value` is a string Firestore will accept as a document id. Not
+ * specific to experiments: api-queue-status.ts runs its `download` queue-entry
+ * id through the same check, and write-log.ts its logs/{experimentID} id.
+ */
+export function isValidDocumentId(value: unknown): value is string {
+ if (typeof value !== "string" || value.length === 0) return false;
+ if (value.includes("/")) return false;
+ if (value === "." || value === "..") return false;
+ if (RESERVED_ID_PATTERN.test(value)) return false;
+ if (Buffer.byteLength(value, "utf-8") > MAX_ID_BYTES) return false;
+ return true;
+}
+
+/**
+ * experiments/{experimentID}, or null when there is no such experiment --
+ * including when `experimentID` is not an id Firestore would accept at all.
+ * Every endpoint that looks an experiment up by a client-supplied id goes
+ * through this, so none of them can reach the doc() call that throws, and
+ * each keeps the single not-found branch it already had.
+ */
+export async function getExperiment(experimentID: unknown): Promise {
+ if (!isValidDocumentId(experimentID)) return null;
+ const snap = await db.collection("experiments").doc(experimentID).get();
+ return snap.exists ? snap : null;
+}
diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts
index 7e2bac1..08c68ed 100644
--- a/functions/src/providers/dataverse.ts
+++ b/functions/src/providers/dataverse.ts
@@ -1,6 +1,8 @@
-import fetch from "node-fetch";
+import fetch, { type Response } from "node-fetch";
+import type { Readable } from "stream";
import { randomBytes } from "crypto";
import { decrypt } from "../crypto-utils.js";
+import { redactSecret } from "../redact.js";
import { UserData } from "../interfaces.js";
import { isAllowedServerUrl } from "./server-url.js";
import {
@@ -55,6 +57,39 @@ function quoteHeaderParam(value: string): string {
return `"${sanitized}"`;
}
+// validateStaticToken's server URL is researcher-chosen, so its error body is
+// untrusted: read at most this much, for at most this long, then drop the
+// connection. response.text() would buffer a hostile multi-hundred-MB (or
+// slow-drip) body into dashboardapi's shared memory. The margin over the 300
+// characters logged leaves room for a token-sized redaction.
+const VALIDATE_BODY_MAX_BYTES = 4096;
+const VALIDATE_BODY_TIMEOUT_MS = 5000;
+
+// Never throws: the body is diagnostic only, and an unreadable one still
+// leaves the status.
+async function readBodyPrefix(response: Response, maxBytes: number, timeoutMs: number): Promise {
+ const stream = response.body as Readable | null;
+ if (!stream) return "";
+ const chunks: Buffer[] = [];
+ let total = 0;
+ let timer: NodeJS.Timeout | undefined;
+ const timedOut = new Promise((resolve) => {
+ timer = setTimeout(resolve, timeoutMs);
+ });
+ const read = (async () => {
+ for await (const chunk of stream) {
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
+ chunks.push(buf);
+ total += buf.length;
+ if (total >= maxBytes) break;
+ }
+ })().catch(() => {});
+ await Promise.race([read, timedOut]);
+ clearTimeout(timer);
+ stream.destroy();
+ return Buffer.concat(chunks).subarray(0, maxBytes).toString("utf8");
+}
+
function authHeaders(auth: ResolvedAuth): Record {
return { "X-Dataverse-key": auth.token };
}
@@ -423,7 +458,18 @@ export const dataverseProvider: StorageProvider = {
});
// Never throw on a non-200 -- a bad/expired token is simply "not valid",
// not an exceptional condition.
- return response.status === 200;
+ if (response.status === 200) return true;
+
+ // Log what the installation actually said. The caller collapses every
+ // non-200 into "Invalid API token", so without this a real 401, a WAF
+ // 403, and an outage 5xx are indistinguishable after the fact. The body
+ // is scrubbed of the token in case an installation echoes the key back in
+ // its error message, and flattened to one line so a multi-line block page
+ // stays in one log entry.
+ const prefix = await readBodyPrefix(response, VALIDATE_BODY_MAX_BYTES, VALIDATE_BODY_TIMEOUT_MS);
+ const body = redactSecret(prefix, auth.token).replace(/\s+/g, " ").trim().slice(0, 300);
+ console.warn(`dataverse validateStaticToken: ${serverUrl}/api/users/:me returned ${response.status}: ${body}`);
+ return false;
},
// Reads the token's expiry from the one endpoint that reports it, GET
diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts
index 7ef03dc..c629bad 100644
--- a/functions/src/providers/osf.ts
+++ b/functions/src/providers/osf.ts
@@ -57,6 +57,20 @@ function mapStatus(errorCode: number | null): ProviderErrorCode {
}
}
+// OSF's own explanation of an error response: the JSON:API body's
+// errors[0].detail, e.g. "You do not have permission to perform this action."
+// Empty when the body is not JSON or has no detail -- the caller falls back to
+// statusText, which is itself empty behind some proxies and over HTTP/2.
+async function osfErrorDetail(response: { json: () => Promise }): Promise {
+ try {
+ const body = (await response.json()) as { errors?: { detail?: unknown }[] };
+ const detail = body?.errors?.[0]?.detail;
+ return typeof detail === "string" ? detail : "";
+ } catch {
+ return "";
+ }
+}
+
export const osfProvider: StorageProvider = {
id: "osf",
authMethod: "oauth2",
@@ -244,8 +258,21 @@ export const osfProvider: StorageProvider = {
},
});
- const folder = (await osfResult.json()) as { data: OSFFile[] };
- const listOfFiles: OSFFile[] = folder["data"];
+ // An error response has no `data`, so the filter below would throw an opaque
+ // TypeError. Throw OSF's status and its own explanation instead (same shape
+ // as zenodo/gdrive listFiles); collision-cache's rehydrate wraps it in
+ // CollisionCacheUnavailableError.
+ if (osfResult.status !== 200) {
+ const reason = (await osfErrorDetail(osfResult)) || osfResult.statusText;
+ throw new Error(`OSF listing failed: ${osfResult.status} ${reason}`.trim());
+ }
+
+ const folder = (await osfResult.json()) as { data?: OSFFile[] };
+ const listOfFiles = folder["data"];
+
+ if (!Array.isArray(listOfFiles)) {
+ throw new Error("OSF listing failed: response body had no file list");
+ }
return listOfFiles
.filter((file) => file.attributes.kind === "file")
diff --git a/functions/src/redact.ts b/functions/src/redact.ts
new file mode 100644
index 0000000..50a2d28
--- /dev/null
+++ b/functions/src/redact.ts
@@ -0,0 +1,7 @@
+// Replaces every occurrence of `secret` in `text` before it is logged. An
+// empty secret is a no-op: "abc".split("") would otherwise redact between
+// every character.
+export function redactSecret(text: string, secret: string | undefined): string {
+ if (!secret) return text;
+ return text.split(secret).join("[redacted]");
+}
diff --git a/functions/src/write-log.ts b/functions/src/write-log.ts
index 5ee8cf5..be68092 100644
--- a/functions/src/write-log.ts
+++ b/functions/src/write-log.ts
@@ -1,6 +1,7 @@
import { db } from "./app.js";
import { FieldValue, Timestamp } from "firebase-admin/firestore";
import { StorageProviderId } from "./providers/types.js";
+import { isValidDocumentId } from "./experiment-id.js";
/**
* logs/{experimentID} — the per-experiment activity record.
@@ -144,6 +145,11 @@ export default async function writeLog(
error?: object,
context?: LogContext
): Promise {
+ // A client-supplied id Firestore would reject (an unfilled "__X__"
+ // placeholder, say) can never have a log document. doc() would throw into
+ // the catch below and print an error per request; skip it quietly instead.
+ if (!isValidDocumentId(experimentID)) return false;
+
try {
const log_doc_ref = db.collection("logs").doc(experimentID);
diff --git a/lib/base-url.js b/lib/base-url.js
new file mode 100644
index 0000000..0640bb2
--- /dev/null
+++ b/lib/base-url.js
@@ -0,0 +1,35 @@
+// The DataPipe deployment this build serves, for the URLs shown in the API
+// reference and threaded into the copy-paste code samples.
+//
+// Both deploy workflows set NEXT_PUBLIC_BASE_URL (firebase-deploy.yml ->
+// https://pipe.jspsych.org, firebase-deploy-test.yml ->
+// https://datapipe-test.web.app). Before this module nothing read it, so the
+// test deployment handed testers samples that fell through to
+// datapipe-client's built-in production URL: copy one, run it, and the data
+// landed in the live service, against real researchers' experiments.
+//
+// Next.js inlines NEXT_PUBLIC_* at build time, so this must stay a literal
+// `process.env.NEXT_PUBLIC_BASE_URL` reference (no dynamic lookup), and the
+// value is fixed per build, not per request. The production URL is the
+// fallback: an unconfigured build (a self-host that never set the var) is
+// describing the hosted service.
+
+export const PRODUCTION_BASE_URL = "https://pipe.jspsych.org";
+
+export function resolveBaseURL(configured) {
+ let url = (configured || "").trim();
+ while (url.endsWith("/")) url = url.slice(0, -1);
+ return url || PRODUCTION_BASE_URL;
+}
+
+export const BASE_URL = resolveBaseURL(process.env.NEXT_PUBLIC_BASE_URL);
+
+// The URL the code samples must pass explicitly, or null when the published
+// packages' own default already points at this deployment. Production samples
+// stay free of an option researchers would only copy around; every other
+// build names its deployment, because the npm packages cannot know it.
+export function sampleBaseURL(baseURL) {
+ return baseURL === PRODUCTION_BASE_URL ? null : baseURL;
+}
+
+export const SAMPLE_BASE_URL = sampleBaseURL(BASE_URL);