From a28eace08115573f60339a107fe6c14113cb4893 Mon Sep 17 00:00:00 2001 From: Mandyx22 <1915537307@qq.com> Date: Wed, 1 Jul 2026 11:08:21 -0400 Subject: [PATCH 001/181] feat: surface and document metadata production - Add "Recommended" badge next to the metadata toggle - Add a help popover next to the Metadata section heading linking to the FAQ - Expand FAQ item-11 explaining metadata production, with links to the getting started guide and Psych-DS documentation Co-Authored-By: Claude Opus 4.8 --- components/dashboard/MetadataControl.js | 6 +++- pages/admin/[experiment_id].js | 38 ++++++++++++++++++++++--- pages/faq.js | 29 ++++++++++++++----- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/components/dashboard/MetadataControl.js b/components/dashboard/MetadataControl.js index 464dc22..bef49e6 100644 --- a/components/dashboard/MetadataControl.js +++ b/components/dashboard/MetadataControl.js @@ -3,6 +3,7 @@ import { HStack, Switch, Stack, + Badge, } from "@chakra-ui/react"; import { useState } from "react"; @@ -19,7 +20,10 @@ export default function MetadataControl({ data }) { - Enable Psych-DS metadata production? + + Enable Psych-DS metadata production? + Recommended + - - Metadata - + + + Metadata + + + + + + + + + + + + Generates Psych-DS metadata describing your data's + columns (descriptions, value ranges, and levels), making + your dataset easier to share and reuse.{" "} + + Learn more + + + + + + + diff --git a/pages/faq.js b/pages/faq.js index cfb5ea1..04f7a65 100644 --- a/pages/faq.js +++ b/pages/faq.js @@ -238,15 +238,30 @@ export default function FAQ() { + + When enabled, DataPipe generates a dataset_description.json file in + your OSF project that describes your dataset and its variables + according to the Psych-DS specification. The file is updated + automatically as new sessions are uploaded. + + + For each variable, DataPipe records its data type and, when + available, a human-readable description from the relevant jsPsych + plugin documentation. It also combines information across sessions, + such as observed numeric ranges and categorical values. + - When enabled, DataPipe generates a metadata file describing your - data and its variables, following the{" "} - - Psych-DS + Metadata production is recommended when you plan to share or publish + your data because it makes the dataset easier to understand and + reuse. You can enable it from your experiment dashboard. See the{" "} + + getting started guide + {" "} + for setup instructions, or visit the{" "} + + Psych-DS documentation {" "} - specification. The file is stored in your OSF project as - dataset_description.json and is updated automatically after - each session. + to learn more. From b0fa96eaf15b5d3ddfca21ab0f1b9dcab993d1f7 Mon Sep 17 00:00:00 2001 From: Mandyx22 <1915537307@qq.com> Date: Wed, 1 Jul 2026 13:35:10 -0400 Subject: [PATCH 002/181] feat: cross-link Getting Started to the metadata FAQ - Add a "how it works" link in Getting Started step 4 that deep-links to FAQ item-11, and point the Psych-DS link at the Psych-DS docs - Make the FAQ accordion open and scroll to an item when arriving via its hash (e.g. /faq#item-11) Co-Authored-By: Claude Opus 4.8 --- pages/faq.js | 20 +++++++++++++++++++- pages/getting-started.js | 6 ++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pages/faq.js b/pages/faq.js index 04f7a65..ab0c9df 100644 --- a/pages/faq.js +++ b/pages/faq.js @@ -7,14 +7,32 @@ import { Box, } from "@chakra-ui/react"; import NextLink from "next/link"; +import { useState, useEffect } from "react"; export default function FAQ() { + const [openItems, setOpenItems] = useState(["item-0"]); + + useEffect(() => { + const hash = window.location.hash.replace("#", ""); + if (!hash) return; + setOpenItems((prev) => (prev.includes(hash) ? prev : [...prev, hash])); + // Wait for the accordion to expand, then bring the item's trigger into view. + // Chakra doesn't forward `id` to the DOM, so target the trigger via its + // data-controls attribute and offset for the fixed navbar. + setTimeout(() => { + const el = document.querySelector(`[data-controls$=":content:${hash}"]`); + if (!el) return; + const top = el.getBoundingClientRect().top + window.scrollY - 80; + window.scrollTo({ top, behavior: "smooth" }); + }, 350); + }, []); + return ( FAQ - + setOpenItems(e.value)} multiple collapsible> Follow our{" "} diff --git a/pages/getting-started.js b/pages/getting-started.js index 06ac44d..abade5f 100644 --- a/pages/getting-started.js +++ b/pages/getting-started.js @@ -211,10 +211,12 @@ export default function GettingStarted() { — automatically produce metadata adhering to{" "} - + Psych-DS - , updated after each session. + , updated after each session. See{" "} + how it works + {" "}in the FAQ. From 2c600bf19f382dff921d80a5d866930f3bde739b Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 1 Jul 2026 17:58:29 -0400 Subject: [PATCH 003/181] fix(metadata): re-vendor @jspsych/metadata from upstream main + sync tooling DataPipe's vendored @jspsych/metadata was a June-2024 fork frozen at v0.0.1 that silently discarded nested object/array trial data. The fix lives on the upstream main branch but is NOT in the published npm 0.0.3 (which has the same data-loss bug and would silently drop all data given DataPipe's pre-parsed input). Rather than depend on the stale npm release or live-track a moving branch, vendor a PINNED upstream commit and rebuild from it, with tooling to make future re-syncs a one-command, reviewable step. - functions/scripts/sync-metadata.mjs (+ npm run sync:metadata): clone upstream at a ref, build packages/metadata, copy the built dist + sanitized package.json + LICENSE into functions/metadata/, and record provenance in VENDORED_FROM.json. Strips the package's scripts (upstream's prepare:"npm run build" would break `npm install` of the file: dep, since we ship dist-only) while keeping the csv-parse runtime dep. - .github/workflows/metadata-drift-check.yml: weekly non-blocking job that opens/updates a tracking issue when upstream main moves past the pinned commit. - functions/metadata/: now dist-only, pinned to upstream main 224d336. dist is committed (deploys need no metadata build); .gitignore updated to un-ignore it. - functions/package.json: dep stays file:metadata; add explicit typescript devDep (the build had relied on it transitively via the removed fork). - functions/src/metadata-production.ts: generate()'s 3rd arg is now a string ext ('json'|'csv'), not the old boolean csv flag. - metadata-production.test.js: fixture updated to real output (type -> @type, numeric -> number); data-derived levels/min-max double as a silent-drop guard; fixed a pre-existing aliasing bug in the options test. Verified: metadata-production, metadata-update, metadata-process suites pass; functions build (tsc) and npm install are clean. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/metadata-drift-check.yml | 74 + .gitignore | 8 +- functions/metadata/LICENSE | 21 + functions/metadata/README.md | 34 +- functions/metadata/VENDORED_FROM.json | 10 + functions/metadata/dist/AuthorsMap.d.ts | 70 + functions/metadata/dist/PluginCache.d.ts | 109 + functions/metadata/dist/VariablesMap.d.ts | 173 + functions/metadata/dist/index.browser.js | 6777 ++++++ functions/metadata/dist/index.browser.min.js | 25 + functions/metadata/dist/index.cjs | 3103 +++ functions/metadata/dist/index.d.ts | 393 + functions/metadata/dist/index.esm.js | 6793 ++++++ functions/metadata/dist/index.iife.js | 6818 ++++++ functions/metadata/dist/index.js | 1722 ++ functions/metadata/dist/utils.d.ts | 188 + functions/metadata/jest.config.cjs | 1 - functions/metadata/package-lock.json | 13796 ------------ functions/metadata/package.json | 34 +- functions/metadata/rollup.config.mjs | 3 - functions/metadata/src/AuthorsMap.ts | 115 - functions/metadata/src/VariablesMap.ts | 353 - functions/metadata/src/index.ts | 520 - .../metadata/tests/metadata-maps.test.ts | 314 - .../metadata/tests/metadata-module.test.ts | 81 - functions/metadata/tsconfig.json | 7 - functions/package-lock.json | 17749 ++++------------ functions/package.json | 6 +- functions/scripts/sync-metadata.mjs | 175 + .../src/__tests__/metadata-production.test.js | 25 +- functions/src/metadata-production.ts | 9 +- 31 files changed, 31259 insertions(+), 28247 deletions(-) create mode 100644 .github/workflows/metadata-drift-check.yml create mode 100644 functions/metadata/LICENSE create mode 100644 functions/metadata/VENDORED_FROM.json create mode 100644 functions/metadata/dist/AuthorsMap.d.ts create mode 100644 functions/metadata/dist/PluginCache.d.ts create mode 100644 functions/metadata/dist/VariablesMap.d.ts create mode 100644 functions/metadata/dist/index.browser.js create mode 100644 functions/metadata/dist/index.browser.min.js create mode 100644 functions/metadata/dist/index.cjs create mode 100644 functions/metadata/dist/index.d.ts create mode 100644 functions/metadata/dist/index.esm.js create mode 100644 functions/metadata/dist/index.iife.js create mode 100644 functions/metadata/dist/index.js create mode 100644 functions/metadata/dist/utils.d.ts delete mode 100644 functions/metadata/jest.config.cjs delete mode 100644 functions/metadata/package-lock.json delete mode 100644 functions/metadata/rollup.config.mjs delete mode 100644 functions/metadata/src/AuthorsMap.ts delete mode 100644 functions/metadata/src/VariablesMap.ts delete mode 100644 functions/metadata/src/index.ts delete mode 100644 functions/metadata/tests/metadata-maps.test.ts delete mode 100644 functions/metadata/tests/metadata-module.test.ts delete mode 100644 functions/metadata/tsconfig.json create mode 100644 functions/scripts/sync-metadata.mjs diff --git a/.github/workflows/metadata-drift-check.yml b/.github/workflows/metadata-drift-check.yml new file mode 100644 index 0000000..d4b10ee --- /dev/null +++ b/.github/workflows/metadata-drift-check.yml @@ -0,0 +1,74 @@ +# Nudge (never an auto-merge): flags when upstream @jspsych/metadata main has moved past +# the commit DataPipe currently vendors. The vendored copy is pinned + built by +# functions/scripts/sync-metadata.mjs; this job just tells a human it's time to re-sync. +name: Metadata vendor drift check + +on: + schedule: + - cron: "0 12 * * 1" # Mondays 12:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + drift-check: + runs-on: ubuntu-latest + env: + UPSTREAM: jspsych/metadata + ISSUE_TITLE: "[metadata-sync] Vendored @jspsych/metadata is behind upstream main" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + + - name: Compare pinned commit against upstream main + id: compare + run: | + PINNED=$(jq -r .commit functions/metadata/VENDORED_FROM.json) + echo "Pinned commit: $PINNED" + if [ -z "$PINNED" ] || [ "$PINNED" = "null" ]; then + echo "::error::Could not read pinned commit from functions/metadata/VENDORED_FROM.json" + exit 1 + fi + UPSTREAM_MAIN=$(gh api "repos/$UPSTREAM/commits/main" --jq .sha) + echo "Upstream main: $UPSTREAM_MAIN" + if [ "$PINNED" = "$UPSTREAM_MAIN" ]; then + echo "in_sync=true" >> "$GITHUB_OUTPUT" + echo "Vendored copy is up to date with upstream main." + exit 0 + fi + # ahead_by = how many commits upstream main is ahead of our pinned commit. + AHEAD=$(gh api "repos/$UPSTREAM/compare/$PINNED...main" --jq .ahead_by) + echo "in_sync=false" >> "$GITHUB_OUTPUT" + echo "ahead=$AHEAD" >> "$GITHUB_OUTPUT" + echo "pinned=$PINNED" >> "$GITHUB_OUTPUT" + echo "upstream=$UPSTREAM_MAIN" >> "$GITHUB_OUTPUT" + + - name: Open or update tracking issue + if: steps.compare.outputs.in_sync == 'false' + run: | + BODY=$(cat < # from a specific commit +``` +Then review the diff, run `npm test` at the repo root, and commit `functions/metadata/`. +A scheduled CI job (`.github/workflows/metadata-drift-check.yml`) flags when upstream main +has moved past this pin. + +## Exit plan +When `@jspsych/metadata` ships a released npm version with these fixes, delete this +directory and the sync script, and set the dependency to the published `"^x.y.z"`. diff --git a/functions/metadata/VENDORED_FROM.json b/functions/metadata/VENDORED_FROM.json new file mode 100644 index 0000000..4305661 --- /dev/null +++ b/functions/metadata/VENDORED_FROM.json @@ -0,0 +1,10 @@ +{ + "source": "https://github.com/jspsych/metadata.git", + "package": "@jspsych/metadata", + "version": "0.0.3", + "ref": "main", + "commit": "224d336f8c6e6c67f22e345787f7fd3256bc4cf6", + "commitDate": "2026-06-26T11:29:40-04:00", + "syncedAt": "2026-07-01T21:31:30.852Z", + "note": "Generated by functions/scripts/sync-metadata.mjs — do not edit dist/ by hand." +} diff --git a/functions/metadata/dist/AuthorsMap.d.ts b/functions/metadata/dist/AuthorsMap.d.ts new file mode 100644 index 0000000..d3a7d81 --- /dev/null +++ b/functions/metadata/dist/AuthorsMap.d.ts @@ -0,0 +1,70 @@ +/** + * Interface that defines the type for the fields that are specified for authors + * according to Psych-DS regulations, with name being the one required field. + * + * @export + * @interface AuthorFields + * @typedef {AuthorFields} + */ +export interface AuthorFields { + /** The type of the author. */ + "@type"?: string; + /** The name of the author. (required) */ + name: string; + /** The given name of the author. */ + givenName?: string; + /** The family name of the author. */ + familyName?: string; + /** The identifier that distinguishes the author across datasets (URL). */ + identifier?: string; +} +/** + * Class that helps keep track of authors and allows for easy conversion to list format when + * generating the final Metadata file. + * + * @export + * @class AuthorsMap + * @typedef {AuthorsMap} + */ +export declare class AuthorsMap { + /** + * Field that keeps track of the authors in a map. + * + * @private + * @type {({ [key: string]: AuthorFields | string })} + */ + private authors; + /** + * Creates an empty instance of authors map. Doesn't generate default metadata because + * can't assume anything about the authors. + * + * @constructor + */ + constructor(); + /** + * Returns the final list format of the authors according to Psych-DS standards. + * + * @returns {(AuthorFields | string)[]} - List of authors + */ + getList(): (AuthorFields | string)[]; + /** + * Method that creates an author. This method can also be used to overwrite existing authors + * with the same name in order to update fields. + * + * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name. + */ + setAuthor(author: AuthorFields | string): void; + /** + * Method that fetches an author object allowing user to update (in existing workflow should not be necessary). + * + * @param {string} name - Name of author to be used as key. + * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found. + */ + getAuthor(name: string): AuthorFields | string | {}; + /** + * Deletes the author if it exists, printing out warning if doesn't exist. + * + * @param {string} author_name - Name of author to be deleted + */ + deleteAuthor(author_name: string): void; +} diff --git a/functions/metadata/dist/PluginCache.d.ts b/functions/metadata/dist/PluginCache.d.ts new file mode 100644 index 0000000..20ae7b3 --- /dev/null +++ b/functions/metadata/dist/PluginCache.d.ts @@ -0,0 +1,109 @@ +/** + * This class handles the fetching and extraction of description field data about variables + * using plugin and extension type. It caches and parses it efficiently to speed up the metadata generation + * process. + * + * @export + * @class PluginCache + * @typedef {PluginCache} + */ +export declare class PluginCache { + private pluginFields; + constructor(); + /** + * Gets the description of a variable in a plugin by fetching the source code of the plugin + * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription + * to extract the description for the variable (present as JSDoc); caches the result for future use. + * + * @param {string} pluginType - The type of the plugin for which information is to be fetched. + * @param {string} variableName - The name of the variable for which information is to be fetched. + * @param {string} version - The name of the variable for which information is to be fetched. + * @param {boolean} verbose - Indicates whether should run with verbose mode + * @param {boolean} [extension] - An optional flag to indicate if an extension should be used. + * @returns {Promise} The description of the plugin variable if found, otherwise null. + * @throws Will throw an error if the fetch operation fails. + */ + getPluginInfo(pluginType: string, variableName: string, version: string, verbose: boolean, extension?: boolean): Promise; + /** + * Method that handles the generation of the fields and calls helpers methods that + * fetch and parse the plugin data. + * + * @private + * @async + * @param {string} pluginType - Name of plugin or extension to fetch. + * @param {string} version - String version to fetch + * @param {boolean} verbose - Boolean indicating verbose mode + * @param {?boolean} [extension] - Optional flag if pluginType is extension + * @returns {unknown} + */ + private generatePluginFields; + /** + * The method that generates the unpkg links based on whether extension vs plugin and the + * specific type. + * + * @private + * @param {string} pluginType - Name of plugin or extension to fetch + * @param {string} version - String version used + * @param {?boolean} [extension] - Optional flag if pluginType is extension + * @returns {string} + */ + private generateUnpkg; + /** + * Fetches the actual script text content from unpkg. Calls the method to generate the link + * and then handles error checking and fetching. + * + * @private + * @async + * @param {string} pluginType - The plugin or extension name to be fetched + * @param {string} version - The string version of the plugin + * @param {boolean} verbose - Boolean indicating verbose mode + * @param {?boolean} [extension] - Whether pluginType is extension + * @returns {unknown} + */ + private fetchScript; + /** + * Extracts the content of the top-level `data: { ... }` block from a jsPsych plugin source + * file using brace counting. This is more robust than a regex approach because the data block + * ends with `},` (not `};`), and plugin sources contain deeply nested objects that would + * cause a lazy regex to stop at the wrong closing brace. + * + * Known limitations (acceptable for current jsPsych plugin sources): + * - Matches the first `data:` property in the file; a plugin with a `data:` field inside its + * `parameters` block before the top-level `info.data` block would extract the wrong object. + * - Brace counting treats every `{`/`}` as structural; braces inside string literals or JSDoc + * comments (e.g. `/** e.g. {foo: 1} *\/`) would throw off the counter. + * + * @private + * @param {string} script - Full plugin source text. + * @returns {string | null} Content between the outer braces of the data block, or null if not found. + */ + private extractDataBlock; + /** + * Parses JSDoc comments and variable blocks from the data section of a jsPsych plugin source. + * + * @private + * @param {string} script - The script text content of the fetching. + * @returns {{}} + */ + private parseJavadocString; + /** + * Extracts JSDoc-annotated fields from a data block string. Uses brace counting to find + * each variable's true closing brace, then recursively processes any `nested:` sub-object + * so that nested parameter descriptions are also captured. + * + * @private + * @param {string} block - Content of a data or nested block (without outer braces). + * @returns {Record} + */ + private extractJsdocFields; + /** + * Returns the index of the `}` that closes the `{` at `startIndex`, using brace counting. + * Returns -1 if the source is unbalanced (no matching closing brace found). + * + * @private + * @param {string} str - String to search. + * @param {number} startIndex - Index of the opening `{`. + * @returns {number} + */ + private findMatchingBrace; +} diff --git a/functions/metadata/dist/VariablesMap.d.ts b/functions/metadata/dist/VariablesMap.d.ts new file mode 100644 index 0000000..eec49b1 --- /dev/null +++ b/functions/metadata/dist/VariablesMap.d.ts @@ -0,0 +1,173 @@ +/** + * Interface that defines the type for the fields that are specified for variables + * according to Psych-DS regulations, with name being the one required field. + * + * @export + * @interface VariableFields + * @typedef {VariableFields} + */ +export interface VariableFields { + "@type"?: string; + name: string; + description?: string | Record; + value?: string; + identifier?: string; + minValue?: number; + maxValue?: number; + levels?: string[] | []; + levelsOrdered?: boolean; + na?: boolean; + naValue?: string; + alternateName?: string; + privacy?: string; +} +/** + * Custom class that stores and handles the storage, update and retrieval of variable metadata. + * + * @export + * @class VariablesMap + * @typedef {VariablesMap} + */ +export declare class VariablesMap { + /** + * Field that holds a map of the current variables allowing for fast look-up. + * + * @private + * @type {{ [key: string]: VariableFields }} + */ + private variables; + /** + * Creates the VariablesMap by initialising an empty variable map. The jsPsych system + * variables (trial_type, trial_index, time_elapsed, extension_*) are NOT seeded here — they + * are registered lazily when their column is actually observed in the data (see + * {@link registerSystemVariable}). Seeding them unconditionally produced orphan + * variableMeasured entries (e.g. time_elapsed) for datasets that omit those columns, which + * fails Psych-DS validation (VARIABLE_MISSING_FROM_CSV_COLUMNS). + * + * @constructor + */ + constructor(); + /** + * The fixed jsPsych definition for a system column, or null if `name` is not a known system + * variable. Returns a fresh object on each call so callers never share/mutate one template. + */ + private static systemVariableTemplate; + /** + * Lazily registers the default jsPsych definition for a system column the first time it is + * observed in the data. No-op (returns false) when `name` is not a known system variable or + * is already present; returns true when a new variable was registered. This is what keeps a + * system variable out of variableMeasured unless the data actually contains that column. + * + * @param {string} name - The column / system-variable name. + * @returns {boolean} - True if a variable was registered, false otherwise. + */ + registerSystemVariable(name: string): boolean; + /** + * Initialises the variable map. System variables are registered lazily (see the constructor + * and {@link registerSystemVariable}), so this just resets the map to empty. + */ + generateDefaultVariables(): void; + /** + * Returns a list of the variables instead of an object according to the Psych-DS format. + * + * @returns {{}[]} - The list of variables represented as objects. + */ + getList(): {}[]; + /** + * Collapses an internal { pluginType: description } map into a single schema.org-valid + * Text value. Descriptions are stored per-plugin and only ever hold multiple keys when the + * texts genuinely differ (identical texts are merged upstream in updateDescription). Psych-DS / + * schema.org require `description` to be Text, so an object value triggers an OBJECT_TYPE_MISSING + * validator warning — this folds everything down to a string. + * + * @private + * @param {*} description - The description value (a { pluginType: text } map, or already a string). + * @returns {string} - A single Text description. + */ + private collapseDescription; + /** + * Allows user to set a variable and includes all the fields that are possible according to + * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable. + * Can also be used to overwrite existing variables if they have the same name. + * + * @param {VariableFields} variable - The fields of the variable that is being created. + */ + setVariable(variable: VariableFields): void; + /** + * Allows you to get information for a single variable returning empty dict if it doesn't exist. + * Allows you to update fields but not recommended in favor of updateVariable. + * + * @param {string} name + * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist + */ + getVariable(name: string): VariableFields | {}; + /** + * Checks if variable exists in VariablesMap. + * + * @param {string} name - Name of variable + * @returns {boolean} - True if exists, false if doesn't. + */ + containsVariable(name: string): boolean; + /** + * Method that gets a list of the names of variables. + * + * @returns {string[]} - String list containing names of existing variables. + */ + getVariableNames(): string[]; + /** + * Allows you to update a variable or add a value in the case of updating values. In other situations will + * replace the existing value with the new value. Has special cases and logic for levels and names making it + * easier to update variable values. + * + * + * @param {string} var_name - Name of variable to be updated. + * @param {string} field_name - Specific field to be updated. + * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType. + */ + updateVariable(var_name: string, field_name: string, added_value: string | boolean | number | { + [key: string]: string; + }): void; + /** + * Logic that handles updates to levels field by creating new array if necessary, otherwise + * pushing the value if it doesn't already exist. Levels can only be added to with strings. + * + * @private + * @param {*} updated_var - The variable object to be updated. + * @param {*} added_value - The value being added to the levels field. + */ + private updateLevels; + /** + * Logic to update the min and max for the specific value. + * + * @private + * @param {*} updated_var - The variable object to be updated. + * @param {*} added_value - The value that is being checked against current min/max. + * @param {*} field_name - The name of field that is being checked (min or max). + */ + private updateMinMax; + /** + * Logic for updating description field that checks to see value already exists. If it does, + * appends the pluginType to the current key and pushes that along with the value. Creates + * map if it does not exist. + * + * @private + * @param {*} updated_var - The variable to be updated. + * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field. + */ + private updateDescription; + /** + * Logic for updating name. Needs to retain all the old values while creating a new reference in the map + * while keeping the same perspe + * + * @private + * @param {*} updated_var + * @param {*} added_value + */ + private updateName; + /** + * Allows you to delete a variable by key/name. Returns console error if not found. + * + * @param {string} var_name - Name of variable to be deleted. + */ + deleteVariable(var_name: string): void; +} diff --git a/functions/metadata/dist/index.browser.js b/functions/metadata/dist/index.browser.js new file mode 100644 index 0000000..91b77a3 --- /dev/null +++ b/functions/metadata/dist/index.browser.js @@ -0,0 +1,6777 @@ +(() => { + // src/AuthorsMap.ts + var AuthorsMap = class { + /** + * Creates an empty instance of authors map. Doesn't generate default metadata because + * can't assume anything about the authors. + * + * @constructor + */ + constructor() { + this.authors = {}; + } + /** + * Returns the final list format of the authors according to Psych-DS standards. + * + * @returns {(AuthorFields | string)[]} - List of authors + */ + getList() { + const author_list = []; + for (const key of Object.keys(this.authors)) { + author_list.push(this.authors[key]); + } + return author_list; + } + /** + * Method that creates an author. This method can also be used to overwrite existing authors + * with the same name in order to update fields. + * + * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name. + */ + setAuthor(author) { + if (typeof author === "string") { + this.authors[author] = author; + return; + } + if (!author.name) { + console.warn("Name field is missing. Author not added."); + return; + } + const { name, ...rest } = author; + if (Object.keys(rest).length == 0) { + this.authors[name] = name; + } else { + const newAuthor = { name, ...rest }; + this.authors[name] = newAuthor; + const unexpectedFields = Object.keys(author).filter( + (key) => !["@type", "name", "givenName", "familyName", "identifier"].includes(key) + ); + if (unexpectedFields.length > 0) { + console.warn( + `Unexpected fields (${unexpectedFields.join( + ", " + )}) detected and included in the author object.` + ); + } + } + } + /** + * Method that fetches an author object allowing user to update (in existing workflow should not be necessary). + * + * @param {string} name - Name of author to be used as key. + * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found. + */ + getAuthor(name) { + if (name in this.authors) { + return this.authors[name]; + } else { + console.warn("Author (", name, ") not found."); + return {}; + } + } + /** + * Deletes the author if it exists, printing out warning if doesn't exist. + * + * @param {string} author_name - Name of author to be deleted + */ + deleteAuthor(author_name) { + if (author_name in this.authors) { + delete this.authors[author_name]; + } else { + console.error(`Author "${author_name}" does not exist.`); + } + } + }; + + // src/PluginCache.ts + var PluginCache = class { + constructor() { + this.pluginFields = {}; + } + /** + * Gets the description of a variable in a plugin by fetching the source code of the plugin + * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription + * to extract the description for the variable (present as JSDoc); caches the result for future use. + * + * @param {string} pluginType - The type of the plugin for which information is to be fetched. + * @param {string} variableName - The name of the variable for which information is to be fetched. + * @param {string} version - The name of the variable for which information is to be fetched. + * @param {boolean} verbose - Indicates whether should run with verbose mode + * @param {boolean} [extension] - An optional flag to indicate if an extension should be used. + * @returns {Promise} The description of the plugin variable if found, otherwise null. + * @throws Will throw an error if the fetch operation fails. + */ + async getPluginInfo(pluginType, variableName, version2, verbose, extension) { + if (!(pluginType in this.pluginFields)) { + const fields = await this.generatePluginFields(pluginType, version2, verbose, extension); + this.pluginFields[pluginType] = fields; + } + if (variableName in this.pluginFields[pluginType]) + return this.pluginFields[pluginType][variableName]; + else + return { + description: "unknown", + type: "unknown" + }; + } + /** + * Method that handles the generation of the fields and calls helpers methods that + * fetch and parse the plugin data. + * + * @private + * @async + * @param {string} pluginType - Name of plugin or extension to fetch. + * @param {string} version - String version to fetch + * @param {boolean} verbose - Boolean indicating verbose mode + * @param {?boolean} [extension] - Optional flag if pluginType is extension + * @returns {unknown} + */ + async generatePluginFields(pluginType, version2, verbose, extension) { + const script = await this.fetchScript(pluginType, version2, verbose, extension); + if (script !== void 0 && script !== null && script !== "") { + try { + return this.parseJavadocString(script); + } catch (err) { + console.warn("* Error parsing", pluginType, err); + return {}; + } + } else { + return {}; + } + } + /** + * The method that generates the unpkg links based on whether extension vs plugin and the + * specific type. + * + * @private + * @param {string} pluginType - Name of plugin or extension to fetch + * @param {string} version - String version used + * @param {?boolean} [extension] - Optional flag if pluginType is extension + * @returns {string} + */ + generateUnpkg(pluginType, version2, extension) { + if (extension) { + if (version2) { + return `https://unpkg.com/@jspsych/extension-${pluginType}@${version2}/src/index.ts`; + } else return `https://unpkg.com/@jspsych/extension-${pluginType}/src/index.ts`; + } + if (version2) { + return `https://unpkg.com/@jspsych/plugin-${pluginType}@${version2}/src/index.ts`; + } else return `https://unpkg.com/@jspsych/plugin-${pluginType}/src/index.ts`; + } + /** + * Fetches the actual script text content from unpkg. Calls the method to generate the link + * and then handles error checking and fetching. + * + * @private + * @async + * @param {string} pluginType - The plugin or extension name to be fetched + * @param {string} version - The string version of the plugin + * @param {boolean} verbose - Boolean indicating verbose mode + * @param {?boolean} [extension] - Whether pluginType is extension + * @returns {unknown} + */ + async fetchScript(pluginType, version2, verbose, extension) { + const unpkgUrl = this.generateUnpkg(pluginType, version2, extension); + if (verbose) console.log("-> fetching information for [", pluginType, "] from ->", unpkgUrl); + try { + const response = await fetch(unpkgUrl); + if (!response.ok) { + console.warn(`Plugin source not found for: ${pluginType} (HTTP ${response.status}). Descriptions will default to "unknown".`); + return void 0; + } + const scriptContent = await response.text(); + return scriptContent; + } catch (error) { + console.error( + `Plugin fetching failed for:`, + pluginType, + "with error", + error, + "Note: if you are using a plugin not supported the main JsPsych branch this will always fail." + ); + return void 0; + } + } + /** + * Extracts the content of the top-level `data: { ... }` block from a jsPsych plugin source + * file using brace counting. This is more robust than a regex approach because the data block + * ends with `},` (not `};`), and plugin sources contain deeply nested objects that would + * cause a lazy regex to stop at the wrong closing brace. + * + * Known limitations (acceptable for current jsPsych plugin sources): + * - Matches the first `data:` property in the file; a plugin with a `data:` field inside its + * `parameters` block before the top-level `info.data` block would extract the wrong object. + * - Brace counting treats every `{`/`}` as structural; braces inside string literals or JSDoc + * comments (e.g. `/** e.g. {foo: 1} *\/`) would throw off the counter. + * + * @private + * @param {string} script - Full plugin source text. + * @returns {string | null} Content between the outer braces of the data block, or null if not found. + */ + extractDataBlock(script) { + const dataStart = script.search(/\bdata:\s*\{/); + if (dataStart === -1) return null; + const braceStart = script.indexOf("{", dataStart); + if (braceStart === -1) return null; + const braceEnd = this.findMatchingBrace(script, braceStart); + if (braceEnd === -1) return null; + return script.substring(braceStart + 1, braceEnd); + } + /** + * Parses JSDoc comments and variable blocks from the data section of a jsPsych plugin source. + * + * @private + * @param {string} script - The script text content of the fetching. + * @returns {{}} + */ + parseJavadocString(script) { + const dataBlock = this.extractDataBlock(script); + if (!dataBlock) return {}; + return this.extractJsdocFields(dataBlock); + } + /** + * Extracts JSDoc-annotated fields from a data block string. Uses brace counting to find + * each variable's true closing brace, then recursively processes any `nested:` sub-object + * so that nested parameter descriptions are also captured. + * + * @private + * @param {string} block - Content of a data or nested block (without outer braces). + * @returns {Record} + */ + extractJsdocFields(block) { + const result = {}; + const varStartRegex = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(\w+):\s*\{/g; + const propRegex = /(\w+):\s*([^,\s{}]+)/g; + let match; + while ((match = varStartRegex.exec(block)) !== null) { + const description = match[1].replace(/^[ \t]*\*[ \t]?/gm, "").trim().replace(/\s+/g, " "); + const varName = match[2]; + const braceStart = match.index + match[0].length - 1; + const braceEnd = this.findMatchingBrace(block, braceStart); + if (braceEnd === -1) continue; + varStartRegex.lastIndex = braceEnd + 1; + const varContent = block.substring(braceStart + 1, braceEnd); + const propsObj = {}; + let propMatch; + propRegex.lastIndex = 0; + while ((propMatch = propRegex.exec(varContent)) !== null) { + propsObj[propMatch[1]] = propMatch[2]; + } + result[varName] = { description, ...propsObj }; + const nestedSearch = /\bnested:\s*\{/.exec(varContent); + if (nestedSearch) { + const nestedBraceStart = varContent.indexOf("{", nestedSearch.index); + const nestedBraceEnd = this.findMatchingBrace(varContent, nestedBraceStart); + if (nestedBraceEnd !== -1) { + Object.assign(result, this.extractJsdocFields(varContent.substring(nestedBraceStart + 1, nestedBraceEnd))); + } + } + } + return result; + } + /** + * Returns the index of the `}` that closes the `{` at `startIndex`, using brace counting. + * Returns -1 if the source is unbalanced (no matching closing brace found). + * + * @private + * @param {string} str - String to search. + * @param {number} startIndex - Index of the opening `{`. + * @returns {number} + */ + findMatchingBrace(str, startIndex) { + let depth = 0; + for (let i = startIndex; i < str.length; i++) { + if (str[i] === "{") depth++; + else if (str[i] === "}" && --depth === 0) return i; + } + return -1; + } + }; + + // ../../node_modules/csv-parse/dist/esm/index.js + var global$1 = typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}; + var lookup = []; + var revLookup = []; + var Arr = typeof Uint8Array !== "undefined" ? Uint8Array : Array; + var inited = false; + function init() { + inited = true; + var code = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (var i = 0, len = code.length; i < len; ++i) { + lookup[i] = code[i]; + revLookup[code.charCodeAt(i)] = i; + } + revLookup["-".charCodeAt(0)] = 62; + revLookup["_".charCodeAt(0)] = 63; + } + function toByteArray(b64) { + if (!inited) { + init(); + } + var i, j, l, tmp, placeHolders, arr; + var len = b64.length; + if (len % 4 > 0) { + throw new Error("Invalid string. Length must be a multiple of 4"); + } + placeHolders = b64[len - 2] === "=" ? 2 : b64[len - 1] === "=" ? 1 : 0; + arr = new Arr(len * 3 / 4 - placeHolders); + l = placeHolders > 0 ? len - 4 : len; + var L = 0; + for (i = 0, j = 0; i < l; i += 4, j += 3) { + tmp = revLookup[b64.charCodeAt(i)] << 18 | revLookup[b64.charCodeAt(i + 1)] << 12 | revLookup[b64.charCodeAt(i + 2)] << 6 | revLookup[b64.charCodeAt(i + 3)]; + arr[L++] = tmp >> 16 & 255; + arr[L++] = tmp >> 8 & 255; + arr[L++] = tmp & 255; + } + if (placeHolders === 2) { + tmp = revLookup[b64.charCodeAt(i)] << 2 | revLookup[b64.charCodeAt(i + 1)] >> 4; + arr[L++] = tmp & 255; + } else if (placeHolders === 1) { + tmp = revLookup[b64.charCodeAt(i)] << 10 | revLookup[b64.charCodeAt(i + 1)] << 4 | revLookup[b64.charCodeAt(i + 2)] >> 2; + arr[L++] = tmp >> 8 & 255; + arr[L++] = tmp & 255; + } + return arr; + } + function tripletToBase64(num) { + return lookup[num >> 18 & 63] + lookup[num >> 12 & 63] + lookup[num >> 6 & 63] + lookup[num & 63]; + } + function encodeChunk(uint8, start, end) { + var tmp; + var output = []; + for (var i = start; i < end; i += 3) { + tmp = (uint8[i] << 16) + (uint8[i + 1] << 8) + uint8[i + 2]; + output.push(tripletToBase64(tmp)); + } + return output.join(""); + } + function fromByteArray(uint8) { + if (!inited) { + init(); + } + var tmp; + var len = uint8.length; + var extraBytes = len % 3; + var output = ""; + var parts = []; + var maxChunkLength = 16383; + for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) { + parts.push(encodeChunk(uint8, i, i + maxChunkLength > len2 ? len2 : i + maxChunkLength)); + } + if (extraBytes === 1) { + tmp = uint8[len - 1]; + output += lookup[tmp >> 2]; + output += lookup[tmp << 4 & 63]; + output += "=="; + } else if (extraBytes === 2) { + tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + output += lookup[tmp >> 10]; + output += lookup[tmp >> 4 & 63]; + output += lookup[tmp << 2 & 63]; + output += "="; + } + parts.push(output); + return parts.join(""); + } + function read(buffer, offset, isLE, mLen, nBytes) { + var e, m; + var eLen = nBytes * 8 - mLen - 1; + var eMax = (1 << eLen) - 1; + var eBias = eMax >> 1; + var nBits = -7; + var i = isLE ? nBytes - 1 : 0; + var d = isLE ? -1 : 1; + var s = buffer[offset + i]; + i += d; + e = s & (1 << -nBits) - 1; + s >>= -nBits; + nBits += eLen; + for (; nBits > 0; e = e * 256 + buffer[offset + i], i += d, nBits -= 8) { + } + m = e & (1 << -nBits) - 1; + e >>= -nBits; + nBits += mLen; + for (; nBits > 0; m = m * 256 + buffer[offset + i], i += d, nBits -= 8) { + } + if (e === 0) { + e = 1 - eBias; + } else if (e === eMax) { + return m ? NaN : (s ? -1 : 1) * Infinity; + } else { + m = m + Math.pow(2, mLen); + e = e - eBias; + } + return (s ? -1 : 1) * m * Math.pow(2, e - mLen); + } + function write(buffer, value, offset, isLE, mLen, nBytes) { + var e, m, c; + var eLen = nBytes * 8 - mLen - 1; + var eMax = (1 << eLen) - 1; + var eBias = eMax >> 1; + var rt = mLen === 23 ? Math.pow(2, -24) - Math.pow(2, -77) : 0; + var i = isLE ? 0 : nBytes - 1; + var d = isLE ? 1 : -1; + var s = value < 0 || value === 0 && 1 / value < 0 ? 1 : 0; + value = Math.abs(value); + if (isNaN(value) || value === Infinity) { + m = isNaN(value) ? 1 : 0; + e = eMax; + } else { + e = Math.floor(Math.log(value) / Math.LN2); + if (value * (c = Math.pow(2, -e)) < 1) { + e--; + c *= 2; + } + if (e + eBias >= 1) { + value += rt / c; + } else { + value += rt * Math.pow(2, 1 - eBias); + } + if (value * c >= 2) { + e++; + c /= 2; + } + if (e + eBias >= eMax) { + m = 0; + e = eMax; + } else if (e + eBias >= 1) { + m = (value * c - 1) * Math.pow(2, mLen); + e = e + eBias; + } else { + m = value * Math.pow(2, eBias - 1) * Math.pow(2, mLen); + e = 0; + } + } + for (; mLen >= 8; buffer[offset + i] = m & 255, i += d, m /= 256, mLen -= 8) { + } + e = e << mLen | m; + eLen += mLen; + for (; eLen > 0; buffer[offset + i] = e & 255, i += d, e /= 256, eLen -= 8) { + } + buffer[offset + i - d] |= s * 128; + } + var toString = {}.toString; + var isArray$1 = Array.isArray || function(arr) { + return toString.call(arr) == "[object Array]"; + }; + var INSPECT_MAX_BYTES = 50; + Buffer.TYPED_ARRAY_SUPPORT = global$1.TYPED_ARRAY_SUPPORT !== void 0 ? global$1.TYPED_ARRAY_SUPPORT : true; + kMaxLength(); + function kMaxLength() { + return Buffer.TYPED_ARRAY_SUPPORT ? 2147483647 : 1073741823; + } + function createBuffer(that, length) { + if (kMaxLength() < length) { + throw new RangeError("Invalid typed array length"); + } + if (Buffer.TYPED_ARRAY_SUPPORT) { + that = new Uint8Array(length); + that.__proto__ = Buffer.prototype; + } else { + if (that === null) { + that = new Buffer(length); + } + that.length = length; + } + return that; + } + function Buffer(arg, encodingOrOffset, length) { + if (!Buffer.TYPED_ARRAY_SUPPORT && !(this instanceof Buffer)) { + return new Buffer(arg, encodingOrOffset, length); + } + if (typeof arg === "number") { + if (typeof encodingOrOffset === "string") { + throw new Error( + "If encoding is specified then the first argument must be a string" + ); + } + return allocUnsafe(this, arg); + } + return from(this, arg, encodingOrOffset, length); + } + Buffer.poolSize = 8192; + Buffer._augment = function(arr) { + arr.__proto__ = Buffer.prototype; + return arr; + }; + function from(that, value, encodingOrOffset, length) { + if (typeof value === "number") { + throw new TypeError('"value" argument must not be a number'); + } + if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) { + return fromArrayBuffer(that, value, encodingOrOffset, length); + } + if (typeof value === "string") { + return fromString(that, value, encodingOrOffset); + } + return fromObject(that, value); + } + Buffer.from = function(value, encodingOrOffset, length) { + return from(null, value, encodingOrOffset, length); + }; + if (Buffer.TYPED_ARRAY_SUPPORT) { + Buffer.prototype.__proto__ = Uint8Array.prototype; + Buffer.__proto__ = Uint8Array; + if (typeof Symbol !== "undefined" && Symbol.species && Buffer[Symbol.species] === Buffer) ; + } + function assertSize(size) { + if (typeof size !== "number") { + throw new TypeError('"size" argument must be a number'); + } else if (size < 0) { + throw new RangeError('"size" argument must not be negative'); + } + } + function alloc(that, size, fill2, encoding) { + assertSize(size); + if (size <= 0) { + return createBuffer(that, size); + } + if (fill2 !== void 0) { + return typeof encoding === "string" ? createBuffer(that, size).fill(fill2, encoding) : createBuffer(that, size).fill(fill2); + } + return createBuffer(that, size); + } + Buffer.alloc = function(size, fill2, encoding) { + return alloc(null, size, fill2, encoding); + }; + function allocUnsafe(that, size) { + assertSize(size); + that = createBuffer(that, size < 0 ? 0 : checked(size) | 0); + if (!Buffer.TYPED_ARRAY_SUPPORT) { + for (var i = 0; i < size; ++i) { + that[i] = 0; + } + } + return that; + } + Buffer.allocUnsafe = function(size) { + return allocUnsafe(null, size); + }; + Buffer.allocUnsafeSlow = function(size) { + return allocUnsafe(null, size); + }; + function fromString(that, string, encoding) { + if (typeof encoding !== "string" || encoding === "") { + encoding = "utf8"; + } + if (!Buffer.isEncoding(encoding)) { + throw new TypeError('"encoding" must be a valid string encoding'); + } + var length = byteLength(string, encoding) | 0; + that = createBuffer(that, length); + var actual = that.write(string, encoding); + if (actual !== length) { + that = that.slice(0, actual); + } + return that; + } + function fromArrayLike(that, array) { + var length = array.length < 0 ? 0 : checked(array.length) | 0; + that = createBuffer(that, length); + for (var i = 0; i < length; i += 1) { + that[i] = array[i] & 255; + } + return that; + } + function fromArrayBuffer(that, array, byteOffset, length) { + array.byteLength; + if (byteOffset < 0 || array.byteLength < byteOffset) { + throw new RangeError("'offset' is out of bounds"); + } + if (array.byteLength < byteOffset + (length || 0)) { + throw new RangeError("'length' is out of bounds"); + } + if (byteOffset === void 0 && length === void 0) { + array = new Uint8Array(array); + } else if (length === void 0) { + array = new Uint8Array(array, byteOffset); + } else { + array = new Uint8Array(array, byteOffset, length); + } + if (Buffer.TYPED_ARRAY_SUPPORT) { + that = array; + that.__proto__ = Buffer.prototype; + } else { + that = fromArrayLike(that, array); + } + return that; + } + function fromObject(that, obj) { + if (internalIsBuffer(obj)) { + var len = checked(obj.length) | 0; + that = createBuffer(that, len); + if (that.length === 0) { + return that; + } + obj.copy(that, 0, 0, len); + return that; + } + if (obj) { + if (typeof ArrayBuffer !== "undefined" && obj.buffer instanceof ArrayBuffer || "length" in obj) { + if (typeof obj.length !== "number" || isnan(obj.length)) { + return createBuffer(that, 0); + } + return fromArrayLike(that, obj); + } + if (obj.type === "Buffer" && isArray$1(obj.data)) { + return fromArrayLike(that, obj.data); + } + } + throw new TypeError("First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object."); + } + function checked(length) { + if (length >= kMaxLength()) { + throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x" + kMaxLength().toString(16) + " bytes"); + } + return length | 0; + } + Buffer.isBuffer = isBuffer; + function internalIsBuffer(b) { + return !!(b != null && b._isBuffer); + } + Buffer.compare = function compare(a, b) { + if (!internalIsBuffer(a) || !internalIsBuffer(b)) { + throw new TypeError("Arguments must be Buffers"); + } + if (a === b) return 0; + var x = a.length; + var y = b.length; + for (var i = 0, len = Math.min(x, y); i < len; ++i) { + if (a[i] !== b[i]) { + x = a[i]; + y = b[i]; + break; + } + } + if (x < y) return -1; + if (y < x) return 1; + return 0; + }; + Buffer.isEncoding = function isEncoding(encoding) { + switch (String(encoding).toLowerCase()) { + case "hex": + case "utf8": + case "utf-8": + case "ascii": + case "latin1": + case "binary": + case "base64": + case "ucs2": + case "ucs-2": + case "utf16le": + case "utf-16le": + return true; + default: + return false; + } + }; + Buffer.concat = function concat(list, length) { + if (!isArray$1(list)) { + throw new TypeError('"list" argument must be an Array of Buffers'); + } + if (list.length === 0) { + return Buffer.alloc(0); + } + var i; + if (length === void 0) { + length = 0; + for (i = 0; i < list.length; ++i) { + length += list[i].length; + } + } + var buffer = Buffer.allocUnsafe(length); + var pos = 0; + for (i = 0; i < list.length; ++i) { + var buf = list[i]; + if (!internalIsBuffer(buf)) { + throw new TypeError('"list" argument must be an Array of Buffers'); + } + buf.copy(buffer, pos); + pos += buf.length; + } + return buffer; + }; + function byteLength(string, encoding) { + if (internalIsBuffer(string)) { + return string.length; + } + if (typeof ArrayBuffer !== "undefined" && typeof ArrayBuffer.isView === "function" && (ArrayBuffer.isView(string) || string instanceof ArrayBuffer)) { + return string.byteLength; + } + if (typeof string !== "string") { + string = "" + string; + } + var len = string.length; + if (len === 0) return 0; + var loweredCase = false; + for (; ; ) { + switch (encoding) { + case "ascii": + case "latin1": + case "binary": + return len; + case "utf8": + case "utf-8": + case void 0: + return utf8ToBytes(string).length; + case "ucs2": + case "ucs-2": + case "utf16le": + case "utf-16le": + return len * 2; + case "hex": + return len >>> 1; + case "base64": + return base64ToBytes(string).length; + default: + if (loweredCase) return utf8ToBytes(string).length; + encoding = ("" + encoding).toLowerCase(); + loweredCase = true; + } + } + } + Buffer.byteLength = byteLength; + function slowToString(encoding, start, end) { + var loweredCase = false; + if (start === void 0 || start < 0) { + start = 0; + } + if (start > this.length) { + return ""; + } + if (end === void 0 || end > this.length) { + end = this.length; + } + if (end <= 0) { + return ""; + } + end >>>= 0; + start >>>= 0; + if (end <= start) { + return ""; + } + if (!encoding) encoding = "utf8"; + while (true) { + switch (encoding) { + case "hex": + return hexSlice(this, start, end); + case "utf8": + case "utf-8": + return utf8Slice(this, start, end); + case "ascii": + return asciiSlice(this, start, end); + case "latin1": + case "binary": + return latin1Slice(this, start, end); + case "base64": + return base64Slice(this, start, end); + case "ucs2": + case "ucs-2": + case "utf16le": + case "utf-16le": + return utf16leSlice(this, start, end); + default: + if (loweredCase) throw new TypeError("Unknown encoding: " + encoding); + encoding = (encoding + "").toLowerCase(); + loweredCase = true; + } + } + } + Buffer.prototype._isBuffer = true; + function swap(b, n, m) { + var i = b[n]; + b[n] = b[m]; + b[m] = i; + } + Buffer.prototype.swap16 = function swap16() { + var len = this.length; + if (len % 2 !== 0) { + throw new RangeError("Buffer size must be a multiple of 16-bits"); + } + for (var i = 0; i < len; i += 2) { + swap(this, i, i + 1); + } + return this; + }; + Buffer.prototype.swap32 = function swap32() { + var len = this.length; + if (len % 4 !== 0) { + throw new RangeError("Buffer size must be a multiple of 32-bits"); + } + for (var i = 0; i < len; i += 4) { + swap(this, i, i + 3); + swap(this, i + 1, i + 2); + } + return this; + }; + Buffer.prototype.swap64 = function swap64() { + var len = this.length; + if (len % 8 !== 0) { + throw new RangeError("Buffer size must be a multiple of 64-bits"); + } + for (var i = 0; i < len; i += 8) { + swap(this, i, i + 7); + swap(this, i + 1, i + 6); + swap(this, i + 2, i + 5); + swap(this, i + 3, i + 4); + } + return this; + }; + Buffer.prototype.toString = function toString2() { + var length = this.length | 0; + if (length === 0) return ""; + if (arguments.length === 0) return utf8Slice(this, 0, length); + return slowToString.apply(this, arguments); + }; + Buffer.prototype.equals = function equals(b) { + if (!internalIsBuffer(b)) throw new TypeError("Argument must be a Buffer"); + if (this === b) return true; + return Buffer.compare(this, b) === 0; + }; + Buffer.prototype.inspect = function inspect() { + var str = ""; + var max = INSPECT_MAX_BYTES; + if (this.length > 0) { + str = this.toString("hex", 0, max).match(/.{2}/g).join(" "); + if (this.length > max) str += " ... "; + } + return ""; + }; + Buffer.prototype.compare = function compare2(target, start, end, thisStart, thisEnd) { + if (!internalIsBuffer(target)) { + throw new TypeError("Argument must be a Buffer"); + } + if (start === void 0) { + start = 0; + } + if (end === void 0) { + end = target ? target.length : 0; + } + if (thisStart === void 0) { + thisStart = 0; + } + if (thisEnd === void 0) { + thisEnd = this.length; + } + if (start < 0 || end > target.length || thisStart < 0 || thisEnd > this.length) { + throw new RangeError("out of range index"); + } + if (thisStart >= thisEnd && start >= end) { + return 0; + } + if (thisStart >= thisEnd) { + return -1; + } + if (start >= end) { + return 1; + } + start >>>= 0; + end >>>= 0; + thisStart >>>= 0; + thisEnd >>>= 0; + if (this === target) return 0; + var x = thisEnd - thisStart; + var y = end - start; + var len = Math.min(x, y); + var thisCopy = this.slice(thisStart, thisEnd); + var targetCopy = target.slice(start, end); + for (var i = 0; i < len; ++i) { + if (thisCopy[i] !== targetCopy[i]) { + x = thisCopy[i]; + y = targetCopy[i]; + break; + } + } + if (x < y) return -1; + if (y < x) return 1; + return 0; + }; + function bidirectionalIndexOf(buffer, val, byteOffset, encoding, dir) { + if (buffer.length === 0) return -1; + if (typeof byteOffset === "string") { + encoding = byteOffset; + byteOffset = 0; + } else if (byteOffset > 2147483647) { + byteOffset = 2147483647; + } else if (byteOffset < -2147483648) { + byteOffset = -2147483648; + } + byteOffset = +byteOffset; + if (isNaN(byteOffset)) { + byteOffset = dir ? 0 : buffer.length - 1; + } + if (byteOffset < 0) byteOffset = buffer.length + byteOffset; + if (byteOffset >= buffer.length) { + if (dir) return -1; + else byteOffset = buffer.length - 1; + } else if (byteOffset < 0) { + if (dir) byteOffset = 0; + else return -1; + } + if (typeof val === "string") { + val = Buffer.from(val, encoding); + } + if (internalIsBuffer(val)) { + if (val.length === 0) { + return -1; + } + return arrayIndexOf(buffer, val, byteOffset, encoding, dir); + } else if (typeof val === "number") { + val = val & 255; + if (Buffer.TYPED_ARRAY_SUPPORT && typeof Uint8Array.prototype.indexOf === "function") { + if (dir) { + return Uint8Array.prototype.indexOf.call(buffer, val, byteOffset); + } else { + return Uint8Array.prototype.lastIndexOf.call(buffer, val, byteOffset); + } + } + return arrayIndexOf(buffer, [val], byteOffset, encoding, dir); + } + throw new TypeError("val must be string, number or Buffer"); + } + function arrayIndexOf(arr, val, byteOffset, encoding, dir) { + var indexSize = 1; + var arrLength = arr.length; + var valLength = val.length; + if (encoding !== void 0) { + encoding = String(encoding).toLowerCase(); + if (encoding === "ucs2" || encoding === "ucs-2" || encoding === "utf16le" || encoding === "utf-16le") { + if (arr.length < 2 || val.length < 2) { + return -1; + } + indexSize = 2; + arrLength /= 2; + valLength /= 2; + byteOffset /= 2; + } + } + function read2(buf, i2) { + if (indexSize === 1) { + return buf[i2]; + } else { + return buf.readUInt16BE(i2 * indexSize); + } + } + var i; + if (dir) { + var foundIndex = -1; + for (i = byteOffset; i < arrLength; i++) { + if (read2(arr, i) === read2(val, foundIndex === -1 ? 0 : i - foundIndex)) { + if (foundIndex === -1) foundIndex = i; + if (i - foundIndex + 1 === valLength) return foundIndex * indexSize; + } else { + if (foundIndex !== -1) i -= i - foundIndex; + foundIndex = -1; + } + } + } else { + if (byteOffset + valLength > arrLength) byteOffset = arrLength - valLength; + for (i = byteOffset; i >= 0; i--) { + var found = true; + for (var j = 0; j < valLength; j++) { + if (read2(arr, i + j) !== read2(val, j)) { + found = false; + break; + } + } + if (found) return i; + } + } + return -1; + } + Buffer.prototype.includes = function includes(val, byteOffset, encoding) { + return this.indexOf(val, byteOffset, encoding) !== -1; + }; + Buffer.prototype.indexOf = function indexOf(val, byteOffset, encoding) { + return bidirectionalIndexOf(this, val, byteOffset, encoding, true); + }; + Buffer.prototype.lastIndexOf = function lastIndexOf(val, byteOffset, encoding) { + return bidirectionalIndexOf(this, val, byteOffset, encoding, false); + }; + function hexWrite(buf, string, offset, length) { + offset = Number(offset) || 0; + var remaining = buf.length - offset; + if (!length) { + length = remaining; + } else { + length = Number(length); + if (length > remaining) { + length = remaining; + } + } + var strLen = string.length; + if (strLen % 2 !== 0) throw new TypeError("Invalid hex string"); + if (length > strLen / 2) { + length = strLen / 2; + } + for (var i = 0; i < length; ++i) { + var parsed = parseInt(string.substr(i * 2, 2), 16); + if (isNaN(parsed)) return i; + buf[offset + i] = parsed; + } + return i; + } + function utf8Write(buf, string, offset, length) { + return blitBuffer(utf8ToBytes(string, buf.length - offset), buf, offset, length); + } + function asciiWrite(buf, string, offset, length) { + return blitBuffer(asciiToBytes(string), buf, offset, length); + } + function latin1Write(buf, string, offset, length) { + return asciiWrite(buf, string, offset, length); + } + function base64Write(buf, string, offset, length) { + return blitBuffer(base64ToBytes(string), buf, offset, length); + } + function ucs2Write(buf, string, offset, length) { + return blitBuffer(utf16leToBytes(string, buf.length - offset), buf, offset, length); + } + Buffer.prototype.write = function write2(string, offset, length, encoding) { + if (offset === void 0) { + encoding = "utf8"; + length = this.length; + offset = 0; + } else if (length === void 0 && typeof offset === "string") { + encoding = offset; + length = this.length; + offset = 0; + } else if (isFinite(offset)) { + offset = offset | 0; + if (isFinite(length)) { + length = length | 0; + if (encoding === void 0) encoding = "utf8"; + } else { + encoding = length; + length = void 0; + } + } else { + throw new Error( + "Buffer.write(string, encoding, offset[, length]) is no longer supported" + ); + } + var remaining = this.length - offset; + if (length === void 0 || length > remaining) length = remaining; + if (string.length > 0 && (length < 0 || offset < 0) || offset > this.length) { + throw new RangeError("Attempt to write outside buffer bounds"); + } + if (!encoding) encoding = "utf8"; + var loweredCase = false; + for (; ; ) { + switch (encoding) { + case "hex": + return hexWrite(this, string, offset, length); + case "utf8": + case "utf-8": + return utf8Write(this, string, offset, length); + case "ascii": + return asciiWrite(this, string, offset, length); + case "latin1": + case "binary": + return latin1Write(this, string, offset, length); + case "base64": + return base64Write(this, string, offset, length); + case "ucs2": + case "ucs-2": + case "utf16le": + case "utf-16le": + return ucs2Write(this, string, offset, length); + default: + if (loweredCase) throw new TypeError("Unknown encoding: " + encoding); + encoding = ("" + encoding).toLowerCase(); + loweredCase = true; + } + } + }; + Buffer.prototype.toJSON = function toJSON() { + return { + type: "Buffer", + data: Array.prototype.slice.call(this._arr || this, 0) + }; + }; + function base64Slice(buf, start, end) { + if (start === 0 && end === buf.length) { + return fromByteArray(buf); + } else { + return fromByteArray(buf.slice(start, end)); + } + } + function utf8Slice(buf, start, end) { + end = Math.min(buf.length, end); + var res = []; + var i = start; + while (i < end) { + var firstByte = buf[i]; + var codePoint = null; + var bytesPerSequence = firstByte > 239 ? 4 : firstByte > 223 ? 3 : firstByte > 191 ? 2 : 1; + if (i + bytesPerSequence <= end) { + var secondByte, thirdByte, fourthByte, tempCodePoint; + switch (bytesPerSequence) { + case 1: + if (firstByte < 128) { + codePoint = firstByte; + } + break; + case 2: + secondByte = buf[i + 1]; + if ((secondByte & 192) === 128) { + tempCodePoint = (firstByte & 31) << 6 | secondByte & 63; + if (tempCodePoint > 127) { + codePoint = tempCodePoint; + } + } + break; + case 3: + secondByte = buf[i + 1]; + thirdByte = buf[i + 2]; + if ((secondByte & 192) === 128 && (thirdByte & 192) === 128) { + tempCodePoint = (firstByte & 15) << 12 | (secondByte & 63) << 6 | thirdByte & 63; + if (tempCodePoint > 2047 && (tempCodePoint < 55296 || tempCodePoint > 57343)) { + codePoint = tempCodePoint; + } + } + break; + case 4: + secondByte = buf[i + 1]; + thirdByte = buf[i + 2]; + fourthByte = buf[i + 3]; + if ((secondByte & 192) === 128 && (thirdByte & 192) === 128 && (fourthByte & 192) === 128) { + tempCodePoint = (firstByte & 15) << 18 | (secondByte & 63) << 12 | (thirdByte & 63) << 6 | fourthByte & 63; + if (tempCodePoint > 65535 && tempCodePoint < 1114112) { + codePoint = tempCodePoint; + } + } + } + } + if (codePoint === null) { + codePoint = 65533; + bytesPerSequence = 1; + } else if (codePoint > 65535) { + codePoint -= 65536; + res.push(codePoint >>> 10 & 1023 | 55296); + codePoint = 56320 | codePoint & 1023; + } + res.push(codePoint); + i += bytesPerSequence; + } + return decodeCodePointsArray(res); + } + var MAX_ARGUMENTS_LENGTH = 4096; + function decodeCodePointsArray(codePoints) { + var len = codePoints.length; + if (len <= MAX_ARGUMENTS_LENGTH) { + return String.fromCharCode.apply(String, codePoints); + } + var res = ""; + var i = 0; + while (i < len) { + res += String.fromCharCode.apply( + String, + codePoints.slice(i, i += MAX_ARGUMENTS_LENGTH) + ); + } + return res; + } + function asciiSlice(buf, start, end) { + var ret = ""; + end = Math.min(buf.length, end); + for (var i = start; i < end; ++i) { + ret += String.fromCharCode(buf[i] & 127); + } + return ret; + } + function latin1Slice(buf, start, end) { + var ret = ""; + end = Math.min(buf.length, end); + for (var i = start; i < end; ++i) { + ret += String.fromCharCode(buf[i]); + } + return ret; + } + function hexSlice(buf, start, end) { + var len = buf.length; + if (!start || start < 0) start = 0; + if (!end || end < 0 || end > len) end = len; + var out = ""; + for (var i = start; i < end; ++i) { + out += toHex(buf[i]); + } + return out; + } + function utf16leSlice(buf, start, end) { + var bytes = buf.slice(start, end); + var res = ""; + for (var i = 0; i < bytes.length; i += 2) { + res += String.fromCharCode(bytes[i] + bytes[i + 1] * 256); + } + return res; + } + Buffer.prototype.slice = function slice(start, end) { + var len = this.length; + start = ~~start; + end = end === void 0 ? len : ~~end; + if (start < 0) { + start += len; + if (start < 0) start = 0; + } else if (start > len) { + start = len; + } + if (end < 0) { + end += len; + if (end < 0) end = 0; + } else if (end > len) { + end = len; + } + if (end < start) end = start; + var newBuf; + if (Buffer.TYPED_ARRAY_SUPPORT) { + newBuf = this.subarray(start, end); + newBuf.__proto__ = Buffer.prototype; + } else { + var sliceLen = end - start; + newBuf = new Buffer(sliceLen, void 0); + for (var i = 0; i < sliceLen; ++i) { + newBuf[i] = this[i + start]; + } + } + return newBuf; + }; + function checkOffset(offset, ext, length) { + if (offset % 1 !== 0 || offset < 0) throw new RangeError("offset is not uint"); + if (offset + ext > length) throw new RangeError("Trying to access beyond buffer length"); + } + Buffer.prototype.readUIntLE = function readUIntLE(offset, byteLength2, noAssert) { + offset = offset | 0; + byteLength2 = byteLength2 | 0; + if (!noAssert) checkOffset(offset, byteLength2, this.length); + var val = this[offset]; + var mul = 1; + var i = 0; + while (++i < byteLength2 && (mul *= 256)) { + val += this[offset + i] * mul; + } + return val; + }; + Buffer.prototype.readUIntBE = function readUIntBE(offset, byteLength2, noAssert) { + offset = offset | 0; + byteLength2 = byteLength2 | 0; + if (!noAssert) { + checkOffset(offset, byteLength2, this.length); + } + var val = this[offset + --byteLength2]; + var mul = 1; + while (byteLength2 > 0 && (mul *= 256)) { + val += this[offset + --byteLength2] * mul; + } + return val; + }; + Buffer.prototype.readUInt8 = function readUInt8(offset, noAssert) { + if (!noAssert) checkOffset(offset, 1, this.length); + return this[offset]; + }; + Buffer.prototype.readUInt16LE = function readUInt16LE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 2, this.length); + return this[offset] | this[offset + 1] << 8; + }; + Buffer.prototype.readUInt16BE = function readUInt16BE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 2, this.length); + return this[offset] << 8 | this[offset + 1]; + }; + Buffer.prototype.readUInt32LE = function readUInt32LE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 4, this.length); + return (this[offset] | this[offset + 1] << 8 | this[offset + 2] << 16) + this[offset + 3] * 16777216; + }; + Buffer.prototype.readUInt32BE = function readUInt32BE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 4, this.length); + return this[offset] * 16777216 + (this[offset + 1] << 16 | this[offset + 2] << 8 | this[offset + 3]); + }; + Buffer.prototype.readIntLE = function readIntLE(offset, byteLength2, noAssert) { + offset = offset | 0; + byteLength2 = byteLength2 | 0; + if (!noAssert) checkOffset(offset, byteLength2, this.length); + var val = this[offset]; + var mul = 1; + var i = 0; + while (++i < byteLength2 && (mul *= 256)) { + val += this[offset + i] * mul; + } + mul *= 128; + if (val >= mul) val -= Math.pow(2, 8 * byteLength2); + return val; + }; + Buffer.prototype.readIntBE = function readIntBE(offset, byteLength2, noAssert) { + offset = offset | 0; + byteLength2 = byteLength2 | 0; + if (!noAssert) checkOffset(offset, byteLength2, this.length); + var i = byteLength2; + var mul = 1; + var val = this[offset + --i]; + while (i > 0 && (mul *= 256)) { + val += this[offset + --i] * mul; + } + mul *= 128; + if (val >= mul) val -= Math.pow(2, 8 * byteLength2); + return val; + }; + Buffer.prototype.readInt8 = function readInt8(offset, noAssert) { + if (!noAssert) checkOffset(offset, 1, this.length); + if (!(this[offset] & 128)) return this[offset]; + return (255 - this[offset] + 1) * -1; + }; + Buffer.prototype.readInt16LE = function readInt16LE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 2, this.length); + var val = this[offset] | this[offset + 1] << 8; + return val & 32768 ? val | 4294901760 : val; + }; + Buffer.prototype.readInt16BE = function readInt16BE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 2, this.length); + var val = this[offset + 1] | this[offset] << 8; + return val & 32768 ? val | 4294901760 : val; + }; + Buffer.prototype.readInt32LE = function readInt32LE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 4, this.length); + return this[offset] | this[offset + 1] << 8 | this[offset + 2] << 16 | this[offset + 3] << 24; + }; + Buffer.prototype.readInt32BE = function readInt32BE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 4, this.length); + return this[offset] << 24 | this[offset + 1] << 16 | this[offset + 2] << 8 | this[offset + 3]; + }; + Buffer.prototype.readFloatLE = function readFloatLE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 4, this.length); + return read(this, offset, true, 23, 4); + }; + Buffer.prototype.readFloatBE = function readFloatBE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 4, this.length); + return read(this, offset, false, 23, 4); + }; + Buffer.prototype.readDoubleLE = function readDoubleLE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 8, this.length); + return read(this, offset, true, 52, 8); + }; + Buffer.prototype.readDoubleBE = function readDoubleBE(offset, noAssert) { + if (!noAssert) checkOffset(offset, 8, this.length); + return read(this, offset, false, 52, 8); + }; + function checkInt(buf, value, offset, ext, max, min) { + if (!internalIsBuffer(buf)) throw new TypeError('"buffer" argument must be a Buffer instance'); + if (value > max || value < min) throw new RangeError('"value" argument is out of bounds'); + if (offset + ext > buf.length) throw new RangeError("Index out of range"); + } + Buffer.prototype.writeUIntLE = function writeUIntLE(value, offset, byteLength2, noAssert) { + value = +value; + offset = offset | 0; + byteLength2 = byteLength2 | 0; + if (!noAssert) { + var maxBytes = Math.pow(2, 8 * byteLength2) - 1; + checkInt(this, value, offset, byteLength2, maxBytes, 0); + } + var mul = 1; + var i = 0; + this[offset] = value & 255; + while (++i < byteLength2 && (mul *= 256)) { + this[offset + i] = value / mul & 255; + } + return offset + byteLength2; + }; + Buffer.prototype.writeUIntBE = function writeUIntBE(value, offset, byteLength2, noAssert) { + value = +value; + offset = offset | 0; + byteLength2 = byteLength2 | 0; + if (!noAssert) { + var maxBytes = Math.pow(2, 8 * byteLength2) - 1; + checkInt(this, value, offset, byteLength2, maxBytes, 0); + } + var i = byteLength2 - 1; + var mul = 1; + this[offset + i] = value & 255; + while (--i >= 0 && (mul *= 256)) { + this[offset + i] = value / mul & 255; + } + return offset + byteLength2; + }; + Buffer.prototype.writeUInt8 = function writeUInt8(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 1, 255, 0); + if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value); + this[offset] = value & 255; + return offset + 1; + }; + function objectWriteUInt16(buf, value, offset, littleEndian) { + if (value < 0) value = 65535 + value + 1; + for (var i = 0, j = Math.min(buf.length - offset, 2); i < j; ++i) { + buf[offset + i] = (value & 255 << 8 * (littleEndian ? i : 1 - i)) >>> (littleEndian ? i : 1 - i) * 8; + } + } + Buffer.prototype.writeUInt16LE = function writeUInt16LE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 2, 65535, 0); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value & 255; + this[offset + 1] = value >>> 8; + } else { + objectWriteUInt16(this, value, offset, true); + } + return offset + 2; + }; + Buffer.prototype.writeUInt16BE = function writeUInt16BE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 2, 65535, 0); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value >>> 8; + this[offset + 1] = value & 255; + } else { + objectWriteUInt16(this, value, offset, false); + } + return offset + 2; + }; + function objectWriteUInt32(buf, value, offset, littleEndian) { + if (value < 0) value = 4294967295 + value + 1; + for (var i = 0, j = Math.min(buf.length - offset, 4); i < j; ++i) { + buf[offset + i] = value >>> (littleEndian ? i : 3 - i) * 8 & 255; + } + } + Buffer.prototype.writeUInt32LE = function writeUInt32LE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 4, 4294967295, 0); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset + 3] = value >>> 24; + this[offset + 2] = value >>> 16; + this[offset + 1] = value >>> 8; + this[offset] = value & 255; + } else { + objectWriteUInt32(this, value, offset, true); + } + return offset + 4; + }; + Buffer.prototype.writeUInt32BE = function writeUInt32BE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 4, 4294967295, 0); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value >>> 24; + this[offset + 1] = value >>> 16; + this[offset + 2] = value >>> 8; + this[offset + 3] = value & 255; + } else { + objectWriteUInt32(this, value, offset, false); + } + return offset + 4; + }; + Buffer.prototype.writeIntLE = function writeIntLE(value, offset, byteLength2, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) { + var limit = Math.pow(2, 8 * byteLength2 - 1); + checkInt(this, value, offset, byteLength2, limit - 1, -limit); + } + var i = 0; + var mul = 1; + var sub = 0; + this[offset] = value & 255; + while (++i < byteLength2 && (mul *= 256)) { + if (value < 0 && sub === 0 && this[offset + i - 1] !== 0) { + sub = 1; + } + this[offset + i] = (value / mul >> 0) - sub & 255; + } + return offset + byteLength2; + }; + Buffer.prototype.writeIntBE = function writeIntBE(value, offset, byteLength2, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) { + var limit = Math.pow(2, 8 * byteLength2 - 1); + checkInt(this, value, offset, byteLength2, limit - 1, -limit); + } + var i = byteLength2 - 1; + var mul = 1; + var sub = 0; + this[offset + i] = value & 255; + while (--i >= 0 && (mul *= 256)) { + if (value < 0 && sub === 0 && this[offset + i + 1] !== 0) { + sub = 1; + } + this[offset + i] = (value / mul >> 0) - sub & 255; + } + return offset + byteLength2; + }; + Buffer.prototype.writeInt8 = function writeInt8(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 1, 127, -128); + if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value); + if (value < 0) value = 255 + value + 1; + this[offset] = value & 255; + return offset + 1; + }; + Buffer.prototype.writeInt16LE = function writeInt16LE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 2, 32767, -32768); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value & 255; + this[offset + 1] = value >>> 8; + } else { + objectWriteUInt16(this, value, offset, true); + } + return offset + 2; + }; + Buffer.prototype.writeInt16BE = function writeInt16BE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 2, 32767, -32768); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value >>> 8; + this[offset + 1] = value & 255; + } else { + objectWriteUInt16(this, value, offset, false); + } + return offset + 2; + }; + Buffer.prototype.writeInt32LE = function writeInt32LE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 4, 2147483647, -2147483648); + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value & 255; + this[offset + 1] = value >>> 8; + this[offset + 2] = value >>> 16; + this[offset + 3] = value >>> 24; + } else { + objectWriteUInt32(this, value, offset, true); + } + return offset + 4; + }; + Buffer.prototype.writeInt32BE = function writeInt32BE(value, offset, noAssert) { + value = +value; + offset = offset | 0; + if (!noAssert) checkInt(this, value, offset, 4, 2147483647, -2147483648); + if (value < 0) value = 4294967295 + value + 1; + if (Buffer.TYPED_ARRAY_SUPPORT) { + this[offset] = value >>> 24; + this[offset + 1] = value >>> 16; + this[offset + 2] = value >>> 8; + this[offset + 3] = value & 255; + } else { + objectWriteUInt32(this, value, offset, false); + } + return offset + 4; + }; + function checkIEEE754(buf, value, offset, ext, max, min) { + if (offset + ext > buf.length) throw new RangeError("Index out of range"); + if (offset < 0) throw new RangeError("Index out of range"); + } + function writeFloat(buf, value, offset, littleEndian, noAssert) { + if (!noAssert) { + checkIEEE754(buf, value, offset, 4); + } + write(buf, value, offset, littleEndian, 23, 4); + return offset + 4; + } + Buffer.prototype.writeFloatLE = function writeFloatLE(value, offset, noAssert) { + return writeFloat(this, value, offset, true, noAssert); + }; + Buffer.prototype.writeFloatBE = function writeFloatBE(value, offset, noAssert) { + return writeFloat(this, value, offset, false, noAssert); + }; + function writeDouble(buf, value, offset, littleEndian, noAssert) { + if (!noAssert) { + checkIEEE754(buf, value, offset, 8); + } + write(buf, value, offset, littleEndian, 52, 8); + return offset + 8; + } + Buffer.prototype.writeDoubleLE = function writeDoubleLE(value, offset, noAssert) { + return writeDouble(this, value, offset, true, noAssert); + }; + Buffer.prototype.writeDoubleBE = function writeDoubleBE(value, offset, noAssert) { + return writeDouble(this, value, offset, false, noAssert); + }; + Buffer.prototype.copy = function copy(target, targetStart, start, end) { + if (!start) start = 0; + if (!end && end !== 0) end = this.length; + if (targetStart >= target.length) targetStart = target.length; + if (!targetStart) targetStart = 0; + if (end > 0 && end < start) end = start; + if (end === start) return 0; + if (target.length === 0 || this.length === 0) return 0; + if (targetStart < 0) { + throw new RangeError("targetStart out of bounds"); + } + if (start < 0 || start >= this.length) throw new RangeError("sourceStart out of bounds"); + if (end < 0) throw new RangeError("sourceEnd out of bounds"); + if (end > this.length) end = this.length; + if (target.length - targetStart < end - start) { + end = target.length - targetStart + start; + } + var len = end - start; + var i; + if (this === target && start < targetStart && targetStart < end) { + for (i = len - 1; i >= 0; --i) { + target[i + targetStart] = this[i + start]; + } + } else if (len < 1e3 || !Buffer.TYPED_ARRAY_SUPPORT) { + for (i = 0; i < len; ++i) { + target[i + targetStart] = this[i + start]; + } + } else { + Uint8Array.prototype.set.call( + target, + this.subarray(start, start + len), + targetStart + ); + } + return len; + }; + Buffer.prototype.fill = function fill(val, start, end, encoding) { + if (typeof val === "string") { + if (typeof start === "string") { + encoding = start; + start = 0; + end = this.length; + } else if (typeof end === "string") { + encoding = end; + end = this.length; + } + if (val.length === 1) { + var code = val.charCodeAt(0); + if (code < 256) { + val = code; + } + } + if (encoding !== void 0 && typeof encoding !== "string") { + throw new TypeError("encoding must be a string"); + } + if (typeof encoding === "string" && !Buffer.isEncoding(encoding)) { + throw new TypeError("Unknown encoding: " + encoding); + } + } else if (typeof val === "number") { + val = val & 255; + } + if (start < 0 || this.length < start || this.length < end) { + throw new RangeError("Out of range index"); + } + if (end <= start) { + return this; + } + start = start >>> 0; + end = end === void 0 ? this.length : end >>> 0; + if (!val) val = 0; + var i; + if (typeof val === "number") { + for (i = start; i < end; ++i) { + this[i] = val; + } + } else { + var bytes = internalIsBuffer(val) ? val : utf8ToBytes(new Buffer(val, encoding).toString()); + var len = bytes.length; + for (i = 0; i < end - start; ++i) { + this[i + start] = bytes[i % len]; + } + } + return this; + }; + var INVALID_BASE64_RE = /[^+\/0-9A-Za-z-_]/g; + function base64clean(str) { + str = stringtrim(str).replace(INVALID_BASE64_RE, ""); + if (str.length < 2) return ""; + while (str.length % 4 !== 0) { + str = str + "="; + } + return str; + } + function stringtrim(str) { + if (str.trim) return str.trim(); + return str.replace(/^\s+|\s+$/g, ""); + } + function toHex(n) { + if (n < 16) return "0" + n.toString(16); + return n.toString(16); + } + function utf8ToBytes(string, units) { + units = units || Infinity; + var codePoint; + var length = string.length; + var leadSurrogate = null; + var bytes = []; + for (var i = 0; i < length; ++i) { + codePoint = string.charCodeAt(i); + if (codePoint > 55295 && codePoint < 57344) { + if (!leadSurrogate) { + if (codePoint > 56319) { + if ((units -= 3) > -1) bytes.push(239, 191, 189); + continue; + } else if (i + 1 === length) { + if ((units -= 3) > -1) bytes.push(239, 191, 189); + continue; + } + leadSurrogate = codePoint; + continue; + } + if (codePoint < 56320) { + if ((units -= 3) > -1) bytes.push(239, 191, 189); + leadSurrogate = codePoint; + continue; + } + codePoint = (leadSurrogate - 55296 << 10 | codePoint - 56320) + 65536; + } else if (leadSurrogate) { + if ((units -= 3) > -1) bytes.push(239, 191, 189); + } + leadSurrogate = null; + if (codePoint < 128) { + if ((units -= 1) < 0) break; + bytes.push(codePoint); + } else if (codePoint < 2048) { + if ((units -= 2) < 0) break; + bytes.push( + codePoint >> 6 | 192, + codePoint & 63 | 128 + ); + } else if (codePoint < 65536) { + if ((units -= 3) < 0) break; + bytes.push( + codePoint >> 12 | 224, + codePoint >> 6 & 63 | 128, + codePoint & 63 | 128 + ); + } else if (codePoint < 1114112) { + if ((units -= 4) < 0) break; + bytes.push( + codePoint >> 18 | 240, + codePoint >> 12 & 63 | 128, + codePoint >> 6 & 63 | 128, + codePoint & 63 | 128 + ); + } else { + throw new Error("Invalid code point"); + } + } + return bytes; + } + function asciiToBytes(str) { + var byteArray = []; + for (var i = 0; i < str.length; ++i) { + byteArray.push(str.charCodeAt(i) & 255); + } + return byteArray; + } + function utf16leToBytes(str, units) { + var c, hi, lo; + var byteArray = []; + for (var i = 0; i < str.length; ++i) { + if ((units -= 2) < 0) break; + c = str.charCodeAt(i); + hi = c >> 8; + lo = c % 256; + byteArray.push(lo); + byteArray.push(hi); + } + return byteArray; + } + function base64ToBytes(str) { + return toByteArray(base64clean(str)); + } + function blitBuffer(src, dst, offset, length) { + for (var i = 0; i < length; ++i) { + if (i + offset >= dst.length || i >= src.length) break; + dst[i + offset] = src[i]; + } + return i; + } + function isnan(val) { + return val !== val; + } + function isBuffer(obj) { + return obj != null && (!!obj._isBuffer || isFastBuffer(obj) || isSlowBuffer(obj)); + } + function isFastBuffer(obj) { + return !!obj.constructor && typeof obj.constructor.isBuffer === "function" && obj.constructor.isBuffer(obj); + } + function isSlowBuffer(obj) { + return typeof obj.readFloatLE === "function" && typeof obj.slice === "function" && isFastBuffer(obj.slice(0, 0)); + } + var domain; + function EventHandlers() { + } + EventHandlers.prototype = /* @__PURE__ */ Object.create(null); + function EventEmitter() { + EventEmitter.init.call(this); + } + EventEmitter.EventEmitter = EventEmitter; + EventEmitter.usingDomains = false; + EventEmitter.prototype.domain = void 0; + EventEmitter.prototype._events = void 0; + EventEmitter.prototype._maxListeners = void 0; + EventEmitter.defaultMaxListeners = 10; + EventEmitter.init = function() { + this.domain = null; + if (EventEmitter.usingDomains) { + if (domain.active) ; + } + if (!this._events || this._events === Object.getPrototypeOf(this)._events) { + this._events = new EventHandlers(); + this._eventsCount = 0; + } + this._maxListeners = this._maxListeners || void 0; + }; + EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) { + if (typeof n !== "number" || n < 0 || isNaN(n)) + throw new TypeError('"n" argument must be a positive number'); + this._maxListeners = n; + return this; + }; + function $getMaxListeners(that) { + if (that._maxListeners === void 0) + return EventEmitter.defaultMaxListeners; + return that._maxListeners; + } + EventEmitter.prototype.getMaxListeners = function getMaxListeners() { + return $getMaxListeners(this); + }; + function emitNone(handler, isFn, self2) { + if (isFn) + handler.call(self2); + else { + var len = handler.length; + var listeners2 = arrayClone(handler, len); + for (var i = 0; i < len; ++i) + listeners2[i].call(self2); + } + } + function emitOne(handler, isFn, self2, arg1) { + if (isFn) + handler.call(self2, arg1); + else { + var len = handler.length; + var listeners2 = arrayClone(handler, len); + for (var i = 0; i < len; ++i) + listeners2[i].call(self2, arg1); + } + } + function emitTwo(handler, isFn, self2, arg1, arg2) { + if (isFn) + handler.call(self2, arg1, arg2); + else { + var len = handler.length; + var listeners2 = arrayClone(handler, len); + for (var i = 0; i < len; ++i) + listeners2[i].call(self2, arg1, arg2); + } + } + function emitThree(handler, isFn, self2, arg1, arg2, arg3) { + if (isFn) + handler.call(self2, arg1, arg2, arg3); + else { + var len = handler.length; + var listeners2 = arrayClone(handler, len); + for (var i = 0; i < len; ++i) + listeners2[i].call(self2, arg1, arg2, arg3); + } + } + function emitMany(handler, isFn, self2, args) { + if (isFn) + handler.apply(self2, args); + else { + var len = handler.length; + var listeners2 = arrayClone(handler, len); + for (var i = 0; i < len; ++i) + listeners2[i].apply(self2, args); + } + } + EventEmitter.prototype.emit = function emit(type) { + var er, handler, len, args, i, events, domain2; + var doError = type === "error"; + events = this._events; + if (events) + doError = doError && events.error == null; + else if (!doError) + return false; + domain2 = this.domain; + if (doError) { + er = arguments[1]; + if (domain2) { + if (!er) + er = new Error('Uncaught, unspecified "error" event'); + er.domainEmitter = this; + er.domain = domain2; + er.domainThrown = false; + domain2.emit("error", er); + } else if (er instanceof Error) { + throw er; + } else { + var err = new Error('Uncaught, unspecified "error" event. (' + er + ")"); + err.context = er; + throw err; + } + return false; + } + handler = events[type]; + if (!handler) + return false; + var isFn = typeof handler === "function"; + len = arguments.length; + switch (len) { + // fast cases + case 1: + emitNone(handler, isFn, this); + break; + case 2: + emitOne(handler, isFn, this, arguments[1]); + break; + case 3: + emitTwo(handler, isFn, this, arguments[1], arguments[2]); + break; + case 4: + emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]); + break; + // slower + default: + args = new Array(len - 1); + for (i = 1; i < len; i++) + args[i - 1] = arguments[i]; + emitMany(handler, isFn, this, args); + } + return true; + }; + function _addListener(target, type, listener, prepend) { + var m; + var events; + var existing; + if (typeof listener !== "function") + throw new TypeError('"listener" argument must be a function'); + events = target._events; + if (!events) { + events = target._events = new EventHandlers(); + target._eventsCount = 0; + } else { + if (events.newListener) { + target.emit( + "newListener", + type, + listener.listener ? listener.listener : listener + ); + events = target._events; + } + existing = events[type]; + } + if (!existing) { + existing = events[type] = listener; + ++target._eventsCount; + } else { + if (typeof existing === "function") { + existing = events[type] = prepend ? [listener, existing] : [existing, listener]; + } else { + if (prepend) { + existing.unshift(listener); + } else { + existing.push(listener); + } + } + if (!existing.warned) { + m = $getMaxListeners(target); + if (m && m > 0 && existing.length > m) { + existing.warned = true; + var w = new Error("Possible EventEmitter memory leak detected. " + existing.length + " " + type + " listeners added. Use emitter.setMaxListeners() to increase limit"); + w.name = "MaxListenersExceededWarning"; + w.emitter = target; + w.type = type; + w.count = existing.length; + emitWarning(w); + } + } + } + return target; + } + function emitWarning(e) { + typeof console.warn === "function" ? console.warn(e) : console.log(e); + } + EventEmitter.prototype.addListener = function addListener(type, listener) { + return _addListener(this, type, listener, false); + }; + EventEmitter.prototype.on = EventEmitter.prototype.addListener; + EventEmitter.prototype.prependListener = function prependListener(type, listener) { + return _addListener(this, type, listener, true); + }; + function _onceWrap(target, type, listener) { + var fired = false; + function g() { + target.removeListener(type, g); + if (!fired) { + fired = true; + listener.apply(target, arguments); + } + } + g.listener = listener; + return g; + } + EventEmitter.prototype.once = function once(type, listener) { + if (typeof listener !== "function") + throw new TypeError('"listener" argument must be a function'); + this.on(type, _onceWrap(this, type, listener)); + return this; + }; + EventEmitter.prototype.prependOnceListener = function prependOnceListener(type, listener) { + if (typeof listener !== "function") + throw new TypeError('"listener" argument must be a function'); + this.prependListener(type, _onceWrap(this, type, listener)); + return this; + }; + EventEmitter.prototype.removeListener = function removeListener(type, listener) { + var list, events, position, i, originalListener; + if (typeof listener !== "function") + throw new TypeError('"listener" argument must be a function'); + events = this._events; + if (!events) + return this; + list = events[type]; + if (!list) + return this; + if (list === listener || list.listener && list.listener === listener) { + if (--this._eventsCount === 0) + this._events = new EventHandlers(); + else { + delete events[type]; + if (events.removeListener) + this.emit("removeListener", type, list.listener || listener); + } + } else if (typeof list !== "function") { + position = -1; + for (i = list.length; i-- > 0; ) { + if (list[i] === listener || list[i].listener && list[i].listener === listener) { + originalListener = list[i].listener; + position = i; + break; + } + } + if (position < 0) + return this; + if (list.length === 1) { + list[0] = void 0; + if (--this._eventsCount === 0) { + this._events = new EventHandlers(); + return this; + } else { + delete events[type]; + } + } else { + spliceOne(list, position); + } + if (events.removeListener) + this.emit("removeListener", type, originalListener || listener); + } + return this; + }; + EventEmitter.prototype.removeAllListeners = function removeAllListeners(type) { + var listeners2, events; + events = this._events; + if (!events) + return this; + if (!events.removeListener) { + if (arguments.length === 0) { + this._events = new EventHandlers(); + this._eventsCount = 0; + } else if (events[type]) { + if (--this._eventsCount === 0) + this._events = new EventHandlers(); + else + delete events[type]; + } + return this; + } + if (arguments.length === 0) { + var keys2 = Object.keys(events); + for (var i = 0, key; i < keys2.length; ++i) { + key = keys2[i]; + if (key === "removeListener") continue; + this.removeAllListeners(key); + } + this.removeAllListeners("removeListener"); + this._events = new EventHandlers(); + this._eventsCount = 0; + return this; + } + listeners2 = events[type]; + if (typeof listeners2 === "function") { + this.removeListener(type, listeners2); + } else if (listeners2) { + do { + this.removeListener(type, listeners2[listeners2.length - 1]); + } while (listeners2[0]); + } + return this; + }; + EventEmitter.prototype.listeners = function listeners(type) { + var evlistener; + var ret; + var events = this._events; + if (!events) + ret = []; + else { + evlistener = events[type]; + if (!evlistener) + ret = []; + else if (typeof evlistener === "function") + ret = [evlistener.listener || evlistener]; + else + ret = unwrapListeners(evlistener); + } + return ret; + }; + EventEmitter.listenerCount = function(emitter, type) { + if (typeof emitter.listenerCount === "function") { + return emitter.listenerCount(type); + } else { + return listenerCount$1.call(emitter, type); + } + }; + EventEmitter.prototype.listenerCount = listenerCount$1; + function listenerCount$1(type) { + var events = this._events; + if (events) { + var evlistener = events[type]; + if (typeof evlistener === "function") { + return 1; + } else if (evlistener) { + return evlistener.length; + } + } + return 0; + } + EventEmitter.prototype.eventNames = function eventNames() { + return this._eventsCount > 0 ? Reflect.ownKeys(this._events) : []; + }; + function spliceOne(list, index) { + for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) + list[i] = list[k]; + list.pop(); + } + function arrayClone(arr, i) { + var copy2 = new Array(i); + while (i--) + copy2[i] = arr[i]; + return copy2; + } + function unwrapListeners(arr) { + var ret = new Array(arr.length); + for (var i = 0; i < ret.length; ++i) { + ret[i] = arr[i].listener || arr[i]; + } + return ret; + } + function defaultSetTimout() { + throw new Error("setTimeout has not been defined"); + } + function defaultClearTimeout() { + throw new Error("clearTimeout has not been defined"); + } + var cachedSetTimeout = defaultSetTimout; + var cachedClearTimeout = defaultClearTimeout; + if (typeof global$1.setTimeout === "function") { + cachedSetTimeout = setTimeout; + } + if (typeof global$1.clearTimeout === "function") { + cachedClearTimeout = clearTimeout; + } + function runTimeout(fun) { + if (cachedSetTimeout === setTimeout) { + return setTimeout(fun, 0); + } + if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) { + cachedSetTimeout = setTimeout; + return setTimeout(fun, 0); + } + try { + return cachedSetTimeout(fun, 0); + } catch (e) { + try { + return cachedSetTimeout.call(null, fun, 0); + } catch (e2) { + return cachedSetTimeout.call(this, fun, 0); + } + } + } + function runClearTimeout(marker) { + if (cachedClearTimeout === clearTimeout) { + return clearTimeout(marker); + } + if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) { + cachedClearTimeout = clearTimeout; + return clearTimeout(marker); + } + try { + return cachedClearTimeout(marker); + } catch (e) { + try { + return cachedClearTimeout.call(null, marker); + } catch (e2) { + return cachedClearTimeout.call(this, marker); + } + } + } + var queue = []; + var draining = false; + var currentQueue; + var queueIndex = -1; + function cleanUpNextTick() { + if (!draining || !currentQueue) { + return; + } + draining = false; + if (currentQueue.length) { + queue = currentQueue.concat(queue); + } else { + queueIndex = -1; + } + if (queue.length) { + drainQueue(); + } + } + function drainQueue() { + if (draining) { + return; + } + var timeout = runTimeout(cleanUpNextTick); + draining = true; + var len = queue.length; + while (len) { + currentQueue = queue; + queue = []; + while (++queueIndex < len) { + if (currentQueue) { + currentQueue[queueIndex].run(); + } + } + queueIndex = -1; + len = queue.length; + } + currentQueue = null; + draining = false; + runClearTimeout(timeout); + } + function nextTick(fun) { + var args = new Array(arguments.length - 1); + if (arguments.length > 1) { + for (var i = 1; i < arguments.length; i++) { + args[i - 1] = arguments[i]; + } + } + queue.push(new Item(fun, args)); + if (queue.length === 1 && !draining) { + runTimeout(drainQueue); + } + } + function Item(fun, array) { + this.fun = fun; + this.array = array; + } + Item.prototype.run = function() { + this.fun.apply(null, this.array); + }; + var title = "browser"; + var platform = "browser"; + var browser = true; + var env = {}; + var argv = []; + var version = ""; + var versions = {}; + var release = {}; + var config = {}; + function noop() { + } + var on = noop; + var addListener2 = noop; + var once2 = noop; + var off = noop; + var removeListener2 = noop; + var removeAllListeners2 = noop; + var emit2 = noop; + function binding(name) { + throw new Error("process.binding is not supported"); + } + function cwd() { + return "/"; + } + function chdir(dir) { + throw new Error("process.chdir is not supported"); + } + function umask() { + return 0; + } + var performance = global$1.performance || {}; + var performanceNow = performance.now || performance.mozNow || performance.msNow || performance.oNow || performance.webkitNow || function() { + return (/* @__PURE__ */ new Date()).getTime(); + }; + function hrtime(previousTimestamp) { + var clocktime = performanceNow.call(performance) * 1e-3; + var seconds = Math.floor(clocktime); + var nanoseconds = Math.floor(clocktime % 1 * 1e9); + if (previousTimestamp) { + seconds = seconds - previousTimestamp[0]; + nanoseconds = nanoseconds - previousTimestamp[1]; + if (nanoseconds < 0) { + seconds--; + nanoseconds += 1e9; + } + } + return [seconds, nanoseconds]; + } + var startTime = /* @__PURE__ */ new Date(); + function uptime() { + var currentTime = /* @__PURE__ */ new Date(); + var dif = currentTime - startTime; + return dif / 1e3; + } + var process = { + nextTick, + title, + browser, + env, + argv, + version, + versions, + on, + addListener: addListener2, + once: once2, + off, + removeListener: removeListener2, + removeAllListeners: removeAllListeners2, + emit: emit2, + binding, + cwd, + chdir, + umask, + hrtime, + platform, + release, + config, + uptime + }; + var inherits; + if (typeof Object.create === "function") { + inherits = function inherits2(ctor, superCtor) { + ctor.super_ = superCtor; + ctor.prototype = Object.create(superCtor.prototype, { + constructor: { + value: ctor, + enumerable: false, + writable: true, + configurable: true + } + }); + }; + } else { + inherits = function inherits2(ctor, superCtor) { + ctor.super_ = superCtor; + var TempCtor = function() { + }; + TempCtor.prototype = superCtor.prototype; + ctor.prototype = new TempCtor(); + ctor.prototype.constructor = ctor; + }; + } + var inherits$1 = inherits; + var formatRegExp = /%[sdj%]/g; + function format(f) { + if (!isString(f)) { + var objects = []; + for (var i = 0; i < arguments.length; i++) { + objects.push(inspect2(arguments[i])); + } + return objects.join(" "); + } + var i = 1; + var args = arguments; + var len = args.length; + var str = String(f).replace(formatRegExp, function(x2) { + if (x2 === "%%") return "%"; + if (i >= len) return x2; + switch (x2) { + case "%s": + return String(args[i++]); + case "%d": + return Number(args[i++]); + case "%j": + try { + return JSON.stringify(args[i++]); + } catch (_) { + return "[Circular]"; + } + default: + return x2; + } + }); + for (var x = args[i]; i < len; x = args[++i]) { + if (isNull(x) || !isObject(x)) { + str += " " + x; + } else { + str += " " + inspect2(x); + } + } + return str; + } + function deprecate(fn, msg) { + if (isUndefined(global$1.process)) { + return function() { + return deprecate(fn, msg).apply(this, arguments); + }; + } + if (process.noDeprecation === true) { + return fn; + } + var warned = false; + function deprecated() { + if (!warned) { + if (process.throwDeprecation) { + throw new Error(msg); + } else if (process.traceDeprecation) { + console.trace(msg); + } else { + console.error(msg); + } + warned = true; + } + return fn.apply(this, arguments); + } + return deprecated; + } + var debugs = {}; + var debugEnviron; + function debuglog(set) { + if (isUndefined(debugEnviron)) + debugEnviron = process.env.NODE_DEBUG || ""; + set = set.toUpperCase(); + if (!debugs[set]) { + if (new RegExp("\\b" + set + "\\b", "i").test(debugEnviron)) { + var pid = 0; + debugs[set] = function() { + var msg = format.apply(null, arguments); + console.error("%s %d: %s", set, pid, msg); + }; + } else { + debugs[set] = function() { + }; + } + } + return debugs[set]; + } + function inspect2(obj, opts) { + var ctx = { + seen: [], + stylize: stylizeNoColor + }; + if (arguments.length >= 3) ctx.depth = arguments[2]; + if (arguments.length >= 4) ctx.colors = arguments[3]; + if (isBoolean(opts)) { + ctx.showHidden = opts; + } else if (opts) { + _extend(ctx, opts); + } + if (isUndefined(ctx.showHidden)) ctx.showHidden = false; + if (isUndefined(ctx.depth)) ctx.depth = 2; + if (isUndefined(ctx.colors)) ctx.colors = false; + if (isUndefined(ctx.customInspect)) ctx.customInspect = true; + if (ctx.colors) ctx.stylize = stylizeWithColor; + return formatValue(ctx, obj, ctx.depth); + } + inspect2.colors = { + "bold": [1, 22], + "italic": [3, 23], + "underline": [4, 24], + "inverse": [7, 27], + "white": [37, 39], + "grey": [90, 39], + "black": [30, 39], + "blue": [34, 39], + "cyan": [36, 39], + "green": [32, 39], + "magenta": [35, 39], + "red": [31, 39], + "yellow": [33, 39] + }; + inspect2.styles = { + "special": "cyan", + "number": "yellow", + "boolean": "yellow", + "undefined": "grey", + "null": "bold", + "string": "green", + "date": "magenta", + // "name": intentionally not styling + "regexp": "red" + }; + function stylizeWithColor(str, styleType) { + var style = inspect2.styles[styleType]; + if (style) { + return "\x1B[" + inspect2.colors[style][0] + "m" + str + "\x1B[" + inspect2.colors[style][1] + "m"; + } else { + return str; + } + } + function stylizeNoColor(str, styleType) { + return str; + } + function arrayToHash(array) { + var hash = {}; + array.forEach(function(val, idx) { + hash[val] = true; + }); + return hash; + } + function formatValue(ctx, value, recurseTimes) { + if (ctx.customInspect && value && isFunction(value.inspect) && // Filter out the util module, it's inspect function is special + value.inspect !== inspect2 && // Also filter out any prototype objects using the circular check. + !(value.constructor && value.constructor.prototype === value)) { + var ret = value.inspect(recurseTimes, ctx); + if (!isString(ret)) { + ret = formatValue(ctx, ret, recurseTimes); + } + return ret; + } + var primitive = formatPrimitive(ctx, value); + if (primitive) { + return primitive; + } + var keys2 = Object.keys(value); + var visibleKeys = arrayToHash(keys2); + if (ctx.showHidden) { + keys2 = Object.getOwnPropertyNames(value); + } + if (isError(value) && (keys2.indexOf("message") >= 0 || keys2.indexOf("description") >= 0)) { + return formatError(value); + } + if (keys2.length === 0) { + if (isFunction(value)) { + var name = value.name ? ": " + value.name : ""; + return ctx.stylize("[Function" + name + "]", "special"); + } + if (isRegExp(value)) { + return ctx.stylize(RegExp.prototype.toString.call(value), "regexp"); + } + if (isDate(value)) { + return ctx.stylize(Date.prototype.toString.call(value), "date"); + } + if (isError(value)) { + return formatError(value); + } + } + var base = "", array = false, braces = ["{", "}"]; + if (isArray(value)) { + array = true; + braces = ["[", "]"]; + } + if (isFunction(value)) { + var n = value.name ? ": " + value.name : ""; + base = " [Function" + n + "]"; + } + if (isRegExp(value)) { + base = " " + RegExp.prototype.toString.call(value); + } + if (isDate(value)) { + base = " " + Date.prototype.toUTCString.call(value); + } + if (isError(value)) { + base = " " + formatError(value); + } + if (keys2.length === 0 && (!array || value.length == 0)) { + return braces[0] + base + braces[1]; + } + if (recurseTimes < 0) { + if (isRegExp(value)) { + return ctx.stylize(RegExp.prototype.toString.call(value), "regexp"); + } else { + return ctx.stylize("[Object]", "special"); + } + } + ctx.seen.push(value); + var output; + if (array) { + output = formatArray(ctx, value, recurseTimes, visibleKeys, keys2); + } else { + output = keys2.map(function(key) { + return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array); + }); + } + ctx.seen.pop(); + return reduceToSingleString(output, base, braces); + } + function formatPrimitive(ctx, value) { + if (isUndefined(value)) + return ctx.stylize("undefined", "undefined"); + if (isString(value)) { + var simple = "'" + JSON.stringify(value).replace(/^"|"$/g, "").replace(/'/g, "\\'").replace(/\\"/g, '"') + "'"; + return ctx.stylize(simple, "string"); + } + if (isNumber(value)) + return ctx.stylize("" + value, "number"); + if (isBoolean(value)) + return ctx.stylize("" + value, "boolean"); + if (isNull(value)) + return ctx.stylize("null", "null"); + } + function formatError(value) { + return "[" + Error.prototype.toString.call(value) + "]"; + } + function formatArray(ctx, value, recurseTimes, visibleKeys, keys2) { + var output = []; + for (var i = 0, l = value.length; i < l; ++i) { + if (hasOwnProperty(value, String(i))) { + output.push(formatProperty( + ctx, + value, + recurseTimes, + visibleKeys, + String(i), + true + )); + } else { + output.push(""); + } + } + keys2.forEach(function(key) { + if (!key.match(/^\d+$/)) { + output.push(formatProperty( + ctx, + value, + recurseTimes, + visibleKeys, + key, + true + )); + } + }); + return output; + } + function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) { + var name, str, desc; + desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] }; + if (desc.get) { + if (desc.set) { + str = ctx.stylize("[Getter/Setter]", "special"); + } else { + str = ctx.stylize("[Getter]", "special"); + } + } else { + if (desc.set) { + str = ctx.stylize("[Setter]", "special"); + } + } + if (!hasOwnProperty(visibleKeys, key)) { + name = "[" + key + "]"; + } + if (!str) { + if (ctx.seen.indexOf(desc.value) < 0) { + if (isNull(recurseTimes)) { + str = formatValue(ctx, desc.value, null); + } else { + str = formatValue(ctx, desc.value, recurseTimes - 1); + } + if (str.indexOf("\n") > -1) { + if (array) { + str = str.split("\n").map(function(line) { + return " " + line; + }).join("\n").substr(2); + } else { + str = "\n" + str.split("\n").map(function(line) { + return " " + line; + }).join("\n"); + } + } + } else { + str = ctx.stylize("[Circular]", "special"); + } + } + if (isUndefined(name)) { + if (array && key.match(/^\d+$/)) { + return str; + } + name = JSON.stringify("" + key); + if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { + name = name.substr(1, name.length - 2); + name = ctx.stylize(name, "name"); + } else { + name = name.replace(/'/g, "\\'").replace(/\\"/g, '"').replace(/(^"|"$)/g, "'"); + name = ctx.stylize(name, "string"); + } + } + return name + ": " + str; + } + function reduceToSingleString(output, base, braces) { + var length = output.reduce(function(prev, cur) { + if (cur.indexOf("\n") >= 0) ; + return prev + cur.replace(/\u001b\[\d\d?m/g, "").length + 1; + }, 0); + if (length > 60) { + return braces[0] + (base === "" ? "" : base + "\n ") + " " + output.join(",\n ") + " " + braces[1]; + } + return braces[0] + base + " " + output.join(", ") + " " + braces[1]; + } + function isArray(ar) { + return Array.isArray(ar); + } + function isBoolean(arg) { + return typeof arg === "boolean"; + } + function isNull(arg) { + return arg === null; + } + function isNumber(arg) { + return typeof arg === "number"; + } + function isString(arg) { + return typeof arg === "string"; + } + function isUndefined(arg) { + return arg === void 0; + } + function isRegExp(re) { + return isObject(re) && objectToString(re) === "[object RegExp]"; + } + function isObject(arg) { + return typeof arg === "object" && arg !== null; + } + function isDate(d) { + return isObject(d) && objectToString(d) === "[object Date]"; + } + function isError(e) { + return isObject(e) && (objectToString(e) === "[object Error]" || e instanceof Error); + } + function isFunction(arg) { + return typeof arg === "function"; + } + function objectToString(o) { + return Object.prototype.toString.call(o); + } + function _extend(origin, add) { + if (!add || !isObject(add)) return origin; + var keys2 = Object.keys(add); + var i = keys2.length; + while (i--) { + origin[keys2[i]] = add[keys2[i]]; + } + return origin; + } + function hasOwnProperty(obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); + } + function BufferList() { + this.head = null; + this.tail = null; + this.length = 0; + } + BufferList.prototype.push = function(v) { + var entry = { data: v, next: null }; + if (this.length > 0) this.tail.next = entry; + else this.head = entry; + this.tail = entry; + ++this.length; + }; + BufferList.prototype.unshift = function(v) { + var entry = { data: v, next: this.head }; + if (this.length === 0) this.tail = entry; + this.head = entry; + ++this.length; + }; + BufferList.prototype.shift = function() { + if (this.length === 0) return; + var ret = this.head.data; + if (this.length === 1) this.head = this.tail = null; + else this.head = this.head.next; + --this.length; + return ret; + }; + BufferList.prototype.clear = function() { + this.head = this.tail = null; + this.length = 0; + }; + BufferList.prototype.join = function(s) { + if (this.length === 0) return ""; + var p = this.head; + var ret = "" + p.data; + while (p = p.next) { + ret += s + p.data; + } + return ret; + }; + BufferList.prototype.concat = function(n) { + if (this.length === 0) return Buffer.alloc(0); + if (this.length === 1) return this.head.data; + var ret = Buffer.allocUnsafe(n >>> 0); + var p = this.head; + var i = 0; + while (p) { + p.data.copy(ret, i); + i += p.data.length; + p = p.next; + } + return ret; + }; + var isBufferEncoding = Buffer.isEncoding || function(encoding) { + switch (encoding && encoding.toLowerCase()) { + case "hex": + case "utf8": + case "utf-8": + case "ascii": + case "binary": + case "base64": + case "ucs2": + case "ucs-2": + case "utf16le": + case "utf-16le": + case "raw": + return true; + default: + return false; + } + }; + function assertEncoding(encoding) { + if (encoding && !isBufferEncoding(encoding)) { + throw new Error("Unknown encoding: " + encoding); + } + } + function StringDecoder(encoding) { + this.encoding = (encoding || "utf8").toLowerCase().replace(/[-_]/, ""); + assertEncoding(encoding); + switch (this.encoding) { + case "utf8": + this.surrogateSize = 3; + break; + case "ucs2": + case "utf16le": + this.surrogateSize = 2; + this.detectIncompleteChar = utf16DetectIncompleteChar; + break; + case "base64": + this.surrogateSize = 3; + this.detectIncompleteChar = base64DetectIncompleteChar; + break; + default: + this.write = passThroughWrite; + return; + } + this.charBuffer = new Buffer(6); + this.charReceived = 0; + this.charLength = 0; + } + StringDecoder.prototype.write = function(buffer) { + var charStr = ""; + while (this.charLength) { + var available = buffer.length >= this.charLength - this.charReceived ? this.charLength - this.charReceived : buffer.length; + buffer.copy(this.charBuffer, this.charReceived, 0, available); + this.charReceived += available; + if (this.charReceived < this.charLength) { + return ""; + } + buffer = buffer.slice(available, buffer.length); + charStr = this.charBuffer.slice(0, this.charLength).toString(this.encoding); + var charCode = charStr.charCodeAt(charStr.length - 1); + if (charCode >= 55296 && charCode <= 56319) { + this.charLength += this.surrogateSize; + charStr = ""; + continue; + } + this.charReceived = this.charLength = 0; + if (buffer.length === 0) { + return charStr; + } + break; + } + this.detectIncompleteChar(buffer); + var end = buffer.length; + if (this.charLength) { + buffer.copy(this.charBuffer, 0, buffer.length - this.charReceived, end); + end -= this.charReceived; + } + charStr += buffer.toString(this.encoding, 0, end); + var end = charStr.length - 1; + var charCode = charStr.charCodeAt(end); + if (charCode >= 55296 && charCode <= 56319) { + var size = this.surrogateSize; + this.charLength += size; + this.charReceived += size; + this.charBuffer.copy(this.charBuffer, size, 0, size); + buffer.copy(this.charBuffer, 0, 0, size); + return charStr.substring(0, end); + } + return charStr; + }; + StringDecoder.prototype.detectIncompleteChar = function(buffer) { + var i = buffer.length >= 3 ? 3 : buffer.length; + for (; i > 0; i--) { + var c = buffer[buffer.length - i]; + if (i == 1 && c >> 5 == 6) { + this.charLength = 2; + break; + } + if (i <= 2 && c >> 4 == 14) { + this.charLength = 3; + break; + } + if (i <= 3 && c >> 3 == 30) { + this.charLength = 4; + break; + } + } + this.charReceived = i; + }; + StringDecoder.prototype.end = function(buffer) { + var res = ""; + if (buffer && buffer.length) + res = this.write(buffer); + if (this.charReceived) { + var cr2 = this.charReceived; + var buf = this.charBuffer; + var enc = this.encoding; + res += buf.slice(0, cr2).toString(enc); + } + return res; + }; + function passThroughWrite(buffer) { + return buffer.toString(this.encoding); + } + function utf16DetectIncompleteChar(buffer) { + this.charReceived = buffer.length % 2; + this.charLength = this.charReceived ? 2 : 0; + } + function base64DetectIncompleteChar(buffer) { + this.charReceived = buffer.length % 3; + this.charLength = this.charReceived ? 3 : 0; + } + Readable.ReadableState = ReadableState; + var debug = debuglog("stream"); + inherits$1(Readable, EventEmitter); + function prependListener2(emitter, event, fn) { + if (typeof emitter.prependListener === "function") { + return emitter.prependListener(event, fn); + } else { + if (!emitter._events || !emitter._events[event]) + emitter.on(event, fn); + else if (Array.isArray(emitter._events[event])) + emitter._events[event].unshift(fn); + else + emitter._events[event] = [fn, emitter._events[event]]; + } + } + function listenerCount(emitter, type) { + return emitter.listeners(type).length; + } + function ReadableState(options, stream) { + options = options || {}; + this.objectMode = !!options.objectMode; + if (stream instanceof Duplex) this.objectMode = this.objectMode || !!options.readableObjectMode; + var hwm = options.highWaterMark; + var defaultHwm = this.objectMode ? 16 : 16 * 1024; + this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm; + this.highWaterMark = ~~this.highWaterMark; + this.buffer = new BufferList(); + this.length = 0; + this.pipes = null; + this.pipesCount = 0; + this.flowing = null; + this.ended = false; + this.endEmitted = false; + this.reading = false; + this.sync = true; + this.needReadable = false; + this.emittedReadable = false; + this.readableListening = false; + this.resumeScheduled = false; + this.defaultEncoding = options.defaultEncoding || "utf8"; + this.ranOut = false; + this.awaitDrain = 0; + this.readingMore = false; + this.decoder = null; + this.encoding = null; + if (options.encoding) { + this.decoder = new StringDecoder(options.encoding); + this.encoding = options.encoding; + } + } + function Readable(options) { + if (!(this instanceof Readable)) return new Readable(options); + this._readableState = new ReadableState(options, this); + this.readable = true; + if (options && typeof options.read === "function") this._read = options.read; + EventEmitter.call(this); + } + Readable.prototype.push = function(chunk, encoding) { + var state = this._readableState; + if (!state.objectMode && typeof chunk === "string") { + encoding = encoding || state.defaultEncoding; + if (encoding !== state.encoding) { + chunk = Buffer.from(chunk, encoding); + encoding = ""; + } + } + return readableAddChunk(this, state, chunk, encoding, false); + }; + Readable.prototype.unshift = function(chunk) { + var state = this._readableState; + return readableAddChunk(this, state, chunk, "", true); + }; + Readable.prototype.isPaused = function() { + return this._readableState.flowing === false; + }; + function readableAddChunk(stream, state, chunk, encoding, addToFront) { + var er = chunkInvalid(state, chunk); + if (er) { + stream.emit("error", er); + } else if (chunk === null) { + state.reading = false; + onEofChunk(stream, state); + } else if (state.objectMode || chunk && chunk.length > 0) { + if (state.ended && !addToFront) { + var e = new Error("stream.push() after EOF"); + stream.emit("error", e); + } else if (state.endEmitted && addToFront) { + var _e = new Error("stream.unshift() after end event"); + stream.emit("error", _e); + } else { + var skipAdd; + if (state.decoder && !addToFront && !encoding) { + chunk = state.decoder.write(chunk); + skipAdd = !state.objectMode && chunk.length === 0; + } + if (!addToFront) state.reading = false; + if (!skipAdd) { + if (state.flowing && state.length === 0 && !state.sync) { + stream.emit("data", chunk); + stream.read(0); + } else { + state.length += state.objectMode ? 1 : chunk.length; + if (addToFront) state.buffer.unshift(chunk); + else state.buffer.push(chunk); + if (state.needReadable) emitReadable(stream); + } + } + maybeReadMore(stream, state); + } + } else if (!addToFront) { + state.reading = false; + } + return needMoreData(state); + } + function needMoreData(state) { + return !state.ended && (state.needReadable || state.length < state.highWaterMark || state.length === 0); + } + Readable.prototype.setEncoding = function(enc) { + this._readableState.decoder = new StringDecoder(enc); + this._readableState.encoding = enc; + return this; + }; + var MAX_HWM = 8388608; + function computeNewHighWaterMark(n) { + if (n >= MAX_HWM) { + n = MAX_HWM; + } else { + n--; + n |= n >>> 1; + n |= n >>> 2; + n |= n >>> 4; + n |= n >>> 8; + n |= n >>> 16; + n++; + } + return n; + } + function howMuchToRead(n, state) { + if (n <= 0 || state.length === 0 && state.ended) return 0; + if (state.objectMode) return 1; + if (n !== n) { + if (state.flowing && state.length) return state.buffer.head.data.length; + else return state.length; + } + if (n > state.highWaterMark) state.highWaterMark = computeNewHighWaterMark(n); + if (n <= state.length) return n; + if (!state.ended) { + state.needReadable = true; + return 0; + } + return state.length; + } + Readable.prototype.read = function(n) { + debug("read", n); + n = parseInt(n, 10); + var state = this._readableState; + var nOrig = n; + if (n !== 0) state.emittedReadable = false; + if (n === 0 && state.needReadable && (state.length >= state.highWaterMark || state.ended)) { + debug("read: emitReadable", state.length, state.ended); + if (state.length === 0 && state.ended) endReadable(this); + else emitReadable(this); + return null; + } + n = howMuchToRead(n, state); + if (n === 0 && state.ended) { + if (state.length === 0) endReadable(this); + return null; + } + var doRead = state.needReadable; + debug("need readable", doRead); + if (state.length === 0 || state.length - n < state.highWaterMark) { + doRead = true; + debug("length less than watermark", doRead); + } + if (state.ended || state.reading) { + doRead = false; + debug("reading or ended", doRead); + } else if (doRead) { + debug("do read"); + state.reading = true; + state.sync = true; + if (state.length === 0) state.needReadable = true; + this._read(state.highWaterMark); + state.sync = false; + if (!state.reading) n = howMuchToRead(nOrig, state); + } + var ret; + if (n > 0) ret = fromList(n, state); + else ret = null; + if (ret === null) { + state.needReadable = true; + n = 0; + } else { + state.length -= n; + } + if (state.length === 0) { + if (!state.ended) state.needReadable = true; + if (nOrig !== n && state.ended) endReadable(this); + } + if (ret !== null) this.emit("data", ret); + return ret; + }; + function chunkInvalid(state, chunk) { + var er = null; + if (!isBuffer(chunk) && typeof chunk !== "string" && chunk !== null && chunk !== void 0 && !state.objectMode) { + er = new TypeError("Invalid non-string/buffer chunk"); + } + return er; + } + function onEofChunk(stream, state) { + if (state.ended) return; + if (state.decoder) { + var chunk = state.decoder.end(); + if (chunk && chunk.length) { + state.buffer.push(chunk); + state.length += state.objectMode ? 1 : chunk.length; + } + } + state.ended = true; + emitReadable(stream); + } + function emitReadable(stream) { + var state = stream._readableState; + state.needReadable = false; + if (!state.emittedReadable) { + debug("emitReadable", state.flowing); + state.emittedReadable = true; + if (state.sync) nextTick(emitReadable_, stream); + else emitReadable_(stream); + } + } + function emitReadable_(stream) { + debug("emit readable"); + stream.emit("readable"); + flow(stream); + } + function maybeReadMore(stream, state) { + if (!state.readingMore) { + state.readingMore = true; + nextTick(maybeReadMore_, stream, state); + } + } + function maybeReadMore_(stream, state) { + var len = state.length; + while (!state.reading && !state.flowing && !state.ended && state.length < state.highWaterMark) { + debug("maybeReadMore read 0"); + stream.read(0); + if (len === state.length) + break; + else len = state.length; + } + state.readingMore = false; + } + Readable.prototype._read = function(n) { + this.emit("error", new Error("not implemented")); + }; + Readable.prototype.pipe = function(dest, pipeOpts) { + var src = this; + var state = this._readableState; + switch (state.pipesCount) { + case 0: + state.pipes = dest; + break; + case 1: + state.pipes = [state.pipes, dest]; + break; + default: + state.pipes.push(dest); + break; + } + state.pipesCount += 1; + debug("pipe count=%d opts=%j", state.pipesCount, pipeOpts); + var doEnd = !pipeOpts || pipeOpts.end !== false; + var endFn = doEnd ? onend2 : cleanup; + if (state.endEmitted) nextTick(endFn); + else src.once("end", endFn); + dest.on("unpipe", onunpipe); + function onunpipe(readable) { + debug("onunpipe"); + if (readable === src) { + cleanup(); + } + } + function onend2() { + debug("onend"); + dest.end(); + } + var ondrain = pipeOnDrain(src); + dest.on("drain", ondrain); + var cleanedUp = false; + function cleanup() { + debug("cleanup"); + dest.removeListener("close", onclose); + dest.removeListener("finish", onfinish); + dest.removeListener("drain", ondrain); + dest.removeListener("error", onerror); + dest.removeListener("unpipe", onunpipe); + src.removeListener("end", onend2); + src.removeListener("end", cleanup); + src.removeListener("data", ondata); + cleanedUp = true; + if (state.awaitDrain && (!dest._writableState || dest._writableState.needDrain)) ondrain(); + } + var increasedAwaitDrain = false; + src.on("data", ondata); + function ondata(chunk) { + debug("ondata"); + increasedAwaitDrain = false; + var ret = dest.write(chunk); + if (false === ret && !increasedAwaitDrain) { + if ((state.pipesCount === 1 && state.pipes === dest || state.pipesCount > 1 && indexOf2(state.pipes, dest) !== -1) && !cleanedUp) { + debug("false write response, pause", src._readableState.awaitDrain); + src._readableState.awaitDrain++; + increasedAwaitDrain = true; + } + src.pause(); + } + } + function onerror(er) { + debug("onerror", er); + unpipe(); + dest.removeListener("error", onerror); + if (listenerCount(dest, "error") === 0) dest.emit("error", er); + } + prependListener2(dest, "error", onerror); + function onclose() { + dest.removeListener("finish", onfinish); + unpipe(); + } + dest.once("close", onclose); + function onfinish() { + debug("onfinish"); + dest.removeListener("close", onclose); + unpipe(); + } + dest.once("finish", onfinish); + function unpipe() { + debug("unpipe"); + src.unpipe(dest); + } + dest.emit("pipe", src); + if (!state.flowing) { + debug("pipe resume"); + src.resume(); + } + return dest; + }; + function pipeOnDrain(src) { + return function() { + var state = src._readableState; + debug("pipeOnDrain", state.awaitDrain); + if (state.awaitDrain) state.awaitDrain--; + if (state.awaitDrain === 0 && src.listeners("data").length) { + state.flowing = true; + flow(src); + } + }; + } + Readable.prototype.unpipe = function(dest) { + var state = this._readableState; + if (state.pipesCount === 0) return this; + if (state.pipesCount === 1) { + if (dest && dest !== state.pipes) return this; + if (!dest) dest = state.pipes; + state.pipes = null; + state.pipesCount = 0; + state.flowing = false; + if (dest) dest.emit("unpipe", this); + return this; + } + if (!dest) { + var dests = state.pipes; + var len = state.pipesCount; + state.pipes = null; + state.pipesCount = 0; + state.flowing = false; + for (var _i = 0; _i < len; _i++) { + dests[_i].emit("unpipe", this); + } + return this; + } + var i = indexOf2(state.pipes, dest); + if (i === -1) return this; + state.pipes.splice(i, 1); + state.pipesCount -= 1; + if (state.pipesCount === 1) state.pipes = state.pipes[0]; + dest.emit("unpipe", this); + return this; + }; + Readable.prototype.on = function(ev, fn) { + var res = EventEmitter.prototype.on.call(this, ev, fn); + if (ev === "data") { + if (this._readableState.flowing !== false) this.resume(); + } else if (ev === "readable") { + var state = this._readableState; + if (!state.endEmitted && !state.readableListening) { + state.readableListening = state.needReadable = true; + state.emittedReadable = false; + if (!state.reading) { + nextTick(nReadingNextTick, this); + } else if (state.length) { + emitReadable(this); + } + } + } + return res; + }; + Readable.prototype.addListener = Readable.prototype.on; + function nReadingNextTick(self2) { + debug("readable nexttick read 0"); + self2.read(0); + } + Readable.prototype.resume = function() { + var state = this._readableState; + if (!state.flowing) { + debug("resume"); + state.flowing = true; + resume(this, state); + } + return this; + }; + function resume(stream, state) { + if (!state.resumeScheduled) { + state.resumeScheduled = true; + nextTick(resume_, stream, state); + } + } + function resume_(stream, state) { + if (!state.reading) { + debug("resume read 0"); + stream.read(0); + } + state.resumeScheduled = false; + state.awaitDrain = 0; + stream.emit("resume"); + flow(stream); + if (state.flowing && !state.reading) stream.read(0); + } + Readable.prototype.pause = function() { + debug("call pause flowing=%j", this._readableState.flowing); + if (false !== this._readableState.flowing) { + debug("pause"); + this._readableState.flowing = false; + this.emit("pause"); + } + return this; + }; + function flow(stream) { + var state = stream._readableState; + debug("flow", state.flowing); + while (state.flowing && stream.read() !== null) { + } + } + Readable.prototype.wrap = function(stream) { + var state = this._readableState; + var paused = false; + var self2 = this; + stream.on("end", function() { + debug("wrapped end"); + if (state.decoder && !state.ended) { + var chunk = state.decoder.end(); + if (chunk && chunk.length) self2.push(chunk); + } + self2.push(null); + }); + stream.on("data", function(chunk) { + debug("wrapped data"); + if (state.decoder) chunk = state.decoder.write(chunk); + if (state.objectMode && (chunk === null || chunk === void 0)) return; + else if (!state.objectMode && (!chunk || !chunk.length)) return; + var ret = self2.push(chunk); + if (!ret) { + paused = true; + stream.pause(); + } + }); + for (var i in stream) { + if (this[i] === void 0 && typeof stream[i] === "function") { + this[i] = /* @__PURE__ */ (function(method) { + return function() { + return stream[method].apply(stream, arguments); + }; + })(i); + } + } + var events = ["error", "close", "destroy", "pause", "resume"]; + forEach(events, function(ev) { + stream.on(ev, self2.emit.bind(self2, ev)); + }); + self2._read = function(n) { + debug("wrapped _read", n); + if (paused) { + paused = false; + stream.resume(); + } + }; + return self2; + }; + Readable._fromList = fromList; + function fromList(n, state) { + if (state.length === 0) return null; + var ret; + if (state.objectMode) ret = state.buffer.shift(); + else if (!n || n >= state.length) { + if (state.decoder) ret = state.buffer.join(""); + else if (state.buffer.length === 1) ret = state.buffer.head.data; + else ret = state.buffer.concat(state.length); + state.buffer.clear(); + } else { + ret = fromListPartial(n, state.buffer, state.decoder); + } + return ret; + } + function fromListPartial(n, list, hasStrings) { + var ret; + if (n < list.head.data.length) { + ret = list.head.data.slice(0, n); + list.head.data = list.head.data.slice(n); + } else if (n === list.head.data.length) { + ret = list.shift(); + } else { + ret = hasStrings ? copyFromBufferString(n, list) : copyFromBuffer(n, list); + } + return ret; + } + function copyFromBufferString(n, list) { + var p = list.head; + var c = 1; + var ret = p.data; + n -= ret.length; + while (p = p.next) { + var str = p.data; + var nb = n > str.length ? str.length : n; + if (nb === str.length) ret += str; + else ret += str.slice(0, n); + n -= nb; + if (n === 0) { + if (nb === str.length) { + ++c; + if (p.next) list.head = p.next; + else list.head = list.tail = null; + } else { + list.head = p; + p.data = str.slice(nb); + } + break; + } + ++c; + } + list.length -= c; + return ret; + } + function copyFromBuffer(n, list) { + var ret = Buffer.allocUnsafe(n); + var p = list.head; + var c = 1; + p.data.copy(ret); + n -= p.data.length; + while (p = p.next) { + var buf = p.data; + var nb = n > buf.length ? buf.length : n; + buf.copy(ret, ret.length - n, 0, nb); + n -= nb; + if (n === 0) { + if (nb === buf.length) { + ++c; + if (p.next) list.head = p.next; + else list.head = list.tail = null; + } else { + list.head = p; + p.data = buf.slice(nb); + } + break; + } + ++c; + } + list.length -= c; + return ret; + } + function endReadable(stream) { + var state = stream._readableState; + if (state.length > 0) throw new Error('"endReadable()" called on non-empty stream'); + if (!state.endEmitted) { + state.ended = true; + nextTick(endReadableNT, state, stream); + } + } + function endReadableNT(state, stream) { + if (!state.endEmitted && state.length === 0) { + state.endEmitted = true; + stream.readable = false; + stream.emit("end"); + } + } + function forEach(xs, f) { + for (var i = 0, l = xs.length; i < l; i++) { + f(xs[i], i); + } + } + function indexOf2(xs, x) { + for (var i = 0, l = xs.length; i < l; i++) { + if (xs[i] === x) return i; + } + return -1; + } + Writable.WritableState = WritableState; + inherits$1(Writable, EventEmitter); + function nop() { + } + function WriteReq(chunk, encoding, cb) { + this.chunk = chunk; + this.encoding = encoding; + this.callback = cb; + this.next = null; + } + function WritableState(options, stream) { + Object.defineProperty(this, "buffer", { + get: deprecate(function() { + return this.getBuffer(); + }, "_writableState.buffer is deprecated. Use _writableState.getBuffer instead.") + }); + options = options || {}; + this.objectMode = !!options.objectMode; + if (stream instanceof Duplex) this.objectMode = this.objectMode || !!options.writableObjectMode; + var hwm = options.highWaterMark; + var defaultHwm = this.objectMode ? 16 : 16 * 1024; + this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm; + this.highWaterMark = ~~this.highWaterMark; + this.needDrain = false; + this.ending = false; + this.ended = false; + this.finished = false; + var noDecode = options.decodeStrings === false; + this.decodeStrings = !noDecode; + this.defaultEncoding = options.defaultEncoding || "utf8"; + this.length = 0; + this.writing = false; + this.corked = 0; + this.sync = true; + this.bufferProcessing = false; + this.onwrite = function(er) { + onwrite(stream, er); + }; + this.writecb = null; + this.writelen = 0; + this.bufferedRequest = null; + this.lastBufferedRequest = null; + this.pendingcb = 0; + this.prefinished = false; + this.errorEmitted = false; + this.bufferedRequestCount = 0; + this.corkedRequestsFree = new CorkedRequest(this); + } + WritableState.prototype.getBuffer = function writableStateGetBuffer() { + var current = this.bufferedRequest; + var out = []; + while (current) { + out.push(current); + current = current.next; + } + return out; + }; + function Writable(options) { + if (!(this instanceof Writable) && !(this instanceof Duplex)) return new Writable(options); + this._writableState = new WritableState(options, this); + this.writable = true; + if (options) { + if (typeof options.write === "function") this._write = options.write; + if (typeof options.writev === "function") this._writev = options.writev; + } + EventEmitter.call(this); + } + Writable.prototype.pipe = function() { + this.emit("error", new Error("Cannot pipe, not readable")); + }; + function writeAfterEnd(stream, cb) { + var er = new Error("write after end"); + stream.emit("error", er); + nextTick(cb, er); + } + function validChunk(stream, state, chunk, cb) { + var valid = true; + var er = false; + if (chunk === null) { + er = new TypeError("May not write null values to stream"); + } else if (!Buffer.isBuffer(chunk) && typeof chunk !== "string" && chunk !== void 0 && !state.objectMode) { + er = new TypeError("Invalid non-string/buffer chunk"); + } + if (er) { + stream.emit("error", er); + nextTick(cb, er); + valid = false; + } + return valid; + } + Writable.prototype.write = function(chunk, encoding, cb) { + var state = this._writableState; + var ret = false; + if (typeof encoding === "function") { + cb = encoding; + encoding = null; + } + if (Buffer.isBuffer(chunk)) encoding = "buffer"; + else if (!encoding) encoding = state.defaultEncoding; + if (typeof cb !== "function") cb = nop; + if (state.ended) writeAfterEnd(this, cb); + else if (validChunk(this, state, chunk, cb)) { + state.pendingcb++; + ret = writeOrBuffer(this, state, chunk, encoding, cb); + } + return ret; + }; + Writable.prototype.cork = function() { + var state = this._writableState; + state.corked++; + }; + Writable.prototype.uncork = function() { + var state = this._writableState; + if (state.corked) { + state.corked--; + if (!state.writing && !state.corked && !state.finished && !state.bufferProcessing && state.bufferedRequest) clearBuffer(this, state); + } + }; + Writable.prototype.setDefaultEncoding = function setDefaultEncoding(encoding) { + if (typeof encoding === "string") encoding = encoding.toLowerCase(); + if (!(["hex", "utf8", "utf-8", "ascii", "binary", "base64", "ucs2", "ucs-2", "utf16le", "utf-16le", "raw"].indexOf((encoding + "").toLowerCase()) > -1)) throw new TypeError("Unknown encoding: " + encoding); + this._writableState.defaultEncoding = encoding; + return this; + }; + function decodeChunk(state, chunk, encoding) { + if (!state.objectMode && state.decodeStrings !== false && typeof chunk === "string") { + chunk = Buffer.from(chunk, encoding); + } + return chunk; + } + function writeOrBuffer(stream, state, chunk, encoding, cb) { + chunk = decodeChunk(state, chunk, encoding); + if (Buffer.isBuffer(chunk)) encoding = "buffer"; + var len = state.objectMode ? 1 : chunk.length; + state.length += len; + var ret = state.length < state.highWaterMark; + if (!ret) state.needDrain = true; + if (state.writing || state.corked) { + var last = state.lastBufferedRequest; + state.lastBufferedRequest = new WriteReq(chunk, encoding, cb); + if (last) { + last.next = state.lastBufferedRequest; + } else { + state.bufferedRequest = state.lastBufferedRequest; + } + state.bufferedRequestCount += 1; + } else { + doWrite(stream, state, false, len, chunk, encoding, cb); + } + return ret; + } + function doWrite(stream, state, writev, len, chunk, encoding, cb) { + state.writelen = len; + state.writecb = cb; + state.writing = true; + state.sync = true; + if (writev) stream._writev(chunk, state.onwrite); + else stream._write(chunk, encoding, state.onwrite); + state.sync = false; + } + function onwriteError(stream, state, sync, er, cb) { + --state.pendingcb; + if (sync) nextTick(cb, er); + else cb(er); + stream._writableState.errorEmitted = true; + stream.emit("error", er); + } + function onwriteStateUpdate(state) { + state.writing = false; + state.writecb = null; + state.length -= state.writelen; + state.writelen = 0; + } + function onwrite(stream, er) { + var state = stream._writableState; + var sync = state.sync; + var cb = state.writecb; + onwriteStateUpdate(state); + if (er) onwriteError(stream, state, sync, er, cb); + else { + var finished = needFinish(state); + if (!finished && !state.corked && !state.bufferProcessing && state.bufferedRequest) { + clearBuffer(stream, state); + } + if (sync) { + nextTick(afterWrite, stream, state, finished, cb); + } else { + afterWrite(stream, state, finished, cb); + } + } + } + function afterWrite(stream, state, finished, cb) { + if (!finished) onwriteDrain(stream, state); + state.pendingcb--; + cb(); + finishMaybe(stream, state); + } + function onwriteDrain(stream, state) { + if (state.length === 0 && state.needDrain) { + state.needDrain = false; + stream.emit("drain"); + } + } + function clearBuffer(stream, state) { + state.bufferProcessing = true; + var entry = state.bufferedRequest; + if (stream._writev && entry && entry.next) { + var l = state.bufferedRequestCount; + var buffer = new Array(l); + var holder = state.corkedRequestsFree; + holder.entry = entry; + var count = 0; + while (entry) { + buffer[count] = entry; + entry = entry.next; + count += 1; + } + doWrite(stream, state, true, state.length, buffer, "", holder.finish); + state.pendingcb++; + state.lastBufferedRequest = null; + if (holder.next) { + state.corkedRequestsFree = holder.next; + holder.next = null; + } else { + state.corkedRequestsFree = new CorkedRequest(state); + } + } else { + while (entry) { + var chunk = entry.chunk; + var encoding = entry.encoding; + var cb = entry.callback; + var len = state.objectMode ? 1 : chunk.length; + doWrite(stream, state, false, len, chunk, encoding, cb); + entry = entry.next; + if (state.writing) { + break; + } + } + if (entry === null) state.lastBufferedRequest = null; + } + state.bufferedRequestCount = 0; + state.bufferedRequest = entry; + state.bufferProcessing = false; + } + Writable.prototype._write = function(chunk, encoding, cb) { + cb(new Error("not implemented")); + }; + Writable.prototype._writev = null; + Writable.prototype.end = function(chunk, encoding, cb) { + var state = this._writableState; + if (typeof chunk === "function") { + cb = chunk; + chunk = null; + encoding = null; + } else if (typeof encoding === "function") { + cb = encoding; + encoding = null; + } + if (chunk !== null && chunk !== void 0) this.write(chunk, encoding); + if (state.corked) { + state.corked = 1; + this.uncork(); + } + if (!state.ending && !state.finished) endWritable(this, state, cb); + }; + function needFinish(state) { + return state.ending && state.length === 0 && state.bufferedRequest === null && !state.finished && !state.writing; + } + function prefinish(stream, state) { + if (!state.prefinished) { + state.prefinished = true; + stream.emit("prefinish"); + } + } + function finishMaybe(stream, state) { + var need = needFinish(state); + if (need) { + if (state.pendingcb === 0) { + prefinish(stream, state); + state.finished = true; + stream.emit("finish"); + } else { + prefinish(stream, state); + } + } + return need; + } + function endWritable(stream, state, cb) { + state.ending = true; + finishMaybe(stream, state); + if (cb) { + if (state.finished) nextTick(cb); + else stream.once("finish", cb); + } + state.ended = true; + stream.writable = false; + } + function CorkedRequest(state) { + var _this = this; + this.next = null; + this.entry = null; + this.finish = function(err) { + var entry = _this.entry; + _this.entry = null; + while (entry) { + var cb = entry.callback; + state.pendingcb--; + cb(err); + entry = entry.next; + } + if (state.corkedRequestsFree) { + state.corkedRequestsFree.next = _this; + } else { + state.corkedRequestsFree = _this; + } + }; + } + inherits$1(Duplex, Readable); + var keys = Object.keys(Writable.prototype); + for (v = 0; v < keys.length; v++) { + method = keys[v]; + if (!Duplex.prototype[method]) Duplex.prototype[method] = Writable.prototype[method]; + } + var method; + var v; + function Duplex(options) { + if (!(this instanceof Duplex)) return new Duplex(options); + Readable.call(this, options); + Writable.call(this, options); + if (options && options.readable === false) this.readable = false; + if (options && options.writable === false) this.writable = false; + this.allowHalfOpen = true; + if (options && options.allowHalfOpen === false) this.allowHalfOpen = false; + this.once("end", onend); + } + function onend() { + if (this.allowHalfOpen || this._writableState.ended) return; + nextTick(onEndNT, this); + } + function onEndNT(self2) { + self2.end(); + } + inherits$1(Transform, Duplex); + function TransformState(stream) { + this.afterTransform = function(er, data) { + return afterTransform(stream, er, data); + }; + this.needTransform = false; + this.transforming = false; + this.writecb = null; + this.writechunk = null; + this.writeencoding = null; + } + function afterTransform(stream, er, data) { + var ts = stream._transformState; + ts.transforming = false; + var cb = ts.writecb; + if (!cb) return stream.emit("error", new Error("no writecb in Transform class")); + ts.writechunk = null; + ts.writecb = null; + if (data !== null && data !== void 0) stream.push(data); + cb(er); + var rs = stream._readableState; + rs.reading = false; + if (rs.needReadable || rs.length < rs.highWaterMark) { + stream._read(rs.highWaterMark); + } + } + function Transform(options) { + if (!(this instanceof Transform)) return new Transform(options); + Duplex.call(this, options); + this._transformState = new TransformState(this); + var stream = this; + this._readableState.needReadable = true; + this._readableState.sync = false; + if (options) { + if (typeof options.transform === "function") this._transform = options.transform; + if (typeof options.flush === "function") this._flush = options.flush; + } + this.once("prefinish", function() { + if (typeof this._flush === "function") this._flush(function(er) { + done(stream, er); + }); + else done(stream); + }); + } + Transform.prototype.push = function(chunk, encoding) { + this._transformState.needTransform = false; + return Duplex.prototype.push.call(this, chunk, encoding); + }; + Transform.prototype._transform = function(chunk, encoding, cb) { + throw new Error("Not implemented"); + }; + Transform.prototype._write = function(chunk, encoding, cb) { + var ts = this._transformState; + ts.writecb = cb; + ts.writechunk = chunk; + ts.writeencoding = encoding; + if (!ts.transforming) { + var rs = this._readableState; + if (ts.needTransform || rs.needReadable || rs.length < rs.highWaterMark) this._read(rs.highWaterMark); + } + }; + Transform.prototype._read = function(n) { + var ts = this._transformState; + if (ts.writechunk !== null && ts.writecb && !ts.transforming) { + ts.transforming = true; + this._transform(ts.writechunk, ts.writeencoding, ts.afterTransform); + } else { + ts.needTransform = true; + } + }; + function done(stream, er) { + if (er) return stream.emit("error", er); + var ws = stream._writableState; + var ts = stream._transformState; + if (ws.length) throw new Error("Calling transform done when ws.length != 0"); + if (ts.transforming) throw new Error("Calling transform done when still transforming"); + return stream.push(null); + } + inherits$1(PassThrough, Transform); + function PassThrough(options) { + if (!(this instanceof PassThrough)) return new PassThrough(options); + Transform.call(this, options); + } + PassThrough.prototype._transform = function(chunk, encoding, cb) { + cb(null, chunk); + }; + inherits$1(Stream, EventEmitter); + Stream.Readable = Readable; + Stream.Writable = Writable; + Stream.Duplex = Duplex; + Stream.Transform = Transform; + Stream.PassThrough = PassThrough; + Stream.Stream = Stream; + function Stream() { + EventEmitter.call(this); + } + Stream.prototype.pipe = function(dest, options) { + var source = this; + function ondata(chunk) { + if (dest.writable) { + if (false === dest.write(chunk) && source.pause) { + source.pause(); + } + } + } + source.on("data", ondata); + function ondrain() { + if (source.readable && source.resume) { + source.resume(); + } + } + dest.on("drain", ondrain); + if (!dest._isStdio && (!options || options.end !== false)) { + source.on("end", onend2); + source.on("close", onclose); + } + var didOnEnd = false; + function onend2() { + if (didOnEnd) return; + didOnEnd = true; + dest.end(); + } + function onclose() { + if (didOnEnd) return; + didOnEnd = true; + if (typeof dest.destroy === "function") dest.destroy(); + } + function onerror(er) { + cleanup(); + if (EventEmitter.listenerCount(this, "error") === 0) { + throw er; + } + } + source.on("error", onerror); + dest.on("error", onerror); + function cleanup() { + source.removeListener("data", ondata); + dest.removeListener("drain", ondrain); + source.removeListener("end", onend2); + source.removeListener("close", onclose); + source.removeListener("error", onerror); + dest.removeListener("error", onerror); + source.removeListener("end", cleanup); + source.removeListener("close", cleanup); + dest.removeListener("close", cleanup); + } + source.on("end", cleanup); + source.on("close", cleanup); + dest.on("close", cleanup); + dest.emit("pipe", source); + return dest; + }; + var is_object = function(obj) { + return typeof obj === "object" && obj !== null && !Array.isArray(obj); + }; + var CsvError = class _CsvError extends Error { + constructor(code, message, options, ...contexts) { + if (Array.isArray(message)) message = message.join(" ").trim(); + super(message); + if (Error.captureStackTrace !== void 0) { + Error.captureStackTrace(this, _CsvError); + } + this.code = code; + for (const context of contexts) { + for (const key in context) { + const value = context[key]; + this[key] = isBuffer(value) ? value.toString(options.encoding) : value == null ? value : JSON.parse(JSON.stringify(value)); + } + } + } + }; + var normalize_columns_array = function(columns) { + const normalizedColumns = []; + for (let i = 0, l = columns.length; i < l; i++) { + const column = columns[i]; + if (column === void 0 || column === null || column === false) { + normalizedColumns[i] = { disabled: true }; + } else if (typeof column === "string") { + normalizedColumns[i] = { name: column }; + } else if (is_object(column)) { + if (typeof column.name !== "string") { + throw new CsvError("CSV_OPTION_COLUMNS_MISSING_NAME", [ + "Option columns missing name:", + `property "name" is required at position ${i}`, + "when column is an object literal" + ]); + } + normalizedColumns[i] = column; + } else { + throw new CsvError("CSV_INVALID_COLUMN_DEFINITION", [ + "Invalid column definition:", + "expect a string or a literal object,", + `got ${JSON.stringify(column)} at position ${i}` + ]); + } + } + return normalizedColumns; + }; + var ResizeableBuffer = class { + constructor(size = 100) { + this.size = size; + this.length = 0; + this.buf = Buffer.allocUnsafe(size); + } + prepend(val) { + if (isBuffer(val)) { + const length = this.length + val.length; + if (length >= this.size) { + this.resize(); + if (length >= this.size) { + throw Error("INVALID_BUFFER_STATE"); + } + } + const buf = this.buf; + this.buf = Buffer.allocUnsafe(this.size); + val.copy(this.buf, 0); + buf.copy(this.buf, val.length); + this.length += val.length; + } else { + const length = this.length++; + if (length === this.size) { + this.resize(); + } + const buf = this.clone(); + this.buf[0] = val; + buf.copy(this.buf, 1, 0, length); + } + } + append(val) { + const length = this.length++; + if (length === this.size) { + this.resize(); + } + this.buf[length] = val; + } + clone() { + return Buffer.from(this.buf.slice(0, this.length)); + } + resize() { + const length = this.length; + this.size = this.size * 2; + const buf = Buffer.allocUnsafe(this.size); + this.buf.copy(buf, 0, 0, length); + this.buf = buf; + } + toString(encoding) { + if (encoding) { + return this.buf.slice(0, this.length).toString(encoding); + } else { + return Uint8Array.prototype.slice.call(this.buf.slice(0, this.length)); + } + } + toJSON() { + return this.toString("utf8"); + } + reset() { + this.length = 0; + } + }; + var np = 12; + var cr$1 = 13; + var nl$1 = 10; + var space = 32; + var tab = 9; + var init_state = function(options) { + return { + bomSkipped: false, + bufBytesStart: 0, + castField: options.cast_function, + commenting: false, + // Current error encountered by a record + error: void 0, + enabled: options.from_line === 1, + escaping: false, + escapeIsQuote: isBuffer(options.escape) && isBuffer(options.quote) && Buffer.compare(options.escape, options.quote) === 0, + // columns can be `false`, `true`, `Array` + expectedRecordLength: Array.isArray(options.columns) ? options.columns.length : void 0, + field: new ResizeableBuffer(20), + firstLineToHeaders: options.cast_first_line_to_header, + needMoreDataSize: Math.max( + // Skip if the remaining buffer smaller than comment + options.comment !== null ? options.comment.length : 0, + ...options.delimiter.map((delimiter) => delimiter.length), + // Skip if the remaining buffer can be escape sequence + options.quote !== null ? options.quote.length : 0 + ), + previousBuf: void 0, + quoting: false, + stop: false, + rawBuffer: new ResizeableBuffer(100), + record: [], + recordHasError: false, + record_length: 0, + recordDelimiterMaxLength: options.record_delimiter.length === 0 ? 0 : Math.max(...options.record_delimiter.map((v) => v.length)), + trimChars: [Buffer.from(" ", options.encoding)[0], Buffer.from(" ", options.encoding)[0]], + wasQuoting: false, + wasRowDelimiter: false, + timchars: [ + Buffer.from(Buffer.from([cr$1], "utf8").toString(), options.encoding), + Buffer.from(Buffer.from([nl$1], "utf8").toString(), options.encoding), + Buffer.from(Buffer.from([np], "utf8").toString(), options.encoding), + Buffer.from(Buffer.from([space], "utf8").toString(), options.encoding), + Buffer.from(Buffer.from([tab], "utf8").toString(), options.encoding) + ] + }; + }; + var underscore = function(str) { + return str.replace(/([A-Z])/g, function(_, match) { + return "_" + match.toLowerCase(); + }); + }; + var normalize_options = function(opts) { + const options = {}; + for (const opt in opts) { + options[underscore(opt)] = opts[opt]; + } + if (options.encoding === void 0 || options.encoding === true) { + options.encoding = "utf8"; + } else if (options.encoding === null || options.encoding === false) { + options.encoding = null; + } else if (typeof options.encoding !== "string" && options.encoding !== null) { + throw new CsvError("CSV_INVALID_OPTION_ENCODING", [ + "Invalid option encoding:", + "encoding must be a string or null to return a buffer,", + `got ${JSON.stringify(options.encoding)}` + ], options); + } + if (options.bom === void 0 || options.bom === null || options.bom === false) { + options.bom = false; + } else if (options.bom !== true) { + throw new CsvError("CSV_INVALID_OPTION_BOM", [ + "Invalid option bom:", + "bom must be true,", + `got ${JSON.stringify(options.bom)}` + ], options); + } + options.cast_function = null; + if (options.cast === void 0 || options.cast === null || options.cast === false || options.cast === "") { + options.cast = void 0; + } else if (typeof options.cast === "function") { + options.cast_function = options.cast; + options.cast = true; + } else if (options.cast !== true) { + throw new CsvError("CSV_INVALID_OPTION_CAST", [ + "Invalid option cast:", + "cast must be true or a function,", + `got ${JSON.stringify(options.cast)}` + ], options); + } + if (options.cast_date === void 0 || options.cast_date === null || options.cast_date === false || options.cast_date === "") { + options.cast_date = false; + } else if (options.cast_date === true) { + options.cast_date = function(value) { + const date = Date.parse(value); + return !isNaN(date) ? new Date(date) : value; + }; + } else if (typeof options.cast_date !== "function") { + throw new CsvError("CSV_INVALID_OPTION_CAST_DATE", [ + "Invalid option cast_date:", + "cast_date must be true or a function,", + `got ${JSON.stringify(options.cast_date)}` + ], options); + } + options.cast_first_line_to_header = null; + if (options.columns === true) { + options.cast_first_line_to_header = void 0; + } else if (typeof options.columns === "function") { + options.cast_first_line_to_header = options.columns; + options.columns = true; + } else if (Array.isArray(options.columns)) { + options.columns = normalize_columns_array(options.columns); + } else if (options.columns === void 0 || options.columns === null || options.columns === false) { + options.columns = false; + } else { + throw new CsvError("CSV_INVALID_OPTION_COLUMNS", [ + "Invalid option columns:", + "expect an array, a function or true,", + `got ${JSON.stringify(options.columns)}` + ], options); + } + if (options.group_columns_by_name === void 0 || options.group_columns_by_name === null || options.group_columns_by_name === false) { + options.group_columns_by_name = false; + } else if (options.group_columns_by_name !== true) { + throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [ + "Invalid option group_columns_by_name:", + "expect an boolean,", + `got ${JSON.stringify(options.group_columns_by_name)}` + ], options); + } else if (options.columns === false) { + throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [ + "Invalid option group_columns_by_name:", + "the `columns` mode must be activated." + ], options); + } + if (options.comment === void 0 || options.comment === null || options.comment === false || options.comment === "") { + options.comment = null; + } else { + if (typeof options.comment === "string") { + options.comment = Buffer.from(options.comment, options.encoding); + } + if (!isBuffer(options.comment)) { + throw new CsvError("CSV_INVALID_OPTION_COMMENT", [ + "Invalid option comment:", + "comment must be a buffer or a string,", + `got ${JSON.stringify(options.comment)}` + ], options); + } + } + if (options.comment_no_infix === void 0 || options.comment_no_infix === null || options.comment_no_infix === false) { + options.comment_no_infix = false; + } else if (options.comment_no_infix !== true) { + throw new CsvError("CSV_INVALID_OPTION_COMMENT", [ + "Invalid option comment_no_infix:", + "value must be a boolean,", + `got ${JSON.stringify(options.comment_no_infix)}` + ], options); + } + const delimiter_json = JSON.stringify(options.delimiter); + if (!Array.isArray(options.delimiter)) options.delimiter = [options.delimiter]; + if (options.delimiter.length === 0) { + throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [ + "Invalid option delimiter:", + "delimiter must be a non empty string or buffer or array of string|buffer,", + `got ${delimiter_json}` + ], options); + } + options.delimiter = options.delimiter.map(function(delimiter) { + if (delimiter === void 0 || delimiter === null || delimiter === false) { + return Buffer.from(",", options.encoding); + } + if (typeof delimiter === "string") { + delimiter = Buffer.from(delimiter, options.encoding); + } + if (!isBuffer(delimiter) || delimiter.length === 0) { + throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [ + "Invalid option delimiter:", + "delimiter must be a non empty string or buffer or array of string|buffer,", + `got ${delimiter_json}` + ], options); + } + return delimiter; + }); + if (options.escape === void 0 || options.escape === true) { + options.escape = Buffer.from('"', options.encoding); + } else if (typeof options.escape === "string") { + options.escape = Buffer.from(options.escape, options.encoding); + } else if (options.escape === null || options.escape === false) { + options.escape = null; + } + if (options.escape !== null) { + if (!isBuffer(options.escape)) { + throw new Error(`Invalid Option: escape must be a buffer, a string or a boolean, got ${JSON.stringify(options.escape)}`); + } + } + if (options.from === void 0 || options.from === null) { + options.from = 1; + } else { + if (typeof options.from === "string" && /\d+/.test(options.from)) { + options.from = parseInt(options.from); + } + if (Number.isInteger(options.from)) { + if (options.from < 0) { + throw new Error(`Invalid Option: from must be a positive integer, got ${JSON.stringify(opts.from)}`); + } + } else { + throw new Error(`Invalid Option: from must be an integer, got ${JSON.stringify(options.from)}`); + } + } + if (options.from_line === void 0 || options.from_line === null) { + options.from_line = 1; + } else { + if (typeof options.from_line === "string" && /\d+/.test(options.from_line)) { + options.from_line = parseInt(options.from_line); + } + if (Number.isInteger(options.from_line)) { + if (options.from_line <= 0) { + throw new Error(`Invalid Option: from_line must be a positive integer greater than 0, got ${JSON.stringify(opts.from_line)}`); + } + } else { + throw new Error(`Invalid Option: from_line must be an integer, got ${JSON.stringify(opts.from_line)}`); + } + } + if (options.ignore_last_delimiters === void 0 || options.ignore_last_delimiters === null) { + options.ignore_last_delimiters = false; + } else if (typeof options.ignore_last_delimiters === "number") { + options.ignore_last_delimiters = Math.floor(options.ignore_last_delimiters); + if (options.ignore_last_delimiters === 0) { + options.ignore_last_delimiters = false; + } + } else if (typeof options.ignore_last_delimiters !== "boolean") { + throw new CsvError("CSV_INVALID_OPTION_IGNORE_LAST_DELIMITERS", [ + "Invalid option `ignore_last_delimiters`:", + "the value must be a boolean value or an integer,", + `got ${JSON.stringify(options.ignore_last_delimiters)}` + ], options); + } + if (options.ignore_last_delimiters === true && options.columns === false) { + throw new CsvError("CSV_IGNORE_LAST_DELIMITERS_REQUIRES_COLUMNS", [ + "The option `ignore_last_delimiters`", + "requires the activation of the `columns` option" + ], options); + } + if (options.info === void 0 || options.info === null || options.info === false) { + options.info = false; + } else if (options.info !== true) { + throw new Error(`Invalid Option: info must be true, got ${JSON.stringify(options.info)}`); + } + if (options.max_record_size === void 0 || options.max_record_size === null || options.max_record_size === false) { + options.max_record_size = 0; + } else if (Number.isInteger(options.max_record_size) && options.max_record_size >= 0) ; + else if (typeof options.max_record_size === "string" && /\d+/.test(options.max_record_size)) { + options.max_record_size = parseInt(options.max_record_size); + } else { + throw new Error(`Invalid Option: max_record_size must be a positive integer, got ${JSON.stringify(options.max_record_size)}`); + } + if (options.objname === void 0 || options.objname === null || options.objname === false) { + options.objname = void 0; + } else if (isBuffer(options.objname)) { + if (options.objname.length === 0) { + throw new Error(`Invalid Option: objname must be a non empty buffer`); + } + if (options.encoding === null) ; + else { + options.objname = options.objname.toString(options.encoding); + } + } else if (typeof options.objname === "string") { + if (options.objname.length === 0) { + throw new Error(`Invalid Option: objname must be a non empty string`); + } + } else if (typeof options.objname === "number") ; + else { + throw new Error(`Invalid Option: objname must be a string or a buffer, got ${options.objname}`); + } + if (options.objname !== void 0) { + if (typeof options.objname === "number") { + if (options.columns !== false) { + throw Error("Invalid Option: objname index cannot be combined with columns or be defined as a field"); + } + } else { + if (options.columns === false) { + throw Error("Invalid Option: objname field must be combined with columns or be defined as an index"); + } + } + } + if (options.on_record === void 0 || options.on_record === null) { + options.on_record = void 0; + } else if (typeof options.on_record !== "function") { + throw new CsvError("CSV_INVALID_OPTION_ON_RECORD", [ + "Invalid option `on_record`:", + "expect a function,", + `got ${JSON.stringify(options.on_record)}` + ], options); + } + if (options.on_skip !== void 0 && options.on_skip !== null && typeof options.on_skip !== "function") { + throw new Error(`Invalid Option: on_skip must be a function, got ${JSON.stringify(options.on_skip)}`); + } + if (options.quote === null || options.quote === false || options.quote === "") { + options.quote = null; + } else { + if (options.quote === void 0 || options.quote === true) { + options.quote = Buffer.from('"', options.encoding); + } else if (typeof options.quote === "string") { + options.quote = Buffer.from(options.quote, options.encoding); + } + if (!isBuffer(options.quote)) { + throw new Error(`Invalid Option: quote must be a buffer or a string, got ${JSON.stringify(options.quote)}`); + } + } + if (options.raw === void 0 || options.raw === null || options.raw === false) { + options.raw = false; + } else if (options.raw !== true) { + throw new Error(`Invalid Option: raw must be true, got ${JSON.stringify(options.raw)}`); + } + if (options.record_delimiter === void 0) { + options.record_delimiter = []; + } else if (typeof options.record_delimiter === "string" || isBuffer(options.record_delimiter)) { + if (options.record_delimiter.length === 0) { + throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [ + "Invalid option `record_delimiter`:", + "value must be a non empty string or buffer,", + `got ${JSON.stringify(options.record_delimiter)}` + ], options); + } + options.record_delimiter = [options.record_delimiter]; + } else if (!Array.isArray(options.record_delimiter)) { + throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [ + "Invalid option `record_delimiter`:", + "value must be a string, a buffer or array of string|buffer,", + `got ${JSON.stringify(options.record_delimiter)}` + ], options); + } + options.record_delimiter = options.record_delimiter.map(function(rd, i) { + if (typeof rd !== "string" && !isBuffer(rd)) { + throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [ + "Invalid option `record_delimiter`:", + "value must be a string, a buffer or array of string|buffer", + `at index ${i},`, + `got ${JSON.stringify(rd)}` + ], options); + } else if (rd.length === 0) { + throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [ + "Invalid option `record_delimiter`:", + "value must be a non empty string or buffer", + `at index ${i},`, + `got ${JSON.stringify(rd)}` + ], options); + } + if (typeof rd === "string") { + rd = Buffer.from(rd, options.encoding); + } + return rd; + }); + if (typeof options.relax_column_count === "boolean") ; + else if (options.relax_column_count === void 0 || options.relax_column_count === null) { + options.relax_column_count = false; + } else { + throw new Error(`Invalid Option: relax_column_count must be a boolean, got ${JSON.stringify(options.relax_column_count)}`); + } + if (typeof options.relax_column_count_less === "boolean") ; + else if (options.relax_column_count_less === void 0 || options.relax_column_count_less === null) { + options.relax_column_count_less = false; + } else { + throw new Error(`Invalid Option: relax_column_count_less must be a boolean, got ${JSON.stringify(options.relax_column_count_less)}`); + } + if (typeof options.relax_column_count_more === "boolean") ; + else if (options.relax_column_count_more === void 0 || options.relax_column_count_more === null) { + options.relax_column_count_more = false; + } else { + throw new Error(`Invalid Option: relax_column_count_more must be a boolean, got ${JSON.stringify(options.relax_column_count_more)}`); + } + if (typeof options.relax_quotes === "boolean") ; + else if (options.relax_quotes === void 0 || options.relax_quotes === null) { + options.relax_quotes = false; + } else { + throw new Error(`Invalid Option: relax_quotes must be a boolean, got ${JSON.stringify(options.relax_quotes)}`); + } + if (typeof options.skip_empty_lines === "boolean") ; + else if (options.skip_empty_lines === void 0 || options.skip_empty_lines === null) { + options.skip_empty_lines = false; + } else { + throw new Error(`Invalid Option: skip_empty_lines must be a boolean, got ${JSON.stringify(options.skip_empty_lines)}`); + } + if (typeof options.skip_records_with_empty_values === "boolean") ; + else if (options.skip_records_with_empty_values === void 0 || options.skip_records_with_empty_values === null) { + options.skip_records_with_empty_values = false; + } else { + throw new Error(`Invalid Option: skip_records_with_empty_values must be a boolean, got ${JSON.stringify(options.skip_records_with_empty_values)}`); + } + if (typeof options.skip_records_with_error === "boolean") ; + else if (options.skip_records_with_error === void 0 || options.skip_records_with_error === null) { + options.skip_records_with_error = false; + } else { + throw new Error(`Invalid Option: skip_records_with_error must be a boolean, got ${JSON.stringify(options.skip_records_with_error)}`); + } + if (options.rtrim === void 0 || options.rtrim === null || options.rtrim === false) { + options.rtrim = false; + } else if (options.rtrim !== true) { + throw new Error(`Invalid Option: rtrim must be a boolean, got ${JSON.stringify(options.rtrim)}`); + } + if (options.ltrim === void 0 || options.ltrim === null || options.ltrim === false) { + options.ltrim = false; + } else if (options.ltrim !== true) { + throw new Error(`Invalid Option: ltrim must be a boolean, got ${JSON.stringify(options.ltrim)}`); + } + if (options.trim === void 0 || options.trim === null || options.trim === false) { + options.trim = false; + } else if (options.trim !== true) { + throw new Error(`Invalid Option: trim must be a boolean, got ${JSON.stringify(options.trim)}`); + } + if (options.trim === true && opts.ltrim !== false) { + options.ltrim = true; + } else if (options.ltrim !== true) { + options.ltrim = false; + } + if (options.trim === true && opts.rtrim !== false) { + options.rtrim = true; + } else if (options.rtrim !== true) { + options.rtrim = false; + } + if (options.to === void 0 || options.to === null) { + options.to = -1; + } else { + if (typeof options.to === "string" && /\d+/.test(options.to)) { + options.to = parseInt(options.to); + } + if (Number.isInteger(options.to)) { + if (options.to <= 0) { + throw new Error(`Invalid Option: to must be a positive integer greater than 0, got ${JSON.stringify(opts.to)}`); + } + } else { + throw new Error(`Invalid Option: to must be an integer, got ${JSON.stringify(opts.to)}`); + } + } + if (options.to_line === void 0 || options.to_line === null) { + options.to_line = -1; + } else { + if (typeof options.to_line === "string" && /\d+/.test(options.to_line)) { + options.to_line = parseInt(options.to_line); + } + if (Number.isInteger(options.to_line)) { + if (options.to_line <= 0) { + throw new Error(`Invalid Option: to_line must be a positive integer greater than 0, got ${JSON.stringify(opts.to_line)}`); + } + } else { + throw new Error(`Invalid Option: to_line must be an integer, got ${JSON.stringify(opts.to_line)}`); + } + } + return options; + }; + var isRecordEmpty = function(record) { + return record.every((field) => field == null || field.toString && field.toString().trim() === ""); + }; + var cr = 13; + var nl = 10; + var boms = { + // Note, the following are equals: + // Buffer.from("\ufeff") + // Buffer.from([239, 187, 191]) + // Buffer.from('EFBBBF', 'hex') + "utf8": Buffer.from([239, 187, 191]), + // Note, the following are equals: + // Buffer.from "\ufeff", 'utf16le + // Buffer.from([255, 254]) + "utf16le": Buffer.from([255, 254]) + }; + var transform = function(original_options = {}) { + const info = { + bytes: 0, + comment_lines: 0, + empty_lines: 0, + invalid_field_length: 0, + lines: 1, + records: 0 + }; + const options = normalize_options(original_options); + return { + info, + original_options, + options, + state: init_state(options), + __needMoreData: function(i, bufLen, end) { + if (end) return false; + const { encoding, escape, quote } = this.options; + const { quoting, needMoreDataSize, recordDelimiterMaxLength } = this.state; + const numOfCharLeft = bufLen - i - 1; + const requiredLength = Math.max( + needMoreDataSize, + // Skip if the remaining buffer smaller than record delimiter + // If "record_delimiter" is yet to be discovered: + // 1. It is equals to `[]` and "recordDelimiterMaxLength" equals `0` + // 2. We set the length to windows line ending in the current encoding + // Note, that encoding is known from user or bom discovery at that point + // recordDelimiterMaxLength, + recordDelimiterMaxLength === 0 ? Buffer.from("\r\n", encoding).length : recordDelimiterMaxLength, + // Skip if remaining buffer can be an escaped quote + quoting ? (escape === null ? 0 : escape.length) + quote.length : 0, + // Skip if remaining buffer can be record delimiter following the closing quote + quoting ? quote.length + recordDelimiterMaxLength : 0 + ); + return numOfCharLeft < requiredLength; + }, + // Central parser implementation + parse: function(nextBuf, end, push, close) { + const { bom, comment_no_infix, encoding, from_line, ltrim, max_record_size, raw, relax_quotes, rtrim, skip_empty_lines, to, to_line } = this.options; + let { comment, escape, quote, record_delimiter } = this.options; + const { bomSkipped, previousBuf, rawBuffer, escapeIsQuote } = this.state; + let buf; + if (previousBuf === void 0) { + if (nextBuf === void 0) { + close(); + return; + } else { + buf = nextBuf; + } + } else if (previousBuf !== void 0 && nextBuf === void 0) { + buf = previousBuf; + } else { + buf = Buffer.concat([previousBuf, nextBuf]); + } + if (bomSkipped === false) { + if (bom === false) { + this.state.bomSkipped = true; + } else if (buf.length < 3) { + if (end === false) { + this.state.previousBuf = buf; + return; + } + } else { + for (const encoding2 in boms) { + if (boms[encoding2].compare(buf, 0, boms[encoding2].length) === 0) { + const bomLength = boms[encoding2].length; + this.state.bufBytesStart += bomLength; + buf = buf.slice(bomLength); + this.options = normalize_options({ ...this.original_options, encoding: encoding2 }); + ({ comment, escape, quote } = this.options); + break; + } + } + this.state.bomSkipped = true; + } + } + const bufLen = buf.length; + let pos; + for (pos = 0; pos < bufLen; pos++) { + if (this.__needMoreData(pos, bufLen, end)) { + break; + } + if (this.state.wasRowDelimiter === true) { + this.info.lines++; + this.state.wasRowDelimiter = false; + } + if (to_line !== -1 && this.info.lines > to_line) { + this.state.stop = true; + close(); + return; + } + if (this.state.quoting === false && record_delimiter.length === 0) { + const record_delimiterCount = this.__autoDiscoverRecordDelimiter(buf, pos); + if (record_delimiterCount) { + record_delimiter = this.options.record_delimiter; + } + } + const chr = buf[pos]; + if (raw === true) { + rawBuffer.append(chr); + } + if ((chr === cr || chr === nl) && this.state.wasRowDelimiter === false) { + this.state.wasRowDelimiter = true; + } + if (this.state.escaping === true) { + this.state.escaping = false; + } else { + if (escape !== null && this.state.quoting === true && this.__isEscape(buf, pos, chr) && pos + escape.length < bufLen) { + if (escapeIsQuote) { + if (this.__isQuote(buf, pos + escape.length)) { + this.state.escaping = true; + pos += escape.length - 1; + continue; + } + } else { + this.state.escaping = true; + pos += escape.length - 1; + continue; + } + } + if (this.state.commenting === false && this.__isQuote(buf, pos)) { + if (this.state.quoting === true) { + const nextChr = buf[pos + quote.length]; + const isNextChrTrimable = rtrim && this.__isCharTrimable(buf, pos + quote.length); + const isNextChrComment = comment !== null && this.__compareBytes(comment, buf, pos + quote.length, nextChr); + const isNextChrDelimiter = this.__isDelimiter(buf, pos + quote.length, nextChr); + const isNextChrRecordDelimiter = record_delimiter.length === 0 ? this.__autoDiscoverRecordDelimiter(buf, pos + quote.length) : this.__isRecordDelimiter(nextChr, buf, pos + quote.length); + if (escape !== null && this.__isEscape(buf, pos, chr) && this.__isQuote(buf, pos + escape.length)) { + pos += escape.length - 1; + } else if (!nextChr || isNextChrDelimiter || isNextChrRecordDelimiter || isNextChrComment || isNextChrTrimable) { + this.state.quoting = false; + this.state.wasQuoting = true; + pos += quote.length - 1; + continue; + } else if (relax_quotes === false) { + const err = this.__error( + new CsvError("CSV_INVALID_CLOSING_QUOTE", [ + "Invalid Closing Quote:", + `got "${String.fromCharCode(nextChr)}"`, + `at line ${this.info.lines}`, + "instead of delimiter, record delimiter, trimable character", + "(if activated) or comment" + ], this.options, this.__infoField()) + ); + if (err !== void 0) return err; + } else { + this.state.quoting = false; + this.state.wasQuoting = true; + this.state.field.prepend(quote); + pos += quote.length - 1; + } + } else { + if (this.state.field.length !== 0) { + if (relax_quotes === false) { + const info2 = this.__infoField(); + const bom2 = Object.keys(boms).map((b) => boms[b].equals(this.state.field.toString()) ? b : false).filter(Boolean)[0]; + const err = this.__error( + new CsvError("INVALID_OPENING_QUOTE", [ + "Invalid Opening Quote:", + `a quote is found on field ${JSON.stringify(info2.column)} at line ${info2.lines}, value is ${JSON.stringify(this.state.field.toString(encoding))}`, + bom2 ? `(${bom2} bom)` : void 0 + ], this.options, info2, { + field: this.state.field + }) + ); + if (err !== void 0) return err; + } + } else { + this.state.quoting = true; + pos += quote.length - 1; + continue; + } + } + } + if (this.state.quoting === false) { + const recordDelimiterLength = this.__isRecordDelimiter(chr, buf, pos); + if (recordDelimiterLength !== 0) { + const skipCommentLine = this.state.commenting && (this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0); + if (skipCommentLine) { + this.info.comment_lines++; + } else { + if (this.state.enabled === false && this.info.lines + (this.state.wasRowDelimiter === true ? 1 : 0) >= from_line) { + this.state.enabled = true; + this.__resetField(); + this.__resetRecord(); + pos += recordDelimiterLength - 1; + continue; + } + if (skip_empty_lines === true && this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0) { + this.info.empty_lines++; + pos += recordDelimiterLength - 1; + continue; + } + this.info.bytes = this.state.bufBytesStart + pos; + const errField = this.__onField(); + if (errField !== void 0) return errField; + this.info.bytes = this.state.bufBytesStart + pos + recordDelimiterLength; + const errRecord = this.__onRecord(push); + if (errRecord !== void 0) return errRecord; + if (to !== -1 && this.info.records >= to) { + this.state.stop = true; + close(); + return; + } + } + this.state.commenting = false; + pos += recordDelimiterLength - 1; + continue; + } + if (this.state.commenting) { + continue; + } + if (comment !== null && (comment_no_infix === false || this.state.record.length === 0 && this.state.field.length === 0)) { + const commentCount = this.__compareBytes(comment, buf, pos, chr); + if (commentCount !== 0) { + this.state.commenting = true; + continue; + } + } + const delimiterLength = this.__isDelimiter(buf, pos, chr); + if (delimiterLength !== 0) { + this.info.bytes = this.state.bufBytesStart + pos; + const errField = this.__onField(); + if (errField !== void 0) return errField; + pos += delimiterLength - 1; + continue; + } + } + } + if (this.state.commenting === false) { + if (max_record_size !== 0 && this.state.record_length + this.state.field.length > max_record_size) { + return this.__error( + new CsvError("CSV_MAX_RECORD_SIZE", [ + "Max Record Size:", + "record exceed the maximum number of tolerated bytes", + `of ${max_record_size}`, + `at line ${this.info.lines}` + ], this.options, this.__infoField()) + ); + } + } + const lappend = ltrim === false || this.state.quoting === true || this.state.field.length !== 0 || !this.__isCharTrimable(buf, pos); + const rappend = rtrim === false || this.state.wasQuoting === false; + if (lappend === true && rappend === true) { + this.state.field.append(chr); + } else if (rtrim === true && !this.__isCharTrimable(buf, pos)) { + return this.__error( + new CsvError("CSV_NON_TRIMABLE_CHAR_AFTER_CLOSING_QUOTE", [ + "Invalid Closing Quote:", + "found non trimable byte after quote", + `at line ${this.info.lines}` + ], this.options, this.__infoField()) + ); + } else { + if (lappend === false) { + pos += this.__isCharTrimable(buf, pos) - 1; + } + continue; + } + } + if (end === true) { + if (this.state.quoting === true) { + const err = this.__error( + new CsvError("CSV_QUOTE_NOT_CLOSED", [ + "Quote Not Closed:", + `the parsing is finished with an opening quote at line ${this.info.lines}` + ], this.options, this.__infoField()) + ); + if (err !== void 0) return err; + } else { + if (this.state.wasQuoting === true || this.state.record.length !== 0 || this.state.field.length !== 0) { + this.info.bytes = this.state.bufBytesStart + pos; + const errField = this.__onField(); + if (errField !== void 0) return errField; + const errRecord = this.__onRecord(push); + if (errRecord !== void 0) return errRecord; + } else if (this.state.wasRowDelimiter === true) { + this.info.empty_lines++; + } else if (this.state.commenting === true) { + this.info.comment_lines++; + } + } + } else { + this.state.bufBytesStart += pos; + this.state.previousBuf = buf.slice(pos); + } + if (this.state.wasRowDelimiter === true) { + this.info.lines++; + this.state.wasRowDelimiter = false; + } + }, + __onRecord: function(push) { + const { columns, group_columns_by_name, encoding, info: info2, from: from2, relax_column_count, relax_column_count_less, relax_column_count_more, raw, skip_records_with_empty_values } = this.options; + const { enabled, record } = this.state; + if (enabled === false) { + return this.__resetRecord(); + } + const recordLength = record.length; + if (columns === true) { + if (skip_records_with_empty_values === true && isRecordEmpty(record)) { + this.__resetRecord(); + return; + } + return this.__firstLineToColumns(record); + } + if (columns === false && this.info.records === 0) { + this.state.expectedRecordLength = recordLength; + } + if (recordLength !== this.state.expectedRecordLength) { + const err = columns === false ? new CsvError("CSV_RECORD_INCONSISTENT_FIELDS_LENGTH", [ + "Invalid Record Length:", + `expect ${this.state.expectedRecordLength},`, + `got ${recordLength} on line ${this.info.lines}` + ], this.options, this.__infoField(), { + record + }) : new CsvError("CSV_RECORD_INCONSISTENT_COLUMNS", [ + "Invalid Record Length:", + `columns length is ${columns.length},`, + // rename columns + `got ${recordLength} on line ${this.info.lines}` + ], this.options, this.__infoField(), { + record + }); + if (relax_column_count === true || relax_column_count_less === true && recordLength < this.state.expectedRecordLength || relax_column_count_more === true && recordLength > this.state.expectedRecordLength) { + this.info.invalid_field_length++; + this.state.error = err; + } else { + const finalErr = this.__error(err); + if (finalErr) return finalErr; + } + } + if (skip_records_with_empty_values === true && isRecordEmpty(record)) { + this.__resetRecord(); + return; + } + if (this.state.recordHasError === true) { + this.__resetRecord(); + this.state.recordHasError = false; + return; + } + this.info.records++; + if (from2 === 1 || this.info.records >= from2) { + const { objname } = this.options; + if (columns !== false) { + const obj = {}; + for (let i = 0, l = record.length; i < l; i++) { + if (columns[i] === void 0 || columns[i].disabled) continue; + if (group_columns_by_name === true && obj[columns[i].name] !== void 0) { + if (Array.isArray(obj[columns[i].name])) { + obj[columns[i].name] = obj[columns[i].name].concat(record[i]); + } else { + obj[columns[i].name] = [obj[columns[i].name], record[i]]; + } + } else { + obj[columns[i].name] = record[i]; + } + } + if (raw === true || info2 === true) { + const extRecord = Object.assign( + { record: obj }, + raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {}, + info2 === true ? { info: this.__infoRecord() } : {} + ); + const err = this.__push( + objname === void 0 ? extRecord : [obj[objname], extRecord], + push + ); + if (err) { + return err; + } + } else { + const err = this.__push( + objname === void 0 ? obj : [obj[objname], obj], + push + ); + if (err) { + return err; + } + } + } else { + if (raw === true || info2 === true) { + const extRecord = Object.assign( + { record }, + raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {}, + info2 === true ? { info: this.__infoRecord() } : {} + ); + const err = this.__push( + objname === void 0 ? extRecord : [record[objname], extRecord], + push + ); + if (err) { + return err; + } + } else { + const err = this.__push( + objname === void 0 ? record : [record[objname], record], + push + ); + if (err) { + return err; + } + } + } + } + this.__resetRecord(); + }, + __firstLineToColumns: function(record) { + const { firstLineToHeaders } = this.state; + try { + const headers = firstLineToHeaders === void 0 ? record : firstLineToHeaders.call(null, record); + if (!Array.isArray(headers)) { + return this.__error( + new CsvError("CSV_INVALID_COLUMN_MAPPING", [ + "Invalid Column Mapping:", + "expect an array from column function,", + `got ${JSON.stringify(headers)}` + ], this.options, this.__infoField(), { + headers + }) + ); + } + const normalizedHeaders = normalize_columns_array(headers); + this.state.expectedRecordLength = normalizedHeaders.length; + this.options.columns = normalizedHeaders; + this.__resetRecord(); + return; + } catch (err) { + return err; + } + }, + __resetRecord: function() { + if (this.options.raw === true) { + this.state.rawBuffer.reset(); + } + this.state.error = void 0; + this.state.record = []; + this.state.record_length = 0; + }, + __onField: function() { + const { cast, encoding, rtrim, max_record_size } = this.options; + const { enabled, wasQuoting } = this.state; + if (enabled === false) { + return this.__resetField(); + } + let field = this.state.field.toString(encoding); + if (rtrim === true && wasQuoting === false) { + field = field.trimRight(); + } + if (cast === true) { + const [err, f] = this.__cast(field); + if (err !== void 0) return err; + field = f; + } + this.state.record.push(field); + if (max_record_size !== 0 && typeof field === "string") { + this.state.record_length += field.length; + } + this.__resetField(); + }, + __resetField: function() { + this.state.field.reset(); + this.state.wasQuoting = false; + }, + __push: function(record, push) { + const { on_record } = this.options; + if (on_record !== void 0) { + const info2 = this.__infoRecord(); + try { + record = on_record.call(null, record, info2); + } catch (err) { + return err; + } + if (record === void 0 || record === null) { + return; + } + } + push(record); + }, + // Return a tuple with the error and the casted value + __cast: function(field) { + const { columns, relax_column_count } = this.options; + const isColumns = Array.isArray(columns); + if (isColumns === true && relax_column_count && this.options.columns.length <= this.state.record.length) { + return [void 0, void 0]; + } + if (this.state.castField !== null) { + try { + const info2 = this.__infoField(); + return [void 0, this.state.castField.call(null, field, info2)]; + } catch (err) { + return [err]; + } + } + if (this.__isFloat(field)) { + return [void 0, parseFloat(field)]; + } else if (this.options.cast_date !== false) { + const info2 = this.__infoField(); + return [void 0, this.options.cast_date.call(null, field, info2)]; + } + return [void 0, field]; + }, + // Helper to test if a character is a space or a line delimiter + __isCharTrimable: function(buf, pos) { + const isTrim = (buf2, pos2) => { + const { timchars } = this.state; + loop1: for (let i = 0; i < timchars.length; i++) { + const timchar = timchars[i]; + for (let j = 0; j < timchar.length; j++) { + if (timchar[j] !== buf2[pos2 + j]) continue loop1; + } + return timchar.length; + } + return 0; + }; + return isTrim(buf, pos); + }, + // Keep it in case we implement the `cast_int` option + // __isInt(value){ + // // return Number.isInteger(parseInt(value)) + // // return !isNaN( parseInt( obj ) ); + // return /^(\-|\+)?[1-9][0-9]*$/.test(value) + // } + __isFloat: function(value) { + return value - parseFloat(value) + 1 >= 0; + }, + __compareBytes: function(sourceBuf, targetBuf, targetPos, firstByte) { + if (sourceBuf[0] !== firstByte) return 0; + const sourceLength = sourceBuf.length; + for (let i = 1; i < sourceLength; i++) { + if (sourceBuf[i] !== targetBuf[targetPos + i]) return 0; + } + return sourceLength; + }, + __isDelimiter: function(buf, pos, chr) { + const { delimiter, ignore_last_delimiters } = this.options; + if (ignore_last_delimiters === true && this.state.record.length === this.options.columns.length - 1) { + return 0; + } else if (ignore_last_delimiters !== false && typeof ignore_last_delimiters === "number" && this.state.record.length === ignore_last_delimiters - 1) { + return 0; + } + loop1: for (let i = 0; i < delimiter.length; i++) { + const del = delimiter[i]; + if (del[0] === chr) { + for (let j = 1; j < del.length; j++) { + if (del[j] !== buf[pos + j]) continue loop1; + } + return del.length; + } + } + return 0; + }, + __isRecordDelimiter: function(chr, buf, pos) { + const { record_delimiter } = this.options; + const recordDelimiterLength = record_delimiter.length; + loop1: for (let i = 0; i < recordDelimiterLength; i++) { + const rd = record_delimiter[i]; + const rdLength = rd.length; + if (rd[0] !== chr) { + continue; + } + for (let j = 1; j < rdLength; j++) { + if (rd[j] !== buf[pos + j]) { + continue loop1; + } + } + return rd.length; + } + return 0; + }, + __isEscape: function(buf, pos, chr) { + const { escape } = this.options; + if (escape === null) return false; + const l = escape.length; + if (escape[0] === chr) { + for (let i = 0; i < l; i++) { + if (escape[i] !== buf[pos + i]) { + return false; + } + } + return true; + } + return false; + }, + __isQuote: function(buf, pos) { + const { quote } = this.options; + if (quote === null) return false; + const l = quote.length; + for (let i = 0; i < l; i++) { + if (quote[i] !== buf[pos + i]) { + return false; + } + } + return true; + }, + __autoDiscoverRecordDelimiter: function(buf, pos) { + const { encoding } = this.options; + const rds = [ + // Important, the windows line ending must be before mac os 9 + Buffer.from("\r\n", encoding), + Buffer.from("\n", encoding), + Buffer.from("\r", encoding) + ]; + loop: for (let i = 0; i < rds.length; i++) { + const l = rds[i].length; + for (let j = 0; j < l; j++) { + if (rds[i][j] !== buf[pos + j]) { + continue loop; + } + } + this.options.record_delimiter.push(rds[i]); + this.state.recordDelimiterMaxLength = rds[i].length; + return rds[i].length; + } + return 0; + }, + __error: function(msg) { + const { encoding, raw, skip_records_with_error } = this.options; + const err = typeof msg === "string" ? new Error(msg) : msg; + if (skip_records_with_error) { + this.state.recordHasError = true; + if (this.options.on_skip !== void 0) { + this.options.on_skip(err, raw ? this.state.rawBuffer.toString(encoding) : void 0); + } + return void 0; + } else { + return err; + } + }, + __infoDataSet: function() { + return { + ...this.info, + columns: this.options.columns + }; + }, + __infoRecord: function() { + const { columns, raw, encoding } = this.options; + return { + ...this.__infoDataSet(), + error: this.state.error, + header: columns === true, + index: this.state.record.length, + raw: raw ? this.state.rawBuffer.toString(encoding) : void 0 + }; + }, + __infoField: function() { + const { columns } = this.options; + const isColumns = Array.isArray(columns); + return { + ...this.__infoRecord(), + column: isColumns === true ? columns.length > this.state.record.length ? columns[this.state.record.length].name : null : this.state.record.length, + quoting: this.state.wasQuoting + }; + } + }; + }; + var Parser = class extends Transform { + constructor(opts = {}) { + super({ ...{ readableObjectMode: true }, ...opts, encoding: null }); + this.api = transform({ on_skip: (err, chunk) => { + this.emit("skip", err, chunk); + }, ...opts }); + this.state = this.api.state; + this.options = this.api.options; + this.info = this.api.info; + } + // Implementation of `Transform._transform` + _transform(buf, _, callback) { + if (this.state.stop === true) { + return; + } + const err = this.api.parse(buf, false, (record) => { + this.push(record); + }, () => { + this.push(null); + this.end(); + this.on("end", this.destroy); + }); + if (err !== void 0) { + this.state.stop = true; + } + callback(err); + } + // Implementation of `Transform._flush` + _flush(callback) { + if (this.state.stop === true) { + return; + } + const err = this.api.parse(void 0, true, (record) => { + this.push(record); + }, () => { + this.push(null); + this.on("end", this.destroy); + }); + callback(err); + } + }; + var parse = function() { + let data, options, callback; + for (const i in arguments) { + const argument = arguments[i]; + const type = typeof argument; + if (data === void 0 && (typeof argument === "string" || isBuffer(argument))) { + data = argument; + } else if (options === void 0 && is_object(argument)) { + options = argument; + } else if (callback === void 0 && type === "function") { + callback = argument; + } else { + throw new CsvError("CSV_INVALID_ARGUMENT", [ + "Invalid argument:", + `got ${JSON.stringify(argument)} at index ${i}` + ], options || {}); + } + } + const parser = new Parser(options); + if (callback) { + const records = options === void 0 || options.objname === void 0 ? [] : {}; + parser.on("readable", function() { + let record; + while ((record = this.read()) !== null) { + if (options === void 0 || options.objname === void 0) { + records.push(record); + } else { + records[record[0]] = record[1]; + } + } + }); + parser.on("error", function(err) { + callback(err, void 0, parser.api.__infoDataSet()); + }); + parser.on("end", function() { + callback(void 0, records, parser.api.__infoDataSet()); + }); + } + if (data !== void 0) { + const writer = function() { + parser.write(data); + parser.end(); + }; + if (typeof setImmediate === "function") { + setImmediate(writer); + } else { + setTimeout(writer, 0); + } + } + return parser; + }; + + // src/utils.ts + var PSYCHDS_IGNORE_FILENAME = ".psychds-ignore"; + var PSYCHDS_IGNORE_CONTENT = "**/raw/\n.psychds-ignore\n"; + function saveTextToFile(textstr, filename) { + const blobToSave = new Blob([textstr], { + type: "text/plain" + }); + let blobURL = ""; + if (typeof window.webkitURL !== "undefined") { + blobURL = window.webkitURL.createObjectURL(blobToSave); + } else { + blobURL = window.URL.createObjectURL(blobToSave); + } + const link = document.createElement("a"); + link.id = "jspsych-download-as-text-link"; + link.style.display = "none"; + link.download = filename; + link.href = blobURL; + link.click(); + } + function tryParseJSON(value) { + try { + return JSON.parse(value); + } catch { + return null; + } + } + function unwrapTrials(data) { + const parsed = typeof data === "string" ? JSON.parse(data) : data; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + const keys2 = Object.keys(parsed); + if (keys2.length === 1 && keys2[0] === "trials" && Array.isArray(parsed.trials)) { + return parsed.trials; + } + } + return parsed; + } + function parseJsonData(content, options = {}, stats) { + if (content.charCodeAt(0) === 65279) content = content.slice(1); + const whole = tryParseJSON(content); + if (whole !== null) return unwrapTrials(whole); + const lines = content.split(/\r?\n/); + const out = []; + let parsedAny = false; + let recordIndex = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + let value; + try { + value = JSON.parse(line); + } catch { + throw new Error( + `Could not parse data as JSON or JSON-Lines: line ${i + 1} is not valid JSON.` + ); + } + parsedAny = true; + const observations = Array.isArray(value) ? value : [value]; + if (options.tagSourceRecordId) { + for (const obs of observations) { + if (obs !== null && typeof obs === "object" && !Array.isArray(obs) && !("source_record_id" in obs) && !("participant_id" in obs)) { + obs.source_record_id = recordIndex; + if (stats) stats.synthesizedSourceRecordId = true; + } + } + } + out.push(...observations); + recordIndex++; + } + if (!parsedAny) { + throw new Error("Could not parse data: input is empty or not valid JSON/JSON-Lines."); + } + return out; + } + var SYSTEM_COLUMNS = /* @__PURE__ */ new Set([ + "trial_type", + "trial_index", + "time_elapsed", + "extension_type", + "extension_version" + ]); + function analyzeJoinKeys(parsedData, keys2) { + if (parsedData.length === 0) { + return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null }; + } + const compositeKeys = parsedData.map( + (row) => keys2.map((k) => String(row[k] ?? "")).join("\0") + ); + const keyCount = /* @__PURE__ */ new Map(); + for (const ck of compositeKeys) keyCount.set(ck, (keyCount.get(ck) ?? 0) + 1); + const duplicateCount = [...keyCount.values()].reduce((n, c) => n + (c > 1 ? c - 1 : 0), 0); + const isUnique = duplicateCount === 0; + const duplicateValues = []; + for (let i = 0; i < parsedData.length && duplicateValues.length < 5; i++) { + if ((keyCount.get(compositeKeys[i]) ?? 0) > 1) { + const vals = keys2.reduce((acc, k) => { + acc[k] = parsedData[i][k]; + return acc; + }, {}); + if (!duplicateValues.some((v) => JSON.stringify(v) === JSON.stringify(vals))) { + duplicateValues.push(vals); + } + } + } + if (isUnique) { + return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null }; + } + const keySet = new Set(keys2); + const allColumns = /* @__PURE__ */ new Set(); + for (const row of parsedData) for (const col of Object.keys(row)) allColumns.add(col); + const candidateColumns = [...allColumns].filter( + (col) => !isUnnamedHeader(col) && !keySet.has(col) && !SYSTEM_COLUMNS.has(col) + ); + const candidates = candidateColumns.map((col) => { + const extended = parsedData.map( + (row) => [...keys2, col].map((k) => String(row[k] ?? "")).join("\0") + ); + return { column: col, makesUnique: new Set(extended).size === parsedData.length }; + }); + if (candidates.some((c) => c.makesUnique)) { + return { isUnique, duplicateCount, duplicateValues, candidates, suggestedAdditionalKeys: [] }; + } + const workingKeys = [...keys2]; + const available = [...candidateColumns]; + while (available.length > 0) { + const current = parsedData.map( + (row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0") + ); + if (new Set(current).size === parsedData.length) break; + let bestCol = null; + let bestCount = new Set(current).size; + for (const col of available) { + const test = parsedData.map( + (row) => [...workingKeys, col].map((k) => String(row[k] ?? "")).join("\0") + ); + const count = new Set(test).size; + if (count > bestCount) { + bestCount = count; + bestCol = col; + } + } + if (bestCol === null) break; + workingKeys.push(bestCol); + available.splice(available.indexOf(bestCol), 1); + } + const added = workingKeys.slice(keys2.length); + const greedyIsUnique = new Set( + parsedData.map((row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0")) + ).size === parsedData.length; + return { + isUnique, + duplicateCount, + duplicateValues, + candidates, + suggestedAdditionalKeys: added.length > 0 && greedyIsUnique ? added : null + }; + } + var PSYCH_DS_FILENAME_RE = /^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\.(csv|tsv)$/; + function isValidPsychDSDataFilename(name) { + return PSYCH_DS_FILENAME_RE.test(name); + } + function toPsychDSValue(name, fallback = "value") { + const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean); + if (parts.length === 0) return fallback; + return parts[0] + parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1)).join(""); + } + function deriveFallbackBase(stem) { + return `subject-${toPsychDSValue(stem, "file")}`; + } + function deriveArrayFilename(parentBase, columnName) { + return `${parentBase}_measure-${toPsychDSValue(columnName, "col")}_data.csv`; + } + function objectsToCSV(rows, priorityCols = ["trial_index", "element_index"]) { + if (rows.length === 0) return ""; + const allKeys = /* @__PURE__ */ new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) allKeys.add(key); + } + const otherCols = [...allKeys].filter((k) => !priorityCols.includes(k)); + const headers = [...priorityCols.filter((c) => allKeys.has(c)), ...otherCols]; + const escape = (val) => { + if (val === null || val === void 0) return ""; + const str = typeof val === "object" ? JSON.stringify(val) : String(val); + return str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r") ? `"${str.replace(/"/g, '""')}"` : str; + }; + const lines = [headers.join(",")]; + for (const row of rows) { + lines.push(headers.map((h) => escape(row[h])).join(",")); + } + return lines.join("\r\n"); + } + function disambiguateArrayFilename(base, used) { + if (!used.has(base)) return base; + const suffix = "_data.csv"; + const root = base.endsWith(suffix) ? base.slice(0, -suffix.length) : base.replace(/\.csv$/i, ""); + let n = 2; + let candidate = `${root}${n}${suffix}`; + while (used.has(candidate)) { + n += 1; + candidate = `${root}${n}${suffix}`; + } + return candidate; + } + var isUnnamedHeader = (key) => key.trim() === ""; + function hasUnnamedColumns(rows) { + return rows.some((row) => Object.keys(row).some(isUnnamedHeader)); + } + function stripUnnamedColumns(rows) { + const unnamed = /* @__PURE__ */ new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (isUnnamedHeader(key)) unnamed.add(key); + } + } + if (unnamed.size > 0) { + for (const row of rows) { + for (const key of unnamed) delete row[key]; + } + } + return { rows, dropped: [...unnamed] }; + } + function buildPsychDSDataFiles(args) { + const { + base, + mainRows, + mainContent, + extractedArrays = /* @__PURE__ */ new Map(), + extractedObjects = /* @__PURE__ */ new Map(), + joinKeys = ["trial_index"], + usedArrayFilenames = /* @__PURE__ */ new Set() + } = args; + const out = []; + const reserve = (name) => { + if (!isValidPsychDSDataFilename(name)) { + throw new Error(`Refusing to write non-Psych-DS-compliant data filename "${name}".`); + } + usedArrayFilenames.add(name); + return name; + }; + const mainName = reserve(disambiguateArrayFilename(`${base}_data.csv`, usedArrayFilenames)); + const { rows: cleanedMainRows, dropped: droppedMain } = stripUnnamedColumns(mainRows); + out.push({ + filename: mainName, + content: mainContent !== void 0 && droppedMain.length === 0 ? mainContent : objectsToCSV(cleanedMainRows, ["trial_index"]), + kind: "main" + }); + const arrayPriority = [...joinKeys, "element_index"]; + for (const [colName, rows] of extractedArrays) { + const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames)); + out.push({ filename: name, content: objectsToCSV(rows, arrayPriority), kind: "array" }); + } + for (const [colName, rows] of extractedObjects) { + const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames)); + out.push({ filename: name, content: objectsToCSV(rows, joinKeys), kind: "object" }); + } + return out; + } + async function parseCSV(input) { + if (!parse) { + throw new Error("Parser module not loaded"); + } + return new Promise((resolve, reject) => { + parse(input, { + columns: true, + // Treat the first row as headers + delimiter: ",", + // Specify the delimiter (e.g., comma) + bom: true + // Strip a leading UTF-8 BOM so the first header name isn't corrupted (e.g. "Participant_ID") + }, (err, records) => { + if (err) { + reject(err); + } else { + resolve(records); + } + }); + }); + } + + // src/VariablesMap.ts + var VariablesMap = class _VariablesMap { + /** + * Creates the VariablesMap by initialising an empty variable map. The jsPsych system + * variables (trial_type, trial_index, time_elapsed, extension_*) are NOT seeded here — they + * are registered lazily when their column is actually observed in the data (see + * {@link registerSystemVariable}). Seeding them unconditionally produced orphan + * variableMeasured entries (e.g. time_elapsed) for datasets that omit those columns, which + * fails Psych-DS validation (VARIABLE_MISSING_FROM_CSV_COLUMNS). + * + * @constructor + */ + constructor() { + this.generateDefaultVariables(); + } + /** + * The fixed jsPsych definition for a system column, or null if `name` is not a known system + * variable. Returns a fresh object on each call so callers never share/mutate one template. + */ + static systemVariableTemplate(name) { + switch (name) { + case "trial_type": + return { + "@type": "PropertyValue", + name: "trial_type", + description: { default: "unknown", jsPsych: "The name of the plugin used to run the trial." }, + value: "string" + }; + case "trial_index": + return { + "@type": "PropertyValue", + name: "trial_index", + description: { default: "unknown", jsPsych: "The index of the current trial across the whole experiment." }, + value: "number" + }; + case "time_elapsed": + return { + "@type": "PropertyValue", + name: "time_elapsed", + description: { + default: "unknown", + jsPsych: "The number of milliseconds between the start of the experiment and when the trial ended." + }, + value: "number" + }; + case "extension_type": + return { + "@type": "PropertyValue", + name: "extension_type", + description: { default: "unknown", jsPsych: "The name(s) of the extension(s) used in the trial." }, + value: "string" + }; + case "extension_version": + return { + "@type": "PropertyValue", + name: "extension_version", + description: { default: "unknown", jsPsych: "The version(s) of the extension(s) used in the trial." }, + value: "number" + }; + default: + return null; + } + } + /** + * Lazily registers the default jsPsych definition for a system column the first time it is + * observed in the data. No-op (returns false) when `name` is not a known system variable or + * is already present; returns true when a new variable was registered. This is what keeps a + * system variable out of variableMeasured unless the data actually contains that column. + * + * @param {string} name - The column / system-variable name. + * @returns {boolean} - True if a variable was registered, false otherwise. + */ + registerSystemVariable(name) { + if (this.containsVariable(name)) return false; + const template = _VariablesMap.systemVariableTemplate(name); + if (!template) return false; + this.setVariable(template); + return true; + } + /** + * Initialises the variable map. System variables are registered lazily (see the constructor + * and {@link registerSystemVariable}), so this just resets the map to empty. + */ + generateDefaultVariables() { + this.variables = {}; + } + /** + * Returns a list of the variables instead of an object according to the Psych-DS format. + * + * @returns {{}[]} - The list of variables represented as objects. + */ + getList() { + var var_list = []; + for (const key of Object.keys(this.variables)) { + const variable = this.variables[key]; + variable["description"] = this.collapseDescription(variable["description"]); + var_list.push(variable); + } + return var_list; + } + /** + * Collapses an internal { pluginType: description } map into a single schema.org-valid + * Text value. Descriptions are stored per-plugin and only ever hold multiple keys when the + * texts genuinely differ (identical texts are merged upstream in updateDescription). Psych-DS / + * schema.org require `description` to be Text, so an object value triggers an OBJECT_TYPE_MISSING + * validator warning — this folds everything down to a string. + * + * @private + * @param {*} description - The description value (a { pluginType: text } map, or already a string). + * @returns {string} - A single Text description. + */ + collapseDescription(description) { + if (typeof description !== "object" || description === null) { + return description; + } + if (Object.keys(description).length === 0) { + console.error("Empty description"); + return "unknown"; + } + if (Object.keys(description).length > 1 && "default" in description) { + delete description["default"]; + } + for (const descKey of Object.keys(description)) { + if (description[descKey] === "unknown" && Object.keys(description).length > 1) { + delete description[descKey]; + } + } + return Object.values(description).join(" | "); + } + /** + * Allows user to set a variable and includes all the fields that are possible according to + * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable. + * Can also be used to overwrite existing variables if they have the same name. + * + * @param {VariableFields} variable - The fields of the variable that is being created. + */ + setVariable(variable) { + if (!variable.name) { + console.warn("Name field is missing. Variable not added.", variable); + return; + } + this.variables[variable.name] = variable; + const unexpectedFields = Object.keys(variable).filter( + (key) => ![ + "@type", + "name", + "description", + "value", + "identifier", + "minValue", + "maxValue", + "levels", + "levelsOrdered", + "na", + "naValue", + "alternateName", + "privacy" + ].includes(key) + ); + if (unexpectedFields.length > 0) { + console.warn( + `Unexpected fields (${unexpectedFields.join( + ", " + )}) detected and included in the variable object.` + ); + } + } + /** + * Allows you to get information for a single variable returning empty dict if it doesn't exist. + * Allows you to update fields but not recommended in favor of updateVariable. + * + * @param {string} name + * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist + */ + getVariable(name) { + return this.variables[name] || {}; + } + /** + * Checks if variable exists in VariablesMap. + * + * @param {string} name - Name of variable + * @returns {boolean} - True if exists, false if doesn't. + */ + containsVariable(name) { + return name in this.variables; + } + /** + * Method that gets a list of the names of variables. + * + * @returns {string[]} - String list containing names of existing variables. + */ + getVariableNames() { + var var_list = []; + for (const key of Object.keys(this.variables)) { + var_list.push(this.variables[key]["name"]); + } + return var_list; + } + /** + * Allows you to update a variable or add a value in the case of updating values. In other situations will + * replace the existing value with the new value. Has special cases and logic for levels and names making it + * easier to update variable values. + * + * + * @param {string} var_name - Name of variable to be updated. + * @param {string} field_name - Specific field to be updated. + * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType. + */ + updateVariable(var_name, field_name, added_value) { + const updated_var = this.getVariable(var_name); + if (Object.keys(updated_var).length === 0) { + console.error(`Variable "${var_name}" does not exist.`); + return; + } + if (field_name === "levels") { + this.updateLevels(updated_var, added_value); + } else if (field_name === "minValue" || field_name === "maxValue") { + this.updateMinMax(updated_var, added_value, field_name); + } else if (field_name === "description") { + this.updateDescription(updated_var, added_value); + } else if (field_name === "name") { + this.updateName(updated_var, added_value); + } else { + updated_var[field_name] = added_value; + } + } + /** + * Logic that handles updates to levels field by creating new array if necessary, otherwise + * pushing the value if it doesn't already exist. Levels can only be added to with strings. + * + * @private + * @param {*} updated_var - The variable object to be updated. + * @param {*} added_value - The value being added to the levels field. + */ + updateLevels(updated_var, added_value) { + if (typeof added_value === "object") + return; + const MAX_LENGTH = 50; + if (added_value.length > MAX_LENGTH) { + added_value = added_value.substring(0, MAX_LENGTH) + "..."; + } + if (!Array.isArray(updated_var["levels"])) { + updated_var["levels"] = []; + } + if (!updated_var["levels"].includes(added_value)) { + updated_var["levels"].push(added_value); + } + } + /** + * Logic to update the min and max for the specific value. + * + * @private + * @param {*} updated_var - The variable object to be updated. + * @param {*} added_value - The value that is being checked against current min/max. + * @param {*} field_name - The name of field that is being checked (min or max). + */ + updateMinMax(updated_var, added_value, field_name) { + if (!("minValue" in updated_var) || !("maxValue" in updated_var)) { + updated_var["maxValue"] = updated_var["minValue"] = added_value; + return; + } + if (field_name === "minValue" && updated_var["minValue"] > added_value) { + updated_var["minValue"] = added_value; + } else if (field_name === "maxValue" && updated_var["maxValue"] < added_value) { + updated_var["maxValue"] = added_value; + } + } + /** + * Logic for updating description field that checks to see value already exists. If it does, + * appends the pluginType to the current key and pushes that along with the value. Creates + * map if it does not exist. + * + * @private + * @param {*} updated_var - The variable to be updated. + * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field. + */ + updateDescription(updated_var, added_value) { + const add_key = Object.keys(added_value)[0]; + const add_value = Object.values(added_value)[0]; + if (add_key === "undefined" || add_value === "undefined") { + console.error("New value is passed in bad format", added_value); + return; + } + var exists = false; + if (typeof updated_var["description"] !== "object") { + const existing = updated_var["description"]; + updated_var["description"] = typeof existing === "string" && existing && existing !== "unknown" ? { default: existing } : {}; + } + Object.entries(updated_var["description"]).forEach(([key, value]) => { + if (value === add_value) { + if (!key.includes(add_key)) { + delete updated_var["description"][key]; + updated_var["description"][key + ", " + add_key] = add_value; + } + exists = true; + } + }); + if (!exists) Object.assign(updated_var["description"], added_value); + } + /** + * Logic for updating name. Needs to retain all the old values while creating a new reference in the map + * while keeping the same perspe + * + * @private + * @param {*} updated_var + * @param {*} added_value + */ + updateName(updated_var, added_value) { + const old_name = updated_var["name"]; + updated_var["name"] = added_value; + delete this.variables[old_name]; + this.setVariable(updated_var); + } + /** + * Allows you to delete a variable by key/name. Returns console error if not found. + * + * @param {string} var_name - Name of variable to be deleted. + */ + deleteVariable(var_name) { + if (var_name in this.variables) { + delete this.variables[var_name]; + } else { + console.error(`Variable "${var_name}" does not exist.`); + } + } + }; + + // src/index.ts + var JsPsychMetadata = class { + /** + * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context + * allowing it to access the screen printing information. + * + * @constructor + * @param {JsPsych} JsPsych + */ + constructor(verbose) { + /** + * Initializes a set that contains the variable fields that are to be ignored, so can help with later + * logic when generating data. + * + * @private + * @type {*} + */ + this.ignored_variables = new Set(SYSTEM_COLUMNS); + /** + * Verbose mode that is used in by the tools that call this to print fetching messages and + * reading messages. + * + * @private + * @type {boolean} + */ + this.verbose = false; + this.extractedArrays = /* @__PURE__ */ new Map(); + // Plain (non-array) object columns expanded by expandObjectFields. One row per trial, + // keyed by the same arrayJoinKeys as extractedArrays, with a column for every dotted + // descendant variable (leaf scalars, intermediate object nodes, and nested-array parents). + // The CLI writes these as separate Psych-DS CSVs so those dotted names map to real columns. + this.extractedObjects = /* @__PURE__ */ new Map(); + this.arrayJoinKeys = ["trial_index"]; + this.mixedColumns = /* @__PURE__ */ new Set(); + this.metadata = {}; + this.setMetadataField("name", "title"); + this.setMetadataField("schemaVersion", "Psych-DS 0.4.0"); + this.setMetadataField("@context", "https://schema.org"); + this.setMetadataField("@type", "Dataset"); + this.setMetadataField("description", "Dataset generated using JsPsych"); + this.authors = new AuthorsMap(); + this.variables = new VariablesMap(); + this.pluginCache = new PluginCache(); + this.verbose = verbose; + } + /** + * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields. + * + * @param {string} key - Metadata field name + * @param {*} value - Data associated with the field + */ + setMetadataField(key, value) { + this.metadata[key] = value; + } + /** + * Simple get that accesses the data associated with a field. + * + * @param {string} key - Field name + * @returns {*} - Data associated with the field + */ + getMetadataField(key) { + return this.metadata[key]; + } + /** + * Checks if the metadata field exists in the metadata. + * + * @param {string} key - Key of metadata being checked. + * @returns {*} - Boolean + */ + containsMetadataField(key) { + return key in this.metadata; + } + /** + * Deletes a metadata from the metadata if it exists. + * + * @param {string} key - Name of field to be deleted + */ + deleteMetadataField(key) { + if (key in this.metadata) { + delete this.metadata[key]; + } else { + console.error(`Metadata "${key}" does not exist.`); + } + } + /** + * Returns the final Metadata in a single javascript object. Bundles together the author and variables + * together in a list rather than object compliant with Psych-DS standards. Seems that javascript get + * are implictly called. + * + * @returns {{}} - Final Metadata object + */ + getMetadata() { + const res = this.metadata; + res["author"] = this.authors.getList(); + res["variableMeasured"] = this.variables.getList(); + return res; + } + getUserMetadataFields() { + const res = {}; + const ignored_fields = /* @__PURE__ */ new Set(["schemaVersion", "@type", "@context", "author", "variableMeasured"]); + for (const key in this.metadata) { + if (!ignored_fields.has(key)) { + res[key] = this.metadata[key]; + } + } + return res; + } + /** + * Returns the variable fields while excluding the authors and variables.` + * + * @returns {{}} - Final Metadata object + */ + getMetadataFields() { + const res = this.metadata; + delete res["author"]; + delete res["variableMeasured"]; + return res; + } + /** + * Method that creates an author. This method can also be used to overwrite existing authors + * with the same name in order to update fields. + * + * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name. + */ + setAuthor(fields) { + this.authors.setAuthor(fields); + } + /** + * Method that fetches an author object allowing user to update (in existing workflow should not be necessary). + * + * @param {string} name - Name of author to be used as key. + * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found. + */ + getAuthor(name) { + return this.authors.getAuthor(name); + } + /** + * Returns a list of the authors defined in the metadata. + * + * @returns {(string | AuthorFields)[]} - Authors + */ + getAuthorList() { + return this.authors.getList(); + } + /** + * Deletes an author from the authorsField. + * + * @param {string} name - Name of author to be deleted. + */ + deleteAuthor(name) { + this.authors.deleteAuthor(name); + } + /** + * Method that creates a variable. This method can also be used to overwrite variables with the same name + * as a way to update fields. + * + * @param {{ + * @type?: string; + * name: string; // required + * description?: string | {}; + * value?: string; // string, boolean, or number + * identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description + * minValue?: number; + * maxValue?: number; + * levels?: string[] | []; // technically property values in the other one but not sure how to format it + * levelsOrdered?: boolean; + * na?: boolean; + * naValue?: string; + * alternateName?: string; + * privacy?: string; + * }} fields - Fields associated with the current Psych-DS standard. + */ + setVariable(variable) { + this.variables.setVariable(variable); + } + /** + * Allows you to access a variable's information by using the name of the variable. Can + * be used to update fields within a variable, but suggest using updateVariable() to prevent errors. + * + * @param {string} name - Name of variable to be accessed + * @returns {{}} - Returns object of fields + */ + getVariable(name) { + return this.variables.getVariable(name); + } + /** + * Returns a list of the variables defined in the metadata. + * + * @returns {{}[]} - Authors + */ + getVariableList() { + return this.variables.getList(); + } + /** + * Allows you to check if the name of the variable exists in variablesMap. + * + * @param {string} name - Name of variable + * @returns {boolean} - Does variable exist in variables + */ + containsVariable(name) { + return this.variables.containsVariable(name); + } + /** + * Allows you to update a variable or add a value in the case of updating values. In other situations will + * replace the existing value with the new value. + * + * @param {string} var_name - Name of variable to be updated. + * @param {string} field_name - Name of field to be updated. + * @param {(string | boolean | number | {})} added_value - Value to be used in the update. + */ + updateVariable(var_name, field_name, added_value) { + this.variables.updateVariable(var_name, field_name, added_value); + } + /** + * Allows you to delete a variable by key/name. + * + * @param {string} var_name - Name of variable to be deleted. + */ + deleteVariable(var_name) { + this.variables.deleteVariable(var_name); + } + /** + * Gets a list of all the variable names. + * + * @returns {string[]} - List of variable string names. + */ + getVariableNames() { + return this.variables.getVariableNames(); + } + /** + * Returns accumulated array-column data keyed by column name. + * Each entry is a list of rows with join key columns, element_index, and the element's own fields. + * Used by the CLI to write Psych-DS compliant separate CSV files. + */ + getExtractedArrays() { + return this.extractedArrays; + } + /** + * Returns accumulated plain-object-column data keyed by the top-level column name. + * Each entry is one row per trial: the join key columns plus a column for every dotted + * descendant variable expanded from that object (matching the names in variableMeasured). + * Used by the CLI to write a separate Psych-DS CSV per object column, so those dotted + * sub-variables resolve to real columns. No element_index (one row per trial, not per element). + */ + getExtractedObjects() { + return this.extractedObjects; + } + /** + * Returns the join key columns used in the most recent generate() call. + * The CLI uses this to order columns correctly in extracted array CSVs. + */ + getArrayJoinKeys() { + return [...this.arrayJoinKeys]; + } + warnJoinKeyUniqueness(analysis) { + const keyStr = this.arrayJoinKeys.join(", "); + const exampleStr = analysis.duplicateValues.slice(0, 3).map((v) => Object.entries(v).map(([k, val]) => `${k}=${val}`).join(", ")).join("; "); + let msg = `[jspsych-metadata] Join key (${keyStr}) is not unique in this dataset + (${analysis.duplicateCount} duplicate rows; e.g. ${exampleStr}) +`; + if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length === 0) { + const sufficient = analysis.candidates.filter((c) => c.makesUnique).map((c) => c.column); + const example = JSON.stringify([sufficient[0], ...this.arrayJoinKeys]); + msg += ` Sufficient fix: add one of these columns to arrayJoinKeys: + ${sufficient.join(", ")} + Pass { arrayJoinKeys: ${example} } as the options argument to generate().`; + } else if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length > 0) { + const combined = JSON.stringify([...analysis.suggestedAdditionalKeys, ...this.arrayJoinKeys]); + msg += ` No single column makes rows unique. Suggested combination: + ${analysis.suggestedAdditionalKeys.join(" + ")} + Pass { arrayJoinKeys: ${combined} } as the options argument to generate().`; + } else { + msg += ` No combination of available columns was found to make rows unique. + Your data may contain genuinely duplicate rows. + Extracted array CSVs will have non-unique join keys.`; + } + console.warn(msg); + } + /** + * Method that allows you to display metadata at the end of an experiment. + * + * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling. + */ + displayMetadata(display_element) { + const elementId = "jspsych-metadata-display"; + const metadata_string = JSON.stringify(this.getMetadata(), null, 2); + display_element.innerHTML += `

Metadata

`;
+      document.getElementById(elementId).textContent += metadata_string;
+    }
+    /**
+     * Method that begins a download for the dataset_description.json at the end of experiment.
+     * Allows you to download the metadat.
+     */
+    localSave() {
+      let data_string = JSON.stringify(this.getMetadata());
+      saveTextToFile(data_string, "dataset_description.json");
+    }
+    /**
+     * This method loads the metadata into the metadata object. This takes in the"dataset_description.json" string content 
+     * and first parses it as an object. This then loads in all the fields, authors and variables into the metadata object by calling all the 
+     * relevant methods that overwrites the default data.
+     *
+     * @param {string} stringMetadata - String version of the metadata to be loaded from "dataset_description.json".
+     */
+    loadMetadata(stringMetadata) {
+      const meta = JSON.parse(stringMetadata);
+      for (const field_key in meta) {
+        if (field_key === "variableMeasured") {
+          for (const variable of meta[field_key]) {
+            this.setVariable(variable);
+          }
+        } else if (field_key === "author") {
+          for (const author of meta[field_key]) {
+            this.setAuthor(author);
+          }
+        } else {
+          this.setMetadataField(field_key, meta[field_key]);
+        }
+      }
+    }
+    /**
+     * Generates observations based on the input data and processes optional metadata. This is the
+     * outer wrapper function that should called and handles the logic of reading individual observations.
+     *
+     * This method accepts data as a JSON string, a CSV string, or an already-parsed array of
+     * observation objects. A string is parsed according to `ext`; an array is consumed as-is.
+     * Each observation is processed asynchronously via `generateObservation`. Optionally, metadata
+     * options can be provided as an object, and each key-value pair is processed by `processMetadata`.
+     *
+     * NOTE: when `data` is a pre-parsed array it is consumed in place and MUTATED — unnamed
+     * (blank-header) columns are deleted from the row objects. Callers that need the rows to stay
+     * pristine must pass a copy. This lets a caller parse a file once and share the rows with
+     * generate() instead of having generate() re-parse the same content.
+     *
+     * @async
+     * @param {Array|String} data - Observations to generate from: a pre-parsed array (consumed as-is and mutated in place), a JSON string, or a CSV string.
+     * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
+     * @param {'json'|'csv'} [ext='json'] - Format of a string `data`; ignored when `data` is already an array.
+     * @param {Object} [options={}] - arrayJoinKeys / suppressJoinKeyWarning, plus synthesizedSourceRecordId for pre-parsed callers that tagged a synthetic source_record_id themselves.
+     */
+    async generate(data, metadata = {}, ext = "json", options = {}) {
+      this.extractedArrays = /* @__PURE__ */ new Map();
+      this.extractedObjects = /* @__PURE__ */ new Map();
+      this.arrayJoinKeys = options.arrayJoinKeys ?? ["trial_index"];
+      var parsed_data;
+      let synthesizedSourceRecordId = options.synthesizedSourceRecordId ?? false;
+      if (Array.isArray(data)) {
+        parsed_data = data;
+      } else if (ext === "csv") {
+        parsed_data = await parseCSV(data);
+      } else if (ext === "json") {
+        const parseStats = {};
+        parsed_data = parseJsonData(data, { tagSourceRecordId: true }, parseStats);
+        synthesizedSourceRecordId = parseStats.synthesizedSourceRecordId === true;
+      }
+      if (!Array.isArray(parsed_data)) {
+        throw new Error("Parsed data is not in correct format: Expected an array of observations");
+      }
+      const { dropped } = stripUnnamedColumns(parsed_data);
+      if (dropped.length > 0) {
+        console.warn(
+          `Dropped ${dropped.length} unnamed column${dropped.length > 1 ? "s" : ""} from the data \u2014 Psych-DS requires every column to have a name (usually a row-index column added by R's write.csv). Excluded from variableMeasured.`
+        );
+      }
+      const rows = parsed_data;
+      const hasColumn = (col) => ext === "json" && rows.some((row) => row && typeof row === "object" && col in row);
+      const idColumn = hasColumn("source_record_id") ? "source_record_id" : hasColumn("participant_id") ? "participant_id" : void 0;
+      if (idColumn && !this.arrayJoinKeys.includes(idColumn)) {
+        this.arrayJoinKeys = [idColumn, ...this.arrayJoinKeys];
+      }
+      const analysis = analyzeJoinKeys(parsed_data, this.arrayJoinKeys);
+      if (!analysis.isUnique && !options.suppressJoinKeyWarning) this.warnJoinKeyUniqueness(analysis);
+      for (const observation of parsed_data) {
+        await this.generateObservation(observation);
+      }
+      if (synthesizedSourceRecordId && this.containsVariable("source_record_id")) {
+        const existing = this.getVariable("source_record_id");
+        this.setVariable({
+          ...existing,
+          description: { default: "Synthetic source-record identifier (0-based), assigned one per source record (one JSON-Lines line, which is usually but not always one participant) because the raw data carried no identifier column. NOT a real subject ID from the experiment \u2014 it only orders/links records as they appeared in the source file, and serves as a join key connecting each trial to its extracted array/object rows." }
+        });
+      }
+      await this.updateMetadata(metadata);
+    }
+    /**
+     * This function iterates through the entire row of data stepping through one column at a time.
+     * It is designed to only be accessed through calling generate on an entire data file. 
+     * Searching for plugin, plugin version, extension, extension it then calls the 
+     * helper methods that process the individual row of data. There is limited error chcking and 
+     * type conversion from csv due to the way that csv data is represented as strings.
+     * This method also handles extensions, declaring them if necessary and iterate through each.
+     * This method also skips generating descriptions the variables that should the same for 
+     * all variables and instead updates their fields. 
+     *
+     * @private
+     * @async
+     * @param {*} observation Dictionary that represent one row of data
+     * @returns {*}
+     */
+    async generateObservation(observation) {
+      const version2 = observation["plugin_version"] ? observation["plugin_version"] : null;
+      const pluginType = observation["trial_type"];
+      const extensionType = observation["extension_type"];
+      const extensionVersion = observation["extension_version"];
+      const joinValues = this.arrayJoinKeys.reduce((acc, k) => {
+        acc[k] = observation[k];
+        return acc;
+      }, {});
+      for (const variable in observation) {
+        var value = observation[variable];
+        var type = typeof value;
+        if (!this.containsVariable(variable)) {
+          if (this.ignored_variables.has(variable)) {
+            this.variables.registerSystemVariable(variable);
+          } else {
+            this.setVariable({
+              "@type": "PropertyValue",
+              name: variable,
+              description: { default: "unknown" },
+              value: "unknown"
+            });
+          }
+        }
+        if (value === null || value === void 0 || value === "" || value === "null") {
+          continue;
+        }
+        if (type === "string") {
+          const asNumber = Number(value);
+          if (value.trim() !== "" && Number.isFinite(asNumber)) {
+            type = "number";
+            value = asNumber;
+          } else if (value.startsWith("{") || value.startsWith("[")) {
+            const parsed = tryParseJSON(value);
+            if (parsed !== null) {
+              value = parsed;
+              type = Array.isArray(parsed) ? "array" : "object";
+            }
+          }
+        }
+        if (this.ignored_variables.has(variable)) {
+          this.updateFields(variable, value, type);
+        } else {
+          if (type === "object" && value !== null && !Array.isArray(value)) {
+            const objectRow = { ...joinValues };
+            await this.expandObjectFields(variable, value, pluginType, version2, joinValues, objectRow);
+            const existingObjects = this.extractedObjects.get(variable) ?? [];
+            existingObjects.push(objectRow);
+            this.extractedObjects.set(variable, existingObjects);
+          } else if (type === "array" || type === "object" && Array.isArray(value)) {
+            await this.generateMetadata(variable, value, pluginType, version2);
+            const existingVar = this.containsVariable(variable) ? this.getVariable(variable) : null;
+            const existingType = existingVar?.value;
+            if (existingType !== "string" && existingType !== "number" && existingType !== "boolean") {
+              this.updateVariable(variable, "value", "array");
+            }
+            await this.accumulateArrayColumn(variable, value, joinValues, pluginType, version2);
+          } else {
+            await this.generateMetadata(variable, value, pluginType, version2);
+          }
+          if (extensionType) {
+            await Promise.all(
+              extensionType.map(async (ext, index) => {
+                if (ext && extensionVersion[index])
+                  await this.generateMetadata(variable, value, ext, extensionVersion[index], true);
+              })
+            );
+          }
+        }
+      }
+    }
+    /**
+     * Iterates through one single datapoint which can be thought of as one row-column pair. 
+     * This method keeps in mind the versionType or pluginType and uses this to generate the 
+     * metadata. 
+     *
+     * @private
+     * @async
+     * @param {*} variable - The column name
+     * @param {*} value - The value at the row-column mapping that is being used to update fields
+     * @param {*} pluginType - The type of the plugin that is used for the fetching (can also be extension if extension?=true)
+     * @param {*} version - The version of the plugin that is not necessary but is used post v8 to ensure accurate fetching
+     * @param {?*} [extension] - This boolean determines whether is a extension to change fetching
+     * @returns {*}
+     */
+    async generateMetadata(variable, value, pluginType, version2, extension) {
+      const type = typeof value;
+      if (!this.containsVariable(variable)) {
+        const new_var = {
+          "@type": "PropertyValue",
+          name: variable,
+          description: { default: "unknown" },
+          value: type
+        };
+        this.setVariable(new_var);
+      } else {
+        const existing = this.getVariable(variable);
+        if (existing.value === "unknown") this.updateVariable(variable, "value", type);
+      }
+      if (pluginType) {
+        const pluginInfo = await this.getPluginInfo(pluginType, variable, version2, extension);
+        const description = pluginInfo["description"];
+        const new_description = description ? { [pluginType]: description } : { [pluginType]: "unknown" };
+        this.updateVariable(variable, "description", new_description);
+      }
+      this.updateFields(variable, value, type);
+    }
+    /**
+     * This calls an update to the individual fields of the metadata, updating levels and 
+     * minValue and maxValue depeneding on the variable type.
+     *
+     * @private
+     * @param {*} variable - The column of the data and name of variable
+     * @param {*} value - The datapoint 
+     * @param {*} type - The type of the datapoint
+     */
+    updateFields(variable, value, type) {
+      if (type === "boolean") return;
+      const existing = this.getVariable(variable);
+      if (type === "number") {
+        if (Array.isArray(existing.levels)) {
+          if (!this.mixedColumns.has(variable)) {
+            this.mixedColumns.add(variable);
+            console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+          }
+          this.updateVariable(variable, "levels", String(value));
+          return;
+        }
+        this.updateVariable(variable, "minValue", value);
+        this.updateVariable(variable, "maxValue", value);
+        return;
+      }
+      if (type !== "object") {
+        if ("minValue" in existing || "maxValue" in existing) {
+          if (!this.mixedColumns.has(variable)) {
+            this.mixedColumns.add(variable);
+            console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+          }
+          if ("minValue" in existing) this.updateVariable(variable, "levels", String(existing.minValue));
+          if ("maxValue" in existing && existing.maxValue !== existing.minValue) {
+            this.updateVariable(variable, "levels", String(existing.maxValue));
+          }
+          delete existing.minValue;
+          delete existing.maxValue;
+          this.updateVariable(variable, "value", "string");
+        }
+        if (existing.value === "boolean" && (value === "true" || value === "false")) {
+          return;
+        }
+        this.updateVariable(variable, "levels", value);
+      }
+    }
+    /**
+     * Iterates through the entire metadata options object by calling processMetadata() to act upon each of the 
+     * individual fields at one time. 
+     *
+     * @async
+     * @param {*} metadata - Metadata options that contains all the metadata according to Psych-DS formatting. 
+     */
+    async updateMetadata(metadata) {
+      for (const key in metadata) {
+        await this.processMetadata(metadata, key);
+      }
+    }
+    /**
+     * This is the method that processes each individual element of the metadata options to be updated. This can be called through generate or outside of it, 
+     * and this processes each element. 
+     *
+     * @private
+     * @param {*} metadata - An object that contains all of the metadata. This is used to access the value. 
+     * @param {*} key - String key that denotes what key-value mapping is being iterated upon. 
+     */
+    processMetadata(metadata, key) {
+      const value = metadata[key];
+      if (key === "variables") {
+        if (typeof value !== "object" || value === null) {
+          console.warn("Variable object is either null or incorrect type");
+          return;
+        }
+        for (let variable_key in value) {
+          if (!this.containsVariable(variable_key)) {
+            console.warn("Metadata does not contain variable:", variable_key);
+            continue;
+          }
+          const variable_parameters = value[variable_key];
+          if (typeof variable_parameters !== "object" || variable_parameters === null) {
+            console.warn(
+              "Parameters of variable:",
+              variable_key,
+              "is either null or incorrect type. The value",
+              variable_parameters,
+              "is either null or not an object."
+            );
+            continue;
+          }
+          for (const parameter in variable_parameters) {
+            const parameter_value = variable_parameters[parameter];
+            this.updateVariable(variable_key, parameter, parameter_value);
+            if (parameter === "value" && parameter_value === "boolean") {
+              this.applyBooleanOverride(variable_key);
+            }
+            if (parameter === "name") variable_key = parameter_value;
+          }
+        }
+      } else if (key === "author") {
+        if (typeof value !== "object" || value === null) {
+          console.warn("Author object is not correct type");
+          return;
+        }
+        for (const author_key in value) {
+          const author = value[author_key];
+          if (typeof author !== "string" && !("name" in author)) author["name"] = author_key;
+          this.setAuthor(author);
+        }
+      } else this.setMetadataField(key, value);
+    }
+    /**
+     * Applies a user-chosen `value:"boolean"` override to an already-populated variable.
+     * Warns when the values detected from the data don't map cleanly to boolean logic
+     * (anything other than true/false/0/1, case-insensitive), then drops the detected
+     * levels/min/max so the variable matches how genuine booleans are recorded (no levels).
+     */
+    applyBooleanOverride(variableName) {
+      const existing = this.getVariable(variableName);
+      const isBooleanLike = (v) => {
+        const s = String(v).trim().toLowerCase();
+        return s === "true" || s === "false" || s === "0" || s === "1";
+      };
+      const offenders = /* @__PURE__ */ new Set();
+      if (Array.isArray(existing.levels)) {
+        for (const level of existing.levels) if (!isBooleanLike(level)) offenders.add(String(level));
+      }
+      if (typeof existing.minValue === "number" && !isBooleanLike(existing.minValue)) offenders.add(String(existing.minValue));
+      if (typeof existing.maxValue === "number" && !isBooleanLike(existing.maxValue)) offenders.add(String(existing.maxValue));
+      if (offenders.size > 0) {
+        const sample = [...offenders].slice(0, 10).join(", ");
+        const more = offenders.size > 10 ? `, \u2026(+${offenders.size - 10} more)` : "";
+        console.warn(
+          `Variable "${variableName}" was set to value:"boolean", but the detected values don't map cleanly to true/false: ${sample}${more}. Double-check this is the intended type.`
+        );
+      }
+      delete existing.levels;
+      delete existing.minValue;
+      delete existing.maxValue;
+    }
+    /**
+     * Registers the keys of a plain JSON object as dotted sub-variables
+     * (e.g. response.Q0, response.Q1) and registers the parent with value: "object".
+     *
+     * Recurses into nested plain objects so structures more than one level deep are
+     * fully expanded (e.g. response.address.city). Nested arrays are registered with
+     * value: "array" (typeof [] === "object", so the inferred type must be overridden)
+     * and, when they hold objects, extracted into a separate CSV keyed by their dotted
+     * column name — mirroring how top-level array columns are handled.
+     *
+     * @param joinValues - The current row's join key values, prepended to every
+     *   extracted nested-array row so the sub-table can be rejoined to the main data.
+     */
+    async expandObjectFields(parentName, obj, pluginType, version2, joinValues, row) {
+      await this.generateMetadata(parentName, obj, pluginType, version2);
+      for (const key of Object.keys(obj)) {
+        const childName = `${parentName}.${key}`;
+        const childValue = obj[key];
+        if (row) row[childName] = childValue;
+        if (childValue !== null && typeof childValue === "object" && !Array.isArray(childValue)) {
+          await this.expandObjectFields(childName, childValue, pluginType, version2, joinValues, row);
+        } else if (Array.isArray(childValue)) {
+          await this.generateMetadata(childName, childValue, pluginType, version2);
+          this.updateVariable(childName, "value", "array");
+          await this.accumulateArrayColumn(childName, childValue, joinValues, pluginType, version2);
+        } else {
+          await this.generateMetadata(childName, childValue, pluginType, version2);
+        }
+      }
+    }
+    /**
+     * Accumulates the object elements of an array column into `extractedArrays` for
+     * separate Psych-DS CSV output, keyed by the column's (possibly dotted) name.
+     * Each emitted row is the join key values, an `element_index`, then the element's
+     * fields under DOTTED names (`columnName.field`) so they don't collide with top-level
+     * columns or with fields of other array columns. Every emitted column is registered in
+     * variableMeasured so the sidecar CSV has no columns missing from the metadata.
+     *
+     * Element fields recurse (see expandElementFields): a nested plain object is expanded
+     * into deeper dotted columns in the SAME row; a nested array is extracted into its own
+     * grandchild CSV, joinable via `${columnName}.element_index` (this element's position)
+     * carried alongside the existing join keys.
+     *
+     * Null / primitive top-level array elements are skipped; arrays with no object elements
+     * produce no rows.
+     */
+    async accumulateArrayColumn(columnName, arr, joinValues, pluginType, version2) {
+      const elements = [];
+      arr.forEach((element, index) => {
+        if (element !== null && element !== void 0) elements.push({ element, index });
+      });
+      if (elements.length === 0) return;
+      if (!this.containsVariable("element_index")) {
+        this.setVariable({
+          "@type": "PropertyValue",
+          name: "element_index",
+          description: { default: "Position of this element within its source array column (0-based)." },
+          value: "number"
+        });
+      }
+      for (const joinKey of Object.keys(joinValues)) {
+        if (!this.containsVariable(joinKey)) {
+          this.setVariable({
+            "@type": "PropertyValue",
+            name: joinKey,
+            description: { default: "Join key referencing the position of an enclosing array element (0-based index)." },
+            value: "number"
+          });
+        }
+      }
+      const existing = this.extractedArrays.get(columnName) ?? [];
+      for (const { element, index } of elements) {
+        const row = { ...joinValues, element_index: index };
+        const nestedJoin = { ...joinValues, [`${columnName}.element_index`]: index };
+        if (typeof element === "object" && !Array.isArray(element)) {
+          await this.expandElementFields(columnName, element, row, nestedJoin, pluginType, version2);
+        } else {
+          const valueName = `${columnName}.value`;
+          row[valueName] = element;
+          if (Array.isArray(element)) {
+            await this.registerNodeVariable(valueName, element, "array", pluginType, version2);
+            await this.accumulateArrayColumn(valueName, element, nestedJoin, pluginType, version2);
+          } else {
+            await this.registerScalarField(valueName, element, pluginType, version2);
+          }
+        }
+        existing.push(row);
+      }
+      this.extractedArrays.set(columnName, existing);
+    }
+    /**
+     * Recursively records one array element's fields into `row` under dotted names. Scalars become
+     * columns with type + min/max/levels tracking; nested plain objects are expanded into the SAME
+     * row (deeper dotted columns); nested arrays are extracted into their own grandchild CSV via
+     * accumulateArrayColumn (keyed by `nestedJoin`). Object/array nodes are also kept as a single
+     * dotted JSON column so their own name is represented as a column too.
+     */
+    async expandElementFields(prefix, obj, row, nestedJoin, pluginType, version2) {
+      for (const key of Object.keys(obj)) {
+        const name = `${prefix}.${key}`;
+        const value = obj[key];
+        row[name] = value;
+        if (value !== null && typeof value === "object" && !Array.isArray(value)) {
+          await this.registerNodeVariable(name, value, "object", pluginType, version2);
+          await this.expandElementFields(name, value, row, nestedJoin, pluginType, version2);
+        } else if (Array.isArray(value)) {
+          await this.registerNodeVariable(name, value, "array", pluginType, version2);
+          await this.accumulateArrayColumn(name, value, nestedJoin, pluginType, version2);
+        } else {
+          await this.registerScalarField(name, value, pluginType, version2);
+        }
+      }
+    }
+    /** Registers an object/array node variable once (with its plugin description, if any). */
+    async registerNodeVariable(name, value, type, pluginType, version2) {
+      if (this.containsVariable(name) && this.getVariable(name).value !== "unknown") return;
+      await this.generateMetadata(name, value, pluginType, version2);
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+      } else {
+        this.updateVariable(name, "value", type);
+      }
+    }
+    /**
+     * Registers one scalar array-element field under its dotted name (so the sidecar column is
+     * represented in variableMeasured), then folds later values into min/max/levels. Empty values
+     * still declare the column (placeholder) without polluting min/max/levels.
+     */
+    async registerScalarField(name, value, pluginType, version2) {
+      if (value === null || value === void 0 || value === "" || value === "null") {
+        if (!this.containsVariable(name)) {
+          this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: "unknown" });
+        }
+        return;
+      }
+      const type = typeof value;
+      const needsRegister = !this.containsVariable(name) || this.getVariable(name).value === "unknown";
+      if (needsRegister) {
+        await this.generateMetadata(name, value, pluginType, version2);
+        if (!this.containsVariable(name)) {
+          this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+          this.updateFields(name, value, type);
+        }
+      } else {
+        this.updateFields(name, value, type);
+      }
+    }
+    /**
+     * Gets the description of a variable in a plugin by fetching the source code of the plugin
+     * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+     * to extract the description for the variable (present as JSDoc); caches the result for future use.
+     *
+     * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+     * @param {string} variableName - The name of the variable for which information is to be fetched.
+     * @param {string} version - The version of the plugin or extension
+     * @param {string} extension - Boolean indicating if pluginType refers to extension
+     * @returns {Promise} The description of the plugin variable if found, otherwise null.
+     * @throws Will throw an error if the fetch operation fails.
+     */
+    async getPluginInfo(pluginType, variableName, version2, extension) {
+      return this.pluginCache.getPluginInfo(pluginType, variableName, version2, this.verbose, extension);
+    }
+  };
+})();
diff --git a/functions/metadata/dist/index.browser.min.js b/functions/metadata/dist/index.browser.min.js
new file mode 100644
index 0000000..c9e2174
--- /dev/null
+++ b/functions/metadata/dist/index.browser.min.js
@@ -0,0 +1,25 @@
+(()=>{var ge=class{constructor(){this.authors={}}getList(){let e=[];for(let t of Object.keys(this.authors))e.push(this.authors[t]);return e}setAuthor(e){if(typeof e=="string"){this.authors[e]=e;return}if(!e.name){console.warn("Name field is missing. Author not added.");return}let{name:t,...n}=e;if(Object.keys(n).length==0)this.authors[t]=t;else{let i={name:t,...n};this.authors[t]=i;let s=Object.keys(e).filter(o=>!["@type","name","givenName","familyName","identifier"].includes(o));s.length>0&&console.warn(`Unexpected fields (${s.join(", ")}) detected and included in the author object.`)}}getAuthor(e){return e in this.authors?this.authors[e]:(console.warn("Author (",e,") not found."),{})}deleteAuthor(e){e in this.authors?delete this.authors[e]:console.error(`Author "${e}" does not exist.`)}};var me=class{constructor(){this.pluginFields={}}async getPluginInfo(e,t,n,i,s){if(!(e in this.pluginFields)){let o=await this.generatePluginFields(e,n,i,s);this.pluginFields[e]=o}return t in this.pluginFields[e]?this.pluginFields[e][t]:{description:"unknown",type:"unknown"}}async generatePluginFields(e,t,n,i){let s=await this.fetchScript(e,t,n,i);if(s!=null&&s!=="")try{return this.parseJavadocString(s)}catch(o){return console.warn("* Error parsing",e,o),{}}else return{}}generateUnpkg(e,t,n){return n?t?`https://unpkg.com/@jspsych/extension-${e}@${t}/src/index.ts`:`https://unpkg.com/@jspsych/extension-${e}/src/index.ts`:t?`https://unpkg.com/@jspsych/plugin-${e}@${t}/src/index.ts`:`https://unpkg.com/@jspsych/plugin-${e}/src/index.ts`}async fetchScript(e,t,n,i){let s=this.generateUnpkg(e,t,i);n&&console.log("-> fetching information for [",e,"] from ->",s);try{let o=await fetch(s);if(!o.ok){console.warn(`Plugin source not found for: ${e} (HTTP ${o.status}). Descriptions will default to "unknown".`);return}return await o.text()}catch(o){console.error("Plugin fetching failed for:",e,"with error",o,"Note: if you are using a plugin not supported the main JsPsych branch this will always fail.");return}}extractDataBlock(e){let t=e.search(/\bdata:\s*\{/);if(t===-1)return null;let n=e.indexOf("{",t);if(n===-1)return null;let i=this.findMatchingBrace(e,n);return i===-1?null:e.substring(n+1,i)}parseJavadocString(e){let t=this.extractDataBlock(e);return t?this.extractJsdocFields(t):{}}extractJsdocFields(e){let t={},n=/\/\*\*\s*([\s\S]*?)\s*\*\/\s*(\w+):\s*\{/g,i=/(\w+):\s*([^,\s{}]+)/g,s;for(;(s=n.exec(e))!==null;){let o=s[1].replace(/^[ \t]*\*[ \t]?/gm,"").trim().replace(/\s+/g," "),a=s[2],l=s.index+s[0].length-1,u=this.findMatchingBrace(e,l);if(u===-1)continue;n.lastIndex=u+1;let c=e.substring(l+1,u),h={},d;for(i.lastIndex=0;(d=i.exec(c))!==null;)h[d[1]]=d[2];t[a]={description:o,...h};let m=/\bnested:\s*\{/.exec(c);if(m){let S=c.indexOf("{",m.index),p=this.findMatchingBrace(c,S);p!==-1&&Object.assign(t,this.extractJsdocFields(c.substring(S+1,p)))}}return t}findMatchingBrace(e,t){let n=0;for(let i=t;i0)throw new Error("Invalid string. Length must be a multiple of 4");s=r[a-2]==="="?2:r[a-1]==="="?1:0,o=new pr(a*3/4-s),n=s>0?a-4:a;var l=0;for(e=0,t=0;e>16&255,o[l++]=i>>8&255,o[l++]=i&255;return s===2?(i=k[r.charCodeAt(e)]<<2|k[r.charCodeAt(e+1)]>>4,o[l++]=i&255):s===1&&(i=k[r.charCodeAt(e)]<<10|k[r.charCodeAt(e+1)]<<4|k[r.charCodeAt(e+2)]>>2,o[l++]=i>>8&255,o[l++]=i&255),o}function mr(r){return B[r>>18&63]+B[r>>12&63]+B[r>>6&63]+B[r&63]}function _r(r,e,t){for(var n,i=[],s=e;sl?l:a+o));return n===1?(e=r[t-1],i+=B[e>>2],i+=B[e<<4&63],i+="=="):n===2&&(e=(r[t-2]<<8)+r[t-1],i+=B[e>>10],i+=B[e>>4&63],i+=B[e<<2&63],i+="="),s.push(i),s.join("")}function Ae(r,e,t,n,i){var s,o,a=i*8-n-1,l=(1<>1,c=-7,h=t?i-1:0,d=t?-1:1,m=r[e+h];for(h+=d,s=m&(1<<-c)-1,m>>=-c,c+=a;c>0;s=s*256+r[e+h],h+=d,c-=8);for(o=s&(1<<-c)-1,s>>=-c,c+=n;c>0;o=o*256+r[e+h],h+=d,c-=8);if(s===0)s=1-u;else{if(s===l)return o?NaN:(m?-1:1)*(1/0);o=o+Math.pow(2,n),s=s-u}return(m?-1:1)*o*Math.pow(2,s-n)}function At(r,e,t,n,i,s){var o,a,l,u=s*8-i-1,c=(1<>1,d=i===23?Math.pow(2,-24)-Math.pow(2,-77):0,m=n?0:s-1,S=n?1:-1,p=e<0||e===0&&1/e<0?1:0;for(e=Math.abs(e),isNaN(e)||e===1/0?(a=isNaN(e)?1:0,o=c):(o=Math.floor(Math.log(e)/Math.LN2),e*(l=Math.pow(2,-o))<1&&(o--,l*=2),o+h>=1?e+=d/l:e+=d*Math.pow(2,1-h),e*l>=2&&(o++,l/=2),o+h>=c?(a=0,o=c):o+h>=1?(a=(e*l-1)*Math.pow(2,i),o=o+h):(a=e*Math.pow(2,h-1)*Math.pow(2,i),o=0));i>=8;r[t+m]=a&255,m+=S,a/=256,i-=8);for(o=o<0;r[t+m]=o&255,m+=S,o/=256,u-=8);r[t+m-S]|=p*128}var yr={}.toString,It=Array.isArray||function(r){return yr.call(r)=="[object Array]"},wr=50;f.TYPED_ARRAY_SUPPORT=oe.TYPED_ARRAY_SUPPORT!==void 0?oe.TYPED_ARRAY_SUPPORT:!0;xe();function xe(){return f.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function $(r,e){if(xe()=xe())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+xe().toString(16)+" bytes");return r|0}f.isBuffer=M;function P(r){return!!(r!=null&&r._isBuffer)}f.compare=function(e,t){if(!P(e)||!P(t))throw new TypeError("Arguments must be Buffers");if(e===t)return 0;for(var n=e.length,i=t.length,s=0,o=Math.min(n,i);s>>1;case"base64":return Tt(r).length;default:if(n)return Se(r).length;e=(""+e).toLowerCase(),n=!0}}f.byteLength=Ct;function Er(r,e,t){var n=!1;if((e===void 0||e<0)&&(e=0),e>this.length||((t===void 0||t>this.length)&&(t=this.length),t<=0)||(t>>>=0,e>>>=0,t<=e))return"";for(r||(r="utf8");;)switch(r){case"hex":return Lr(this,e,t);case"utf8":case"utf-8":return Vt(this,e,t);case"ascii":return Mr(this,e,t);case"latin1":case"binary":return Vr(this,e,t);case"base64":return Cr(this,e,t);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return Dr(this,e,t);default:if(n)throw new TypeError("Unknown encoding: "+r);r=(r+"").toLowerCase(),n=!0}}f.prototype._isBuffer=!0;function X(r,e,t){var n=r[e];r[e]=r[t],r[t]=n}f.prototype.swap16=function(){var e=this.length;if(e%2!==0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(var t=0;t0&&(e=this.toString("hex",0,t).match(/.{2}/g).join(" "),this.length>t&&(e+=" ... ")),""};f.prototype.compare=function(e,t,n,i,s){if(!P(e))throw new TypeError("Argument must be a Buffer");if(t===void 0&&(t=0),n===void 0&&(n=e?e.length:0),i===void 0&&(i=0),s===void 0&&(s=this.length),t<0||n>e.length||i<0||s>this.length)throw new RangeError("out of range index");if(i>=s&&t>=n)return 0;if(i>=s)return-1;if(t>=n)return 1;if(t>>>=0,n>>>=0,i>>>=0,s>>>=0,this===e)return 0;for(var o=s-i,a=n-t,l=Math.min(o,a),u=this.slice(i,s),c=e.slice(t,n),h=0;h2147483647?t=2147483647:t<-2147483648&&(t=-2147483648),t=+t,isNaN(t)&&(t=i?0:r.length-1),t<0&&(t=r.length+t),t>=r.length){if(i)return-1;t=r.length-1}else if(t<0)if(i)t=0;else return-1;if(typeof e=="string"&&(e=f.from(e,n)),P(e))return e.length===0?-1:dt(r,e,t,n,i);if(typeof e=="number")return e=e&255,f.TYPED_ARRAY_SUPPORT&&typeof Uint8Array.prototype.indexOf=="function"?i?Uint8Array.prototype.indexOf.call(r,e,t):Uint8Array.prototype.lastIndexOf.call(r,e,t):dt(r,[e],t,n,i);throw new TypeError("val must be string, number or Buffer")}function dt(r,e,t,n,i){var s=1,o=r.length,a=e.length;if(n!==void 0&&(n=String(n).toLowerCase(),n==="ucs2"||n==="ucs-2"||n==="utf16le"||n==="utf-16le")){if(r.length<2||e.length<2)return-1;s=2,o/=2,a/=2,t/=2}function l(m,S){return s===1?m[S]:m.readUInt16BE(S*s)}var u;if(i){var c=-1;for(u=t;uo&&(t=o-a),u=t;u>=0;u--){for(var h=!0,d=0;di&&(n=i)):n=i;var s=e.length;if(s%2!==0)throw new TypeError("Invalid hex string");n>s/2&&(n=s/2);for(var o=0;os)&&(n=s),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");i||(i="utf8");for(var o=!1;;)switch(i){case"hex":return Rr(this,e,t,n);case"utf8":case"utf-8":return Ar(this,e,t,n);case"ascii":return Mt(this,e,t,n);case"latin1":case"binary":return Ir(this,e,t,n);case"base64":return Or(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return Fr(this,e,t,n);default:if(o)throw new TypeError("Unknown encoding: "+i);i=(""+i).toLowerCase(),o=!0}};f.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};function Cr(r,e,t){return e===0&&t===r.length?ht(r):ht(r.slice(e,t))}function Vt(r,e,t){t=Math.min(r.length,t);for(var n=[],i=e;i239?4:s>223?3:s>191?2:1;if(i+a<=t){var l,u,c,h;switch(a){case 1:s<128&&(o=s);break;case 2:l=r[i+1],(l&192)===128&&(h=(s&31)<<6|l&63,h>127&&(o=h));break;case 3:l=r[i+1],u=r[i+2],(l&192)===128&&(u&192)===128&&(h=(s&15)<<12|(l&63)<<6|u&63,h>2047&&(h<55296||h>57343)&&(o=h));break;case 4:l=r[i+1],u=r[i+2],c=r[i+3],(l&192)===128&&(u&192)===128&&(c&192)===128&&(h=(s&15)<<18|(l&63)<<12|(u&63)<<6|c&63,h>65535&&h<1114112&&(o=h))}}o===null?(o=65533,a=1):o>65535&&(o-=65536,n.push(o>>>10&1023|55296),o=56320|o&1023),n.push(o),i+=a}return Nr(n)}var pt=4096;function Nr(r){var e=r.length;if(e<=pt)return String.fromCharCode.apply(String,r);for(var t="",n=0;nn)&&(t=n);for(var i="",s=e;sn&&(e=n),t<0?(t+=n,t<0&&(t=0)):t>n&&(t=n),tt)throw new RangeError("Trying to access beyond buffer length")}f.prototype.readUIntLE=function(e,t,n){e=e|0,t=t|0,n||O(e,t,this.length);for(var i=this[e],s=1,o=0;++o0&&(s*=256);)i+=this[e+--t]*s;return i};f.prototype.readUInt8=function(e,t){return t||O(e,1,this.length),this[e]};f.prototype.readUInt16LE=function(e,t){return t||O(e,2,this.length),this[e]|this[e+1]<<8};f.prototype.readUInt16BE=function(e,t){return t||O(e,2,this.length),this[e]<<8|this[e+1]};f.prototype.readUInt32LE=function(e,t){return t||O(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+this[e+3]*16777216};f.prototype.readUInt32BE=function(e,t){return t||O(e,4,this.length),this[e]*16777216+(this[e+1]<<16|this[e+2]<<8|this[e+3])};f.prototype.readIntLE=function(e,t,n){e=e|0,t=t|0,n||O(e,t,this.length);for(var i=this[e],s=1,o=0;++o=s&&(i-=Math.pow(2,8*t)),i};f.prototype.readIntBE=function(e,t,n){e=e|0,t=t|0,n||O(e,t,this.length);for(var i=t,s=1,o=this[e+--i];i>0&&(s*=256);)o+=this[e+--i]*s;return s*=128,o>=s&&(o-=Math.pow(2,8*t)),o};f.prototype.readInt8=function(e,t){return t||O(e,1,this.length),this[e]&128?(255-this[e]+1)*-1:this[e]};f.prototype.readInt16LE=function(e,t){t||O(e,2,this.length);var n=this[e]|this[e+1]<<8;return n&32768?n|4294901760:n};f.prototype.readInt16BE=function(e,t){t||O(e,2,this.length);var n=this[e+1]|this[e]<<8;return n&32768?n|4294901760:n};f.prototype.readInt32LE=function(e,t){return t||O(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24};f.prototype.readInt32BE=function(e,t){return t||O(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]};f.prototype.readFloatLE=function(e,t){return t||O(e,4,this.length),Ae(this,e,!0,23,4)};f.prototype.readFloatBE=function(e,t){return t||O(e,4,this.length),Ae(this,e,!1,23,4)};f.prototype.readDoubleLE=function(e,t){return t||O(e,8,this.length),Ae(this,e,!0,52,8)};f.prototype.readDoubleBE=function(e,t){return t||O(e,8,this.length),Ae(this,e,!1,52,8)};function V(r,e,t,n,i,s){if(!P(r))throw new TypeError('"buffer" argument must be a Buffer instance');if(e>i||er.length)throw new RangeError("Index out of range")}f.prototype.writeUIntLE=function(e,t,n,i){if(e=+e,t=t|0,n=n|0,!i){var s=Math.pow(2,8*n)-1;V(this,e,t,n,s,0)}var o=1,a=0;for(this[t]=e&255;++a=0&&(a*=256);)this[t+o]=e/a&255;return t+n};f.prototype.writeUInt8=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,1,255,0),f.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),this[t]=e&255,t+1};function Ie(r,e,t,n){e<0&&(e=65535+e+1);for(var i=0,s=Math.min(r.length-t,2);i>>(n?i:1-i)*8}f.prototype.writeUInt16LE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,2,65535,0),f.TYPED_ARRAY_SUPPORT?(this[t]=e&255,this[t+1]=e>>>8):Ie(this,e,t,!0),t+2};f.prototype.writeUInt16BE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,2,65535,0),f.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=e&255):Ie(this,e,t,!1),t+2};function Oe(r,e,t,n){e<0&&(e=4294967295+e+1);for(var i=0,s=Math.min(r.length-t,4);i>>(n?i:3-i)*8&255}f.prototype.writeUInt32LE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,4,4294967295,0),f.TYPED_ARRAY_SUPPORT?(this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=e&255):Oe(this,e,t,!0),t+4};f.prototype.writeUInt32BE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,4,4294967295,0),f.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=e&255):Oe(this,e,t,!1),t+4};f.prototype.writeIntLE=function(e,t,n,i){if(e=+e,t=t|0,!i){var s=Math.pow(2,8*n-1);V(this,e,t,n,s-1,-s)}var o=0,a=1,l=0;for(this[t]=e&255;++o>0)-l&255;return t+n};f.prototype.writeIntBE=function(e,t,n,i){if(e=+e,t=t|0,!i){var s=Math.pow(2,8*n-1);V(this,e,t,n,s-1,-s)}var o=n-1,a=1,l=0;for(this[t+o]=e&255;--o>=0&&(a*=256);)e<0&&l===0&&this[t+o+1]!==0&&(l=1),this[t+o]=(e/a>>0)-l&255;return t+n};f.prototype.writeInt8=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,1,127,-128),f.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),e<0&&(e=255+e+1),this[t]=e&255,t+1};f.prototype.writeInt16LE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,2,32767,-32768),f.TYPED_ARRAY_SUPPORT?(this[t]=e&255,this[t+1]=e>>>8):Ie(this,e,t,!0),t+2};f.prototype.writeInt16BE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,2,32767,-32768),f.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=e&255):Ie(this,e,t,!1),t+2};f.prototype.writeInt32LE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,4,2147483647,-2147483648),f.TYPED_ARRAY_SUPPORT?(this[t]=e&255,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24):Oe(this,e,t,!0),t+4};f.prototype.writeInt32BE=function(e,t,n){return e=+e,t=t|0,n||V(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),f.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=e&255):Oe(this,e,t,!1),t+4};function Lt(r,e,t,n,i,s){if(t+n>r.length)throw new RangeError("Index out of range");if(t<0)throw new RangeError("Index out of range")}function Dt(r,e,t,n,i){return i||Lt(r,e,t,4),At(r,e,t,n,23,4),t+4}f.prototype.writeFloatLE=function(e,t,n){return Dt(this,e,t,!0,n)};f.prototype.writeFloatBE=function(e,t,n){return Dt(this,e,t,!1,n)};function kt(r,e,t,n,i){return i||Lt(r,e,t,8),At(r,e,t,n,52,8),t+8}f.prototype.writeDoubleLE=function(e,t,n){return kt(this,e,t,!0,n)};f.prototype.writeDoubleBE=function(e,t,n){return kt(this,e,t,!1,n)};f.prototype.copy=function(e,t,n,i){if(n||(n=0),!i&&i!==0&&(i=this.length),t>=e.length&&(t=e.length),t||(t=0),i>0&&i=this.length)throw new RangeError("sourceStart out of bounds");if(i<0)throw new RangeError("sourceEnd out of bounds");i>this.length&&(i=this.length),e.length-t=0;--o)e[o+t]=this[o+n];else if(s<1e3||!f.TYPED_ARRAY_SUPPORT)for(o=0;o>>0,n=n===void 0?this.length:n>>>0,e||(e=0);var o;if(typeof e=="number")for(o=t;o55295&&t<57344){if(!i){if(t>56319){(e-=3)>-1&&s.push(239,191,189);continue}else if(o+1===n){(e-=3)>-1&&s.push(239,191,189);continue}i=t;continue}if(t<56320){(e-=3)>-1&&s.push(239,191,189),i=t;continue}t=(i-55296<<10|t-56320)+65536}else i&&(e-=3)>-1&&s.push(239,191,189);if(i=null,t<128){if((e-=1)<0)break;s.push(t)}else if(t<2048){if((e-=2)<0)break;s.push(t>>6|192,t&63|128)}else if(t<65536){if((e-=3)<0)break;s.push(t>>12|224,t>>6&63|128,t&63|128)}else if(t<1114112){if((e-=4)<0)break;s.push(t>>18|240,t>>12&63|128,t>>6&63|128,t&63|128)}else throw new Error("Invalid code point")}return s}function Pr(r){for(var e=[],t=0;t>8,i=t%256,s.push(i),s.push(n);return s}function Tt(r){return gr(Tr(r))}function Fe(r,e,t,n){for(var i=0;i=e.length||i>=r.length);++i)e[i+t]=r[i];return i}function $r(r){return r!==r}function M(r){return r!=null&&(!!r._isBuffer||jt(r)||Jr(r))}function jt(r){return!!r.constructor&&typeof r.constructor.isBuffer=="function"&&r.constructor.isBuffer(r)}function Jr(r){return typeof r.readFloatLE=="function"&&typeof r.slice=="function"&&jt(r.slice(0,0))}var qr;function H(){}H.prototype=Object.create(null);function w(){w.init.call(this)}w.EventEmitter=w;w.usingDomains=!1;w.prototype.domain=void 0;w.prototype._events=void 0;w.prototype._maxListeners=void 0;w.defaultMaxListeners=10;w.init=function(){this.domain=null,w.usingDomains&&qr.active,(!this._events||this._events===Object.getPrototypeOf(this)._events)&&(this._events=new H,this._eventsCount=0),this._maxListeners=this._maxListeners||void 0};w.prototype.setMaxListeners=function(e){if(typeof e!="number"||e<0||isNaN(e))throw new TypeError('"n" argument must be a positive number');return this._maxListeners=e,this};function Bt(r){return r._maxListeners===void 0?w.defaultMaxListeners:r._maxListeners}w.prototype.getMaxListeners=function(){return Bt(this)};function zr(r,e,t){if(e)r.call(t);else for(var n=r.length,i=fe(r,n),s=0;s0&&o.length>i)){o.warned=!0;var a=new Error("Possible EventEmitter memory leak detected. "+o.length+" "+e+" listeners added. Use emitter.setMaxListeners() to increase limit");a.name="MaxListenersExceededWarning",a.emitter=r,a.type=e,a.count=o.length,Qr(a)}return r}function Qr(r){typeof console.warn=="function"?console.warn(r):console.log(r)}w.prototype.addListener=function(e,t){return Pt(this,e,t,!1)};w.prototype.on=w.prototype.addListener;w.prototype.prependListener=function(e,t){return Pt(this,e,t,!0)};function Ut(r,e,t){var n=!1;function i(){r.removeListener(e,i),n||(n=!0,t.apply(r,arguments))}return i.listener=t,i}w.prototype.once=function(e,t){if(typeof t!="function")throw new TypeError('"listener" argument must be a function');return this.on(e,Ut(this,e,t)),this};w.prototype.prependOnceListener=function(e,t){if(typeof t!="function")throw new TypeError('"listener" argument must be a function');return this.prependListener(e,Ut(this,e,t)),this};w.prototype.removeListener=function(e,t){var n,i,s,o,a;if(typeof t!="function")throw new TypeError('"listener" argument must be a function');if(i=this._events,!i)return this;if(n=i[e],!n)return this;if(n===t||n.listener&&n.listener===t)--this._eventsCount===0?this._events=new H:(delete i[e],i.removeListener&&this.emit("removeListener",e,n.listener||t));else if(typeof n!="function"){for(s=-1,o=n.length;o-- >0;)if(n[o]===t||n[o].listener&&n[o].listener===t){a=n[o].listener,s=o;break}if(s<0)return this;if(n.length===1){if(n[0]=void 0,--this._eventsCount===0)return this._events=new H,this;delete i[e]}else Gr(n,s);i.removeListener&&this.emit("removeListener",e,a||t)}return this};w.prototype.removeAllListeners=function(e){var t,n;if(n=this._events,!n)return this;if(!n.removeListener)return arguments.length===0?(this._events=new H,this._eventsCount=0):n[e]&&(--this._eventsCount===0?this._events=new H:delete n[e]),this;if(arguments.length===0){for(var i=Object.keys(n),s=0,o;s0?Reflect.ownKeys(this._events):[]};function Gr(r,e){for(var t=e,n=t+1,i=r.length;n1)for(var t=1;t=i)return a;switch(a){case"%s":return String(n[t++]);case"%d":return Number(n[t++]);case"%j":try{return JSON.stringify(n[t++])}catch{return"[Circular]"}default:return a}}),o=n[t];t=3&&(t.depth=arguments[2]),arguments.length>=4&&(t.colors=arguments[3]),Ht(e)?t.showHidden=e:e&&Tn(t,e),W(t.showHidden)&&(t.showHidden=!1),W(t.depth)&&(t.depth=2),W(t.colors)&&(t.colors=!1),W(t.customInspect)&&(t.customInspect=!0),t.colors&&(t.stylize=Fn),Ee(t,r,t.depth)}Q.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]};Q.styles={special:"cyan",number:"yellow",boolean:"yellow",undefined:"grey",null:"bold",string:"green",date:"magenta",regexp:"red"};function Fn(r,e){var t=Q.styles[e];return t?"\x1B["+Q.colors[t][0]+"m"+r+"\x1B["+Q.colors[t][1]+"m":r}function Cn(r,e){return r}function Nn(r){var e={};return r.forEach(function(t,n){e[t]=!0}),e}function Ee(r,e,t){if(r.customInspect&&e&&Pe(e.inspect)&&e.inspect!==Q&&!(e.constructor&&e.constructor.prototype===e)){var n=e.inspect(t,r);return Ge(n)||(n=Ee(r,n,t)),n}var i=Mn(r,e);if(i)return i;var s=Object.keys(e),o=Nn(s);if(r.showHidden&&(s=Object.getOwnPropertyNames(e)),Be(e)&&(s.indexOf("message")>=0||s.indexOf("description")>=0))return Te(e);if(s.length===0){if(Pe(e)){var a=e.name?": "+e.name:"";return r.stylize("[Function"+a+"]","special")}if(je(e))return r.stylize(RegExp.prototype.toString.call(e),"regexp");if(gt(e))return r.stylize(Date.prototype.toString.call(e),"date");if(Be(e))return Te(e)}var l="",u=!1,c=["{","}"];if(Dn(e)&&(u=!0,c=["[","]"]),Pe(e)){var h=e.name?": "+e.name:"";l=" [Function"+h+"]"}if(je(e)&&(l=" "+RegExp.prototype.toString.call(e)),gt(e)&&(l=" "+Date.prototype.toUTCString.call(e)),Be(e)&&(l=" "+Te(e)),s.length===0&&(!u||e.length==0))return c[0]+l+c[1];if(t<0)return je(e)?r.stylize(RegExp.prototype.toString.call(e),"regexp"):r.stylize("[Object]","special");r.seen.push(e);var d;return u?d=Vn(r,e,t,o,s):d=s.map(function(m){return qe(r,e,t,o,m,u)}),r.seen.pop(),Ln(d,l,c)}function Mn(r,e){if(W(e))return r.stylize("undefined","undefined");if(Ge(e)){var t="'"+JSON.stringify(e).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return r.stylize(t,"string")}if(kn(e))return r.stylize(""+e,"number");if(Ht(e))return r.stylize(""+e,"boolean");if(Qe(e))return r.stylize("null","null")}function Te(r){return"["+Error.prototype.toString.call(r)+"]"}function Vn(r,e,t,n,i){for(var s=[],o=0,a=e.length;o-1&&(s?a=a.split(`
+`).map(function(u){return"  "+u}).join(`
+`).substr(2):a=`
+`+a.split(`
+`).map(function(u){return"   "+u}).join(`
+`))):a=r.stylize("[Circular]","special")),W(o)){if(s&&i.match(/^\d+$/))return a;o=JSON.stringify(""+i),o.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)?(o=o.substr(1,o.length-2),o=r.stylize(o,"name")):(o=o.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'"),o=r.stylize(o,"string"))}return o+": "+a}function Ln(r,e,t){var n=r.reduce(function(i,s){return s.indexOf(`
+`)>=0,i+s.replace(/\u001b\[\d\d?m/g,"").length+1},0);return n>60?t[0]+(e===""?"":e+`
+ `)+" "+r.join(`,
+  `)+" "+t[1]:t[0]+e+" "+r.join(", ")+" "+t[1]}function Dn(r){return Array.isArray(r)}function Ht(r){return typeof r=="boolean"}function Qe(r){return r===null}function kn(r){return typeof r=="number"}function Ge(r){return typeof r=="string"}function W(r){return r===void 0}function je(r){return ce(r)&&Ze(r)==="[object RegExp]"}function ce(r){return typeof r=="object"&&r!==null}function gt(r){return ce(r)&&Ze(r)==="[object Date]"}function Be(r){return ce(r)&&(Ze(r)==="[object Error]"||r instanceof Error)}function Pe(r){return typeof r=="function"}function Ze(r){return Object.prototype.toString.call(r)}function Tn(r,e){if(!e||!ce(e))return r;for(var t=Object.keys(e),n=t.length;n--;)r[t[n]]=e[t[n]];return r}function Qt(r,e){return Object.prototype.hasOwnProperty.call(r,e)}function re(){this.head=null,this.tail=null,this.length=0}re.prototype.push=function(r){var e={data:r,next:null};this.length>0?this.tail.next=e:this.head=e,this.tail=e,++this.length};re.prototype.unshift=function(r){var e={data:r,next:this.head};this.length===0&&(this.tail=e),this.head=e,++this.length};re.prototype.shift=function(){if(this.length!==0){var r=this.head.data;return this.length===1?this.head=this.tail=null:this.head=this.head.next,--this.length,r}};re.prototype.clear=function(){this.head=this.tail=null,this.length=0};re.prototype.join=function(r){if(this.length===0)return"";for(var e=this.head,t=""+e.data;e=e.next;)t+=r+e.data;return t};re.prototype.concat=function(r){if(this.length===0)return f.alloc(0);if(this.length===1)return this.head.data;for(var e=f.allocUnsafe(r>>>0),t=this.head,n=0;t;)t.data.copy(e,n),n+=t.data.length,t=t.next;return e};var jn=f.isEncoding||function(r){switch(r&&r.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function Bn(r){if(r&&!jn(r))throw new Error("Unknown encoding: "+r)}function he(r){switch(this.encoding=(r||"utf8").toLowerCase().replace(/[-_]/,""),Bn(r),this.encoding){case"utf8":this.surrogateSize=3;break;case"ucs2":case"utf16le":this.surrogateSize=2,this.detectIncompleteChar=Un;break;case"base64":this.surrogateSize=3,this.detectIncompleteChar=$n;break;default:this.write=Pn;return}this.charBuffer=new f(6),this.charReceived=0,this.charLength=0}he.prototype.write=function(r){for(var e="";this.charLength;){var t=r.length>=this.charLength-this.charReceived?this.charLength-this.charReceived:r.length;if(r.copy(this.charBuffer,this.charReceived,0,t),this.charReceived+=t,this.charReceived=55296&&i<=56319){this.charLength+=this.surrogateSize,e="";continue}if(this.charReceived=this.charLength=0,r.length===0)return e;break}this.detectIncompleteChar(r);var n=r.length;this.charLength&&(r.copy(this.charBuffer,0,r.length-this.charReceived,n),n-=this.charReceived),e+=r.toString(this.encoding,0,n);var n=e.length-1,i=e.charCodeAt(n);if(i>=55296&&i<=56319){var s=this.surrogateSize;return this.charLength+=s,this.charReceived+=s,this.charBuffer.copy(this.charBuffer,s,0,s),r.copy(this.charBuffer,0,0,s),e.substring(0,n)}return e};he.prototype.detectIncompleteChar=function(r){for(var e=r.length>=3?3:r.length;e>0;e--){var t=r[r.length-e];if(e==1&&t>>5==6){this.charLength=2;break}if(e<=2&&t>>4==14){this.charLength=3;break}if(e<=3&&t>>3==30){this.charLength=4;break}}this.charReceived=e};he.prototype.end=function(r){var e="";if(r&&r.length&&(e=this.write(r)),this.charReceived){var t=this.charReceived,n=this.charBuffer,i=this.encoding;e+=n.slice(0,t).toString(i)}return e};function Pn(r){return r.toString(this.encoding)}function Un(r){this.charReceived=r.length%2,this.charLength=this.charReceived?2:0}function $n(r){this.charReceived=r.length%3,this.charLength=this.charReceived?3:0}A.ReadableState=Gt;var R=On("stream");ae(A,w);function Jn(r,e,t){if(typeof r.prependListener=="function")return r.prependListener(e,t);!r._events||!r._events[e]?r.on(e,t):Array.isArray(r._events[e])?r._events[e].unshift(t):r._events[e]=[t,r._events[e]]}function qn(r,e){return r.listeners(e).length}function Gt(r,e){r=r||{},this.objectMode=!!r.objectMode,e instanceof T&&(this.objectMode=this.objectMode||!!r.readableObjectMode);var t=r.highWaterMark,n=this.objectMode?16:16*1024;this.highWaterMark=t||t===0?t:n,this.highWaterMark=~~this.highWaterMark,this.buffer=new re,this.length=0,this.pipes=null,this.pipesCount=0,this.flowing=null,this.ended=!1,this.endEmitted=!1,this.reading=!1,this.sync=!0,this.needReadable=!1,this.emittedReadable=!1,this.readableListening=!1,this.resumeScheduled=!1,this.defaultEncoding=r.defaultEncoding||"utf8",this.ranOut=!1,this.awaitDrain=0,this.readingMore=!1,this.decoder=null,this.encoding=null,r.encoding&&(this.decoder=new he(r.encoding),this.encoding=r.encoding)}function A(r){if(!(this instanceof A))return new A(r);this._readableState=new Gt(r,this),this.readable=!0,r&&typeof r.read=="function"&&(this._read=r.read),w.call(this)}A.prototype.push=function(r,e){var t=this._readableState;return!t.objectMode&&typeof r=="string"&&(e=e||t.defaultEncoding,e!==t.encoding&&(r=f.from(r,e),e="")),Zt(this,t,r,e,!1)};A.prototype.unshift=function(r){var e=this._readableState;return Zt(this,e,r,"",!0)};A.prototype.isPaused=function(){return this._readableState.flowing===!1};function Zt(r,e,t,n,i){var s=Kn(e,t);if(s)r.emit("error",s);else if(t===null)e.reading=!1,Wn(r,e);else if(e.objectMode||t&&t.length>0)if(e.ended&&!i){var o=new Error("stream.push() after EOF");r.emit("error",o)}else if(e.endEmitted&&i){var a=new Error("stream.unshift() after end event");r.emit("error",a)}else{var l;e.decoder&&!i&&!n&&(t=e.decoder.write(t),l=!e.objectMode&&t.length===0),i||(e.reading=!1),l||(e.flowing&&e.length===0&&!e.sync?(r.emit("data",t),r.read(0)):(e.length+=e.objectMode?1:t.length,i?e.buffer.unshift(t):e.buffer.push(t),e.needReadable&&Ce(r))),Hn(r,e)}else i||(e.reading=!1);return zn(e)}function zn(r){return!r.ended&&(r.needReadable||r.length=mt?r=mt:(r--,r|=r>>>1,r|=r>>>2,r|=r>>>4,r|=r>>>8,r|=r>>>16,r++),r}function _t(r,e){return r<=0||e.length===0&&e.ended?0:e.objectMode?1:r!==r?e.flowing&&e.length?e.buffer.head.data.length:e.length:(r>e.highWaterMark&&(e.highWaterMark=Yn(r)),r<=e.length?r:e.ended?e.length:(e.needReadable=!0,0))}A.prototype.read=function(r){R("read",r),r=parseInt(r,10);var e=this._readableState,t=r;if(r!==0&&(e.emittedReadable=!1),r===0&&e.needReadable&&(e.length>=e.highWaterMark||e.ended))return R("read: emitReadable",e.length,e.ended),e.length===0&&e.ended?Ue(this):Ce(this),null;if(r=_t(r,e),r===0&&e.ended)return e.length===0&&Ue(this),null;var n=e.needReadable;R("need readable",n),(e.length===0||e.length-r0?i=Xt(r,e):i=null,i===null?(e.needReadable=!0,r=0):e.length-=r,e.length===0&&(e.ended||(e.needReadable=!0),t!==r&&e.ended&&Ue(this)),i!==null&&this.emit("data",i),i};function Kn(r,e){var t=null;return!M(e)&&typeof e!="string"&&e!==null&&e!==void 0&&!r.objectMode&&(t=new TypeError("Invalid non-string/buffer chunk")),t}function Wn(r,e){if(!e.ended){if(e.decoder){var t=e.decoder.end();t&&t.length&&(e.buffer.push(t),e.length+=e.objectMode?1:t.length)}e.ended=!0,Ce(r)}}function Ce(r){var e=r._readableState;e.needReadable=!1,e.emittedReadable||(R("emitReadable",e.flowing),e.emittedReadable=!0,e.sync?D(yt,r):yt(r))}function yt(r){R("emit readable"),r.emit("readable"),Xe(r)}function Hn(r,e){e.readingMore||(e.readingMore=!0,D(Qn,r,e))}function Qn(r,e){for(var t=e.length;!e.reading&&!e.flowing&&!e.ended&&e.length1&&er(n.pipes,r)!==-1)&&!u&&(R("false write response, pause",t._readableState.awaitDrain),t._readableState.awaitDrain++,h=!0),t.pause())}function m(v){R("onerror",v),y(),r.removeListener("error",m),qn(r,"error")===0&&r.emit("error",v)}Jn(r,"error",m);function S(){r.removeListener("finish",p),y()}r.once("close",S);function p(){R("onfinish"),r.removeListener("close",S),y()}r.once("finish",p);function y(){R("unpipe"),t.unpipe(r)}return r.emit("pipe",t),n.flowing||(R("pipe resume"),t.resume()),r};function Gn(r){return function(){var e=r._readableState;R("pipeOnDrain",e.awaitDrain),e.awaitDrain&&e.awaitDrain--,e.awaitDrain===0&&r.listeners("data").length&&(e.flowing=!0,Xe(r))}}A.prototype.unpipe=function(r){var e=this._readableState;if(e.pipesCount===0)return this;if(e.pipesCount===1)return r&&r!==e.pipes?this:(r||(r=e.pipes),e.pipes=null,e.pipesCount=0,e.flowing=!1,r&&r.emit("unpipe",this),this);if(!r){var t=e.pipes,n=e.pipesCount;e.pipes=null,e.pipesCount=0,e.flowing=!1;for(var i=0;i=e.length?(e.decoder?t=e.buffer.join(""):e.buffer.length===1?t=e.buffer.head.data:t=e.buffer.concat(e.length),e.buffer.clear()):t=ti(r,e.buffer,e.decoder),t}function ti(r,e,t){var n;return rs.length?s.length:r;if(o===s.length?i+=s:i+=s.slice(0,r),r-=o,r===0){o===s.length?(++n,t.next?e.head=t.next:e.head=e.tail=null):(e.head=t,t.data=s.slice(o));break}++n}return e.length-=n,i}function ni(r,e){var t=f.allocUnsafe(r),n=e.head,i=1;for(n.data.copy(t),r-=n.data.length;n=n.next;){var s=n.data,o=r>s.length?s.length:r;if(s.copy(t,t.length-r,0,o),r-=o,r===0){o===s.length?(++i,n.next?e.head=n.next:e.head=e.tail=null):(e.head=n,n.data=s.slice(o));break}++i}return e.length-=i,t}function Ue(r){var e=r._readableState;if(e.length>0)throw new Error('"endReadable()" called on non-empty stream');e.endEmitted||(e.ended=!0,D(ii,e,r))}function ii(r,e){!r.endEmitted&&r.length===0&&(r.endEmitted=!0,e.readable=!1,e.emit("end"))}function si(r,e){for(var t=0,n=r.length;t-1))throw new TypeError("Unknown encoding: "+e);return this._writableState.defaultEncoding=e,this};function fi(r,e,t){return!r.objectMode&&r.decodeStrings!==!1&&typeof e=="string"&&(e=f.from(e,t)),e}function ci(r,e,t,n,i){t=fi(e,t,n),f.isBuffer(t)&&(n="buffer");var s=e.objectMode?1:t.length;e.length+=s;var o=e.length=this.size&&(this.resize(),t>=this.size))throw Error("INVALID_BUFFER_STATE");let n=this.buf;this.buf=f.allocUnsafe(this.size),e.copy(this.buf,0),n.copy(this.buf,e.length),this.length+=e.length}else{let t=this.length++;t===this.size&&this.resize();let n=this.clone();this.buf[0]=e,n.copy(this.buf,1,0,t)}}append(e){let t=this.length++;t===this.size&&this.resize(),this.buf[t]=e}clone(){return f.from(this.buf.slice(0,this.length))}resize(){let e=this.length;this.size=this.size*2;let t=f.allocUnsafe(this.size);this.buf.copy(t,0,0,e),this.buf=t}toString(e){return e?this.buf.slice(0,this.length).toString(e):Uint8Array.prototype.slice.call(this.buf.slice(0,this.length))}toJSON(){return this.toString("utf8")}reset(){this.length=0}},vi=12,xi=13,Si=10,Ei=32,Ri=9,Ai=function(r){return{bomSkipped:!1,bufBytesStart:0,castField:r.cast_function,commenting:!1,error:void 0,enabled:r.from_line===1,escaping:!1,escapeIsQuote:M(r.escape)&&M(r.quote)&&f.compare(r.escape,r.quote)===0,expectedRecordLength:Array.isArray(r.columns)?r.columns.length:void 0,field:new Re(20),firstLineToHeaders:r.cast_first_line_to_header,needMoreDataSize:Math.max(r.comment!==null?r.comment.length:0,...r.delimiter.map(e=>e.length),r.quote!==null?r.quote.length:0),previousBuf:void 0,quoting:!1,stop:!1,rawBuffer:new Re(100),record:[],recordHasError:!1,record_length:0,recordDelimiterMaxLength:r.record_delimiter.length===0?0:Math.max(...r.record_delimiter.map(e=>e.length)),trimChars:[f.from(" ",r.encoding)[0],f.from("	",r.encoding)[0]],wasQuoting:!1,wasRowDelimiter:!1,timchars:[f.from(f.from([xi],"utf8").toString(),r.encoding),f.from(f.from([Si],"utf8").toString(),r.encoding),f.from(f.from([vi],"utf8").toString(),r.encoding),f.from(f.from([Ei],"utf8").toString(),r.encoding),f.from(f.from([Ri],"utf8").toString(),r.encoding)]}},Ii=function(r){return r.replace(/([A-Z])/g,function(e,t){return"_"+t.toLowerCase()})},St=function(r){let e={};for(let n in r)e[Ii(n)]=r[n];if(e.encoding===void 0||e.encoding===!0)e.encoding="utf8";else if(e.encoding===null||e.encoding===!1)e.encoding=null;else if(typeof e.encoding!="string"&&e.encoding!==null)throw new x("CSV_INVALID_OPTION_ENCODING",["Invalid option encoding:","encoding must be a string or null to return a buffer,",`got ${JSON.stringify(e.encoding)}`],e);if(e.bom===void 0||e.bom===null||e.bom===!1)e.bom=!1;else if(e.bom!==!0)throw new x("CSV_INVALID_OPTION_BOM",["Invalid option bom:","bom must be true,",`got ${JSON.stringify(e.bom)}`],e);if(e.cast_function=null,e.cast===void 0||e.cast===null||e.cast===!1||e.cast==="")e.cast=void 0;else if(typeof e.cast=="function")e.cast_function=e.cast,e.cast=!0;else if(e.cast!==!0)throw new x("CSV_INVALID_OPTION_CAST",["Invalid option cast:","cast must be true or a function,",`got ${JSON.stringify(e.cast)}`],e);if(e.cast_date===void 0||e.cast_date===null||e.cast_date===!1||e.cast_date==="")e.cast_date=!1;else if(e.cast_date===!0)e.cast_date=function(n){let i=Date.parse(n);return isNaN(i)?n:new Date(i)};else if(typeof e.cast_date!="function")throw new x("CSV_INVALID_OPTION_CAST_DATE",["Invalid option cast_date:","cast_date must be true or a function,",`got ${JSON.stringify(e.cast_date)}`],e);if(e.cast_first_line_to_header=null,e.columns===!0)e.cast_first_line_to_header=void 0;else if(typeof e.columns=="function")e.cast_first_line_to_header=e.columns,e.columns=!0;else if(Array.isArray(e.columns))e.columns=or(e.columns);else if(e.columns===void 0||e.columns===null||e.columns===!1)e.columns=!1;else throw new x("CSV_INVALID_OPTION_COLUMNS",["Invalid option columns:","expect an array, a function or true,",`got ${JSON.stringify(e.columns)}`],e);if(e.group_columns_by_name===void 0||e.group_columns_by_name===null||e.group_columns_by_name===!1)e.group_columns_by_name=!1;else{if(e.group_columns_by_name!==!0)throw new x("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME",["Invalid option group_columns_by_name:","expect an boolean,",`got ${JSON.stringify(e.group_columns_by_name)}`],e);if(e.columns===!1)throw new x("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME",["Invalid option group_columns_by_name:","the `columns` mode must be activated."],e)}if(e.comment===void 0||e.comment===null||e.comment===!1||e.comment==="")e.comment=null;else if(typeof e.comment=="string"&&(e.comment=f.from(e.comment,e.encoding)),!M(e.comment))throw new x("CSV_INVALID_OPTION_COMMENT",["Invalid option comment:","comment must be a buffer or a string,",`got ${JSON.stringify(e.comment)}`],e);if(e.comment_no_infix===void 0||e.comment_no_infix===null||e.comment_no_infix===!1)e.comment_no_infix=!1;else if(e.comment_no_infix!==!0)throw new x("CSV_INVALID_OPTION_COMMENT",["Invalid option comment_no_infix:","value must be a boolean,",`got ${JSON.stringify(e.comment_no_infix)}`],e);let t=JSON.stringify(e.delimiter);if(Array.isArray(e.delimiter)||(e.delimiter=[e.delimiter]),e.delimiter.length===0)throw new x("CSV_INVALID_OPTION_DELIMITER",["Invalid option delimiter:","delimiter must be a non empty string or buffer or array of string|buffer,",`got ${t}`],e);if(e.delimiter=e.delimiter.map(function(n){if(n==null||n===!1)return f.from(",",e.encoding);if(typeof n=="string"&&(n=f.from(n,e.encoding)),!M(n)||n.length===0)throw new x("CSV_INVALID_OPTION_DELIMITER",["Invalid option delimiter:","delimiter must be a non empty string or buffer or array of string|buffer,",`got ${t}`],e);return n}),e.escape===void 0||e.escape===!0?e.escape=f.from('"',e.encoding):typeof e.escape=="string"?e.escape=f.from(e.escape,e.encoding):(e.escape===null||e.escape===!1)&&(e.escape=null),e.escape!==null&&!M(e.escape))throw new Error(`Invalid Option: escape must be a buffer, a string or a boolean, got ${JSON.stringify(e.escape)}`);if(e.from===void 0||e.from===null)e.from=1;else if(typeof e.from=="string"&&/\d+/.test(e.from)&&(e.from=parseInt(e.from)),Number.isInteger(e.from)){if(e.from<0)throw new Error(`Invalid Option: from must be a positive integer, got ${JSON.stringify(r.from)}`)}else throw new Error(`Invalid Option: from must be an integer, got ${JSON.stringify(e.from)}`);if(e.from_line===void 0||e.from_line===null)e.from_line=1;else if(typeof e.from_line=="string"&&/\d+/.test(e.from_line)&&(e.from_line=parseInt(e.from_line)),Number.isInteger(e.from_line)){if(e.from_line<=0)throw new Error(`Invalid Option: from_line must be a positive integer greater than 0, got ${JSON.stringify(r.from_line)}`)}else throw new Error(`Invalid Option: from_line must be an integer, got ${JSON.stringify(r.from_line)}`);if(e.ignore_last_delimiters===void 0||e.ignore_last_delimiters===null)e.ignore_last_delimiters=!1;else if(typeof e.ignore_last_delimiters=="number")e.ignore_last_delimiters=Math.floor(e.ignore_last_delimiters),e.ignore_last_delimiters===0&&(e.ignore_last_delimiters=!1);else if(typeof e.ignore_last_delimiters!="boolean")throw new x("CSV_INVALID_OPTION_IGNORE_LAST_DELIMITERS",["Invalid option `ignore_last_delimiters`:","the value must be a boolean value or an integer,",`got ${JSON.stringify(e.ignore_last_delimiters)}`],e);if(e.ignore_last_delimiters===!0&&e.columns===!1)throw new x("CSV_IGNORE_LAST_DELIMITERS_REQUIRES_COLUMNS",["The option `ignore_last_delimiters`","requires the activation of the `columns` option"],e);if(e.info===void 0||e.info===null||e.info===!1)e.info=!1;else if(e.info!==!0)throw new Error(`Invalid Option: info must be true, got ${JSON.stringify(e.info)}`);if(e.max_record_size===void 0||e.max_record_size===null||e.max_record_size===!1)e.max_record_size=0;else if(!(Number.isInteger(e.max_record_size)&&e.max_record_size>=0))if(typeof e.max_record_size=="string"&&/\d+/.test(e.max_record_size))e.max_record_size=parseInt(e.max_record_size);else throw new Error(`Invalid Option: max_record_size must be a positive integer, got ${JSON.stringify(e.max_record_size)}`);if(e.objname===void 0||e.objname===null||e.objname===!1)e.objname=void 0;else if(M(e.objname)){if(e.objname.length===0)throw new Error("Invalid Option: objname must be a non empty buffer");e.encoding===null||(e.objname=e.objname.toString(e.encoding))}else if(typeof e.objname=="string"){if(e.objname.length===0)throw new Error("Invalid Option: objname must be a non empty string")}else if(typeof e.objname!="number")throw new Error(`Invalid Option: objname must be a string or a buffer, got ${e.objname}`);if(e.objname!==void 0){if(typeof e.objname=="number"){if(e.columns!==!1)throw Error("Invalid Option: objname index cannot be combined with columns or be defined as a field")}else if(e.columns===!1)throw Error("Invalid Option: objname field must be combined with columns or be defined as an index")}if(e.on_record===void 0||e.on_record===null)e.on_record=void 0;else if(typeof e.on_record!="function")throw new x("CSV_INVALID_OPTION_ON_RECORD",["Invalid option `on_record`:","expect a function,",`got ${JSON.stringify(e.on_record)}`],e);if(e.on_skip!==void 0&&e.on_skip!==null&&typeof e.on_skip!="function")throw new Error(`Invalid Option: on_skip must be a function, got ${JSON.stringify(e.on_skip)}`);if(e.quote===null||e.quote===!1||e.quote==="")e.quote=null;else if(e.quote===void 0||e.quote===!0?e.quote=f.from('"',e.encoding):typeof e.quote=="string"&&(e.quote=f.from(e.quote,e.encoding)),!M(e.quote))throw new Error(`Invalid Option: quote must be a buffer or a string, got ${JSON.stringify(e.quote)}`);if(e.raw===void 0||e.raw===null||e.raw===!1)e.raw=!1;else if(e.raw!==!0)throw new Error(`Invalid Option: raw must be true, got ${JSON.stringify(e.raw)}`);if(e.record_delimiter===void 0)e.record_delimiter=[];else if(typeof e.record_delimiter=="string"||M(e.record_delimiter)){if(e.record_delimiter.length===0)throw new x("CSV_INVALID_OPTION_RECORD_DELIMITER",["Invalid option `record_delimiter`:","value must be a non empty string or buffer,",`got ${JSON.stringify(e.record_delimiter)}`],e);e.record_delimiter=[e.record_delimiter]}else if(!Array.isArray(e.record_delimiter))throw new x("CSV_INVALID_OPTION_RECORD_DELIMITER",["Invalid option `record_delimiter`:","value must be a string, a buffer or array of string|buffer,",`got ${JSON.stringify(e.record_delimiter)}`],e);if(e.record_delimiter=e.record_delimiter.map(function(n,i){if(typeof n!="string"&&!M(n))throw new x("CSV_INVALID_OPTION_RECORD_DELIMITER",["Invalid option `record_delimiter`:","value must be a string, a buffer or array of string|buffer",`at index ${i},`,`got ${JSON.stringify(n)}`],e);if(n.length===0)throw new x("CSV_INVALID_OPTION_RECORD_DELIMITER",["Invalid option `record_delimiter`:","value must be a non empty string or buffer",`at index ${i},`,`got ${JSON.stringify(n)}`],e);return typeof n=="string"&&(n=f.from(n,e.encoding)),n}),typeof e.relax_column_count!="boolean")if(e.relax_column_count===void 0||e.relax_column_count===null)e.relax_column_count=!1;else throw new Error(`Invalid Option: relax_column_count must be a boolean, got ${JSON.stringify(e.relax_column_count)}`);if(typeof e.relax_column_count_less!="boolean")if(e.relax_column_count_less===void 0||e.relax_column_count_less===null)e.relax_column_count_less=!1;else throw new Error(`Invalid Option: relax_column_count_less must be a boolean, got ${JSON.stringify(e.relax_column_count_less)}`);if(typeof e.relax_column_count_more!="boolean")if(e.relax_column_count_more===void 0||e.relax_column_count_more===null)e.relax_column_count_more=!1;else throw new Error(`Invalid Option: relax_column_count_more must be a boolean, got ${JSON.stringify(e.relax_column_count_more)}`);if(typeof e.relax_quotes!="boolean")if(e.relax_quotes===void 0||e.relax_quotes===null)e.relax_quotes=!1;else throw new Error(`Invalid Option: relax_quotes must be a boolean, got ${JSON.stringify(e.relax_quotes)}`);if(typeof e.skip_empty_lines!="boolean")if(e.skip_empty_lines===void 0||e.skip_empty_lines===null)e.skip_empty_lines=!1;else throw new Error(`Invalid Option: skip_empty_lines must be a boolean, got ${JSON.stringify(e.skip_empty_lines)}`);if(typeof e.skip_records_with_empty_values!="boolean")if(e.skip_records_with_empty_values===void 0||e.skip_records_with_empty_values===null)e.skip_records_with_empty_values=!1;else throw new Error(`Invalid Option: skip_records_with_empty_values must be a boolean, got ${JSON.stringify(e.skip_records_with_empty_values)}`);if(typeof e.skip_records_with_error!="boolean")if(e.skip_records_with_error===void 0||e.skip_records_with_error===null)e.skip_records_with_error=!1;else throw new Error(`Invalid Option: skip_records_with_error must be a boolean, got ${JSON.stringify(e.skip_records_with_error)}`);if(e.rtrim===void 0||e.rtrim===null||e.rtrim===!1)e.rtrim=!1;else if(e.rtrim!==!0)throw new Error(`Invalid Option: rtrim must be a boolean, got ${JSON.stringify(e.rtrim)}`);if(e.ltrim===void 0||e.ltrim===null||e.ltrim===!1)e.ltrim=!1;else if(e.ltrim!==!0)throw new Error(`Invalid Option: ltrim must be a boolean, got ${JSON.stringify(e.ltrim)}`);if(e.trim===void 0||e.trim===null||e.trim===!1)e.trim=!1;else if(e.trim!==!0)throw new Error(`Invalid Option: trim must be a boolean, got ${JSON.stringify(e.trim)}`);if(e.trim===!0&&r.ltrim!==!1?e.ltrim=!0:e.ltrim!==!0&&(e.ltrim=!1),e.trim===!0&&r.rtrim!==!1?e.rtrim=!0:e.rtrim!==!0&&(e.rtrim=!1),e.to===void 0||e.to===null)e.to=-1;else if(typeof e.to=="string"&&/\d+/.test(e.to)&&(e.to=parseInt(e.to)),Number.isInteger(e.to)){if(e.to<=0)throw new Error(`Invalid Option: to must be a positive integer greater than 0, got ${JSON.stringify(r.to)}`)}else throw new Error(`Invalid Option: to must be an integer, got ${JSON.stringify(r.to)}`);if(e.to_line===void 0||e.to_line===null)e.to_line=-1;else if(typeof e.to_line=="string"&&/\d+/.test(e.to_line)&&(e.to_line=parseInt(e.to_line)),Number.isInteger(e.to_line)){if(e.to_line<=0)throw new Error(`Invalid Option: to_line must be a positive integer greater than 0, got ${JSON.stringify(r.to_line)}`)}else throw new Error(`Invalid Option: to_line must be an integer, got ${JSON.stringify(r.to_line)}`);return e},Et=function(r){return r.every(e=>e==null||e.toString&&e.toString().trim()==="")},Oi=13,Fi=10,ne={utf8:f.from([239,187,191]),utf16le:f.from([255,254])},Ci=function(r={}){let e={bytes:0,comment_lines:0,empty_lines:0,invalid_field_length:0,lines:1,records:0},t=St(r);return{info:e,original_options:r,options:t,state:Ai(t),__needMoreData:function(n,i,s){if(s)return!1;let{encoding:o,escape:a,quote:l}=this.options,{quoting:u,needMoreDataSize:c,recordDelimiterMaxLength:h}=this.state,d=i-n-1,m=Math.max(c,h===0?f.from(`\r
+`,o).length:h,u?(a===null?0:a.length)+l.length:0,u?l.length+h:0);return d_){this.state.stop=!0,o();return}this.state.quoting===!1&&le.length===0&&this.__autoDiscoverRecordDelimiter(E,g)&&(le=this.options.record_delimiter);let I=E[g];if(m===!0&&cr.append(I),(I===Oi||I===Fi)&&this.state.wasRowDelimiter===!1&&(this.state.wasRowDelimiter=!0),this.state.escaping===!0)this.state.escaping=!1;else{if(F!==null&&this.state.quoting===!0&&this.__isEscape(E,g,I)&&g+F.lengthne[Z].equals(this.state.field.toString())?Z:!1).filter(Boolean)[0],U=this.__error(new x("INVALID_OPENING_QUOTE",["Invalid Opening Quote:",`a quote is found on field ${JSON.stringify(C.column)} at line ${C.lines}, value is ${JSON.stringify(this.state.field.toString(u))}`,G?`(${G} bom)`:void 0],this.options,C,{field:this.state.field}));if(U!==void 0)return U}}else{this.state.quoting=!0,g+=L.length-1;continue}if(this.state.quoting===!1){let C=this.__isRecordDelimiter(I,E,g);if(C!==0){if(this.state.commenting&&this.state.wasQuoting===!1&&this.state.record.length===0&&this.state.field.length===0)this.info.comment_lines++;else{if(this.state.enabled===!1&&this.info.lines+(this.state.wasRowDelimiter===!0?1:0)>=c){this.state.enabled=!0,this.__resetField(),this.__resetRecord(),g+=C-1;continue}if(y===!0&&this.state.wasQuoting===!1&&this.state.record.length===0&&this.state.field.length===0){this.info.empty_lines++,g+=C-1;continue}this.info.bytes=this.state.bufBytesStart+g;let Z=this.__onField();if(Z!==void 0)return Z;this.info.bytes=this.state.bufBytesStart+g+C;let pe=this.__onRecord(s);if(pe!==void 0)return pe;if(v!==-1&&this.info.records>=v){this.state.stop=!0,o();return}}this.state.commenting=!1,g+=C-1;continue}if(this.state.commenting)continue;if(b!==null&&(l===!1||this.state.record.length===0&&this.state.field.length===0)&&this.__compareBytes(b,E,g,I)!==0){this.state.commenting=!0;continue}let G=this.__isDelimiter(E,g,I);if(G!==0){this.info.bytes=this.state.bufBytesStart+g;let U=this.__onField();if(U!==void 0)return U;g+=G-1;continue}}}if(this.state.commenting===!1&&d!==0&&this.state.record_length+this.state.field.length>d)return this.__error(new x("CSV_MAX_RECORD_SIZE",["Max Record Size:","record exceed the maximum number of tolerated bytes",`of ${d}`,`at line ${this.info.lines}`],this.options,this.__infoField()));let z=h===!1||this.state.quoting===!0||this.state.field.length!==0||!this.__isCharTrimable(E,g),dr=p===!1||this.state.wasQuoting===!1;if(z===!0&&dr===!0)this.state.field.append(I);else{if(p===!0&&!this.__isCharTrimable(E,g))return this.__error(new x("CSV_NON_TRIMABLE_CHAR_AFTER_CLOSING_QUOTE",["Invalid Closing Quote:","found non trimable byte after quote",`at line ${this.info.lines}`],this.options,this.__infoField()));z===!1&&(g+=this.__isCharTrimable(E,g)-1);continue}}if(i===!0)if(this.state.quoting===!0){let I=this.__error(new x("CSV_QUOTE_NOT_CLOSED",["Quote Not Closed:",`the parsing is finished with an opening quote at line ${this.info.lines}`],this.options,this.__infoField()));if(I!==void 0)return I}else if(this.state.wasQuoting===!0||this.state.record.length!==0||this.state.field.length!==0){this.info.bytes=this.state.bufBytesStart+g;let I=this.__onField();if(I!==void 0)return I;let z=this.__onRecord(s);if(z!==void 0)return z}else this.state.wasRowDelimiter===!0?this.info.empty_lines++:this.state.commenting===!0&&this.info.comment_lines++;else this.state.bufBytesStart+=g,this.state.previousBuf=E.slice(g);this.state.wasRowDelimiter===!0&&(this.info.lines++,this.state.wasRowDelimiter=!1)},__onRecord:function(n){let{columns:i,group_columns_by_name:s,encoding:o,info:a,from:l,relax_column_count:u,relax_column_count_less:c,relax_column_count_more:h,raw:d,skip_records_with_empty_values:m}=this.options,{enabled:S,record:p}=this.state;if(S===!1)return this.__resetRecord();let y=p.length;if(i===!0){if(m===!0&&Et(p)){this.__resetRecord();return}return this.__firstLineToColumns(p)}if(i===!1&&this.info.records===0&&(this.state.expectedRecordLength=y),y!==this.state.expectedRecordLength){let v=i===!1?new x("CSV_RECORD_INCONSISTENT_FIELDS_LENGTH",["Invalid Record Length:",`expect ${this.state.expectedRecordLength},`,`got ${y} on line ${this.info.lines}`],this.options,this.__infoField(),{record:p}):new x("CSV_RECORD_INCONSISTENT_COLUMNS",["Invalid Record Length:",`columns length is ${i.length},`,`got ${y} on line ${this.info.lines}`],this.options,this.__infoField(),{record:p});if(u===!0||c===!0&&ythis.state.expectedRecordLength)this.info.invalid_field_length++,this.state.error=v;else{let _=this.__error(v);if(_)return _}}if(m===!0&&Et(p)){this.__resetRecord();return}if(this.state.recordHasError===!0){this.__resetRecord(),this.state.recordHasError=!1;return}if(this.info.records++,l===1||this.info.records>=l){let{objname:v}=this.options;if(i!==!1){let _={};for(let b=0,F=p.length;b{let{timchars:l}=this.state;e:for(let u=0;u=0},__compareBytes:function(n,i,s,o){if(n[0]!==o)return 0;let a=n.length;for(let l=1;lthis.state.record.length?n[this.state.record.length].name:null:this.state.record.length,quoting:this.state.wasQuoting}}}},Ye=class extends j{constructor(e={}){super({readableObjectMode:!0,...e,encoding:null}),this.api=Ci({on_skip:(t,n)=>{this.emit("skip",t,n)},...e}),this.state=this.api.state,this.options=this.api.options,this.info=this.api.info}_transform(e,t,n){if(this.state.stop===!0)return;let i=this.api.parse(e,!1,s=>{this.push(s)},()=>{this.push(null),this.end(),this.on("end",this.destroy)});i!==void 0&&(this.state.stop=!0),n(i)}_flush(e){if(this.state.stop===!0)return;let t=this.api.parse(void 0,!0,n=>{this.push(n)},()=>{this.push(null),this.on("end",this.destroy)});e(t)}},tt=function(){let r,e,t;for(let i in arguments){let s=arguments[i],o=typeof s;if(r===void 0&&(typeof s=="string"||M(s)))r=s;else if(e===void 0&&sr(s))e=s;else if(t===void 0&&o==="function")t=s;else throw new x("CSV_INVALID_ARGUMENT",["Invalid argument:",`got ${JSON.stringify(s)} at index ${i}`],e||{})}let n=new Ye(e);if(t){let i=e===void 0||e.objname===void 0?[]:{};n.on("readable",function(){let s;for(;(s=this.read())!==null;)e===void 0||e.objname===void 0?i.push(s):i[s[0]]=s[1]}),n.on("error",function(s){t(s,void 0,n.api.__infoDataSet())}),n.on("end",function(){t(void 0,i,n.api.__infoDataSet())})}if(r!==void 0){let i=function(){n.write(r),n.end()};typeof setImmediate=="function"?setImmediate(i):setTimeout(i,0)}return n};var Ni=".psychds-ignore",Mi=`**/raw/
+.psychds-ignore
+`;function ar(r,e){let t=new Blob([r],{type:"text/plain"}),n="";typeof window.webkitURL<"u"?n=window.webkitURL.createObjectURL(t):n=window.URL.createObjectURL(t);let i=document.createElement("a");i.id="jspsych-download-as-text-link",i.style.display="none",i.download=e,i.href=n,i.click()}function nt(r){try{return JSON.parse(r)}catch{return null}}function lr(r){let e=typeof r=="string"?JSON.parse(r):r;if(e!==null&&typeof e=="object"&&!Array.isArray(e)){let t=Object.keys(e);if(t.length===1&&t[0]==="trials"&&Array.isArray(e.trials))return e.trials}return e}function it(r,e={},t){r.charCodeAt(0)===65279&&(r=r.slice(1));let n=nt(r);if(n!==null)return lr(n);let i=r.split(/\r?\n/),s=[],o=!1,a=0;for(let l=0;le.map(y=>String(p[y]??"")).join("\0")),n=new Map;for(let p of t)n.set(p,(n.get(p)??0)+1);let i=[...n.values()].reduce((p,y)=>p+(y>1?y-1:0),0),s=i===0,o=[];for(let p=0;p1){let y=e.reduce((v,_)=>(v[_]=r[p][_],v),{});o.some(v=>JSON.stringify(v)===JSON.stringify(y))||o.push(y)}if(s)return{isUnique:!0,duplicateCount:0,duplicateValues:[],candidates:[],suggestedAdditionalKeys:null};let a=new Set(e),l=new Set;for(let p of r)for(let y of Object.keys(p))l.add(y);let u=[...l].filter(p=>!lt(p)&&!a.has(p)&&!st.has(p)),c=u.map(p=>{let y=r.map(v=>[...e,p].map(_=>String(v[_]??"")).join("\0"));return{column:p,makesUnique:new Set(y).size===r.length}});if(c.some(p=>p.makesUnique))return{isUnique:s,duplicateCount:i,duplicateValues:o,candidates:c,suggestedAdditionalKeys:[]};let h=[...e],d=[...u];for(;d.length>0;){let p=r.map(_=>h.map(b=>String(_[b]??"")).join("\0"));if(new Set(p).size===r.length)break;let y=null,v=new Set(p).size;for(let _ of d){let b=r.map(L=>[...h,_].map(le=>String(L[le]??"")).join("\0")),F=new Set(b).size;F>v&&(v=F,y=_)}if(y===null)break;h.push(y),d.splice(d.indexOf(y),1)}let m=h.slice(e.length),S=new Set(r.map(p=>h.map(y=>String(p[y]??"")).join("\0"))).size===r.length;return{isUnique:s,duplicateCount:i,duplicateValues:o,candidates:c,suggestedAdditionalKeys:m.length>0&&S?m:null}}var Vi=/^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\.(csv|tsv)$/;function ur(r){return Vi.test(r)}function at(r,e="value"){let t=r.split(/[^a-zA-Z0-9]+/).filter(Boolean);return t.length===0?e:t[0]+t.slice(1).map(n=>n[0].toUpperCase()+n.slice(1)).join("")}function Li(r){return`subject-${at(r,"file")}`}function rt(r,e){return`${r}_measure-${at(e,"col")}_data.csv`}function Ne(r,e=["trial_index","element_index"]){if(r.length===0)return"";let t=new Set;for(let a of r)for(let l of Object.keys(a))t.add(l);let n=[...t].filter(a=>!e.includes(a)),i=[...e.filter(a=>t.has(a)),...n],s=a=>{if(a==null)return"";let l=typeof a=="object"?JSON.stringify(a):String(a);return l.includes(",")||l.includes('"')||l.includes(`
+`)||l.includes("\r")?`"${l.replace(/"/g,'""')}"`:l},o=[i.join(",")];for(let a of r)o.push(i.map(l=>s(a[l])).join(","));return o.join(`\r
+`)}function Me(r,e){if(!e.has(r))return r;let t="_data.csv",n=r.endsWith(t)?r.slice(0,-t.length):r.replace(/\.csv$/i,""),i=2,s=`${n}${i}${t}`;for(;e.has(s);)i+=1,s=`${n}${i}${t}`;return s}var lt=r=>r.trim()==="";function Di(r){return r.some(e=>Object.keys(e).some(lt))}function Ve(r){let e=new Set;for(let t of r)for(let n of Object.keys(t))lt(n)&&e.add(n);if(e.size>0)for(let t of r)for(let n of e)delete t[n];return{rows:r,dropped:[...e]}}function ki(r){let{base:e,mainRows:t,mainContent:n,extractedArrays:i=new Map,extractedObjects:s=new Map,joinKeys:o=["trial_index"],usedArrayFilenames:a=new Set}=r,l=[],u=S=>{if(!ur(S))throw new Error(`Refusing to write non-Psych-DS-compliant data filename "${S}".`);return a.add(S),S},c=u(Me(`${e}_data.csv`,a)),{rows:h,dropped:d}=Ve(t);l.push({filename:c,content:n!==void 0&&d.length===0?n:Ne(h,["trial_index"]),kind:"main"});let m=[...o,"element_index"];for(let[S,p]of i){let y=u(Me(rt(e,S),a));l.push({filename:y,content:Ne(p,m),kind:"array"})}for(let[S,p]of s){let y=u(Me(rt(e,S),a));l.push({filename:y,content:Ne(p,o),kind:"object"})}return l}async function ut(r){if(!tt)throw new Error("Parser module not loaded");return new Promise((e,t)=>{tt(r,{columns:!0,delimiter:",",bom:!0},(n,i)=>{n?t(n):e(i)})})}var Le=class r{constructor(){this.generateDefaultVariables()}static systemVariableTemplate(e){switch(e){case"trial_type":return{"@type":"PropertyValue",name:"trial_type",description:{default:"unknown",jsPsych:"The name of the plugin used to run the trial."},value:"string"};case"trial_index":return{"@type":"PropertyValue",name:"trial_index",description:{default:"unknown",jsPsych:"The index of the current trial across the whole experiment."},value:"number"};case"time_elapsed":return{"@type":"PropertyValue",name:"time_elapsed",description:{default:"unknown",jsPsych:"The number of milliseconds between the start of the experiment and when the trial ended."},value:"number"};case"extension_type":return{"@type":"PropertyValue",name:"extension_type",description:{default:"unknown",jsPsych:"The name(s) of the extension(s) used in the trial."},value:"string"};case"extension_version":return{"@type":"PropertyValue",name:"extension_version",description:{default:"unknown",jsPsych:"The version(s) of the extension(s) used in the trial."},value:"number"};default:return null}}registerSystemVariable(e){if(this.containsVariable(e))return!1;let t=r.systemVariableTemplate(e);return t?(this.setVariable(t),!0):!1}generateDefaultVariables(){this.variables={}}getList(){var e=[];for(let t of Object.keys(this.variables)){let n=this.variables[t];n.description=this.collapseDescription(n.description),e.push(n)}return e}collapseDescription(e){if(typeof e!="object"||e===null)return e;if(Object.keys(e).length===0)return console.error("Empty description"),"unknown";Object.keys(e).length>1&&"default"in e&&delete e.default;for(let t of Object.keys(e))e[t]==="unknown"&&Object.keys(e).length>1&&delete e[t];return Object.values(e).join(" | ")}setVariable(e){if(!e.name){console.warn("Name field is missing. Variable not added.",e);return}this.variables[e.name]=e;let t=Object.keys(e).filter(n=>!["@type","name","description","value","identifier","minValue","maxValue","levels","levelsOrdered","na","naValue","alternateName","privacy"].includes(n));t.length>0&&console.warn(`Unexpected fields (${t.join(", ")}) detected and included in the variable object.`)}getVariable(e){return this.variables[e]||{}}containsVariable(e){return e in this.variables}getVariableNames(){var e=[];for(let t of Object.keys(this.variables))e.push(this.variables[t].name);return e}updateVariable(e,t,n){let i=this.getVariable(e);if(Object.keys(i).length===0){console.error(`Variable "${e}" does not exist.`);return}t==="levels"?this.updateLevels(i,n):t==="minValue"||t==="maxValue"?this.updateMinMax(i,n,t):t==="description"?this.updateDescription(i,n):t==="name"?this.updateName(i,n):i[t]=n}updateLevels(e,t){if(typeof t=="object")return;let n=50;t.length>n&&(t=t.substring(0,n)+"..."),Array.isArray(e.levels)||(e.levels=[]),e.levels.includes(t)||e.levels.push(t)}updateMinMax(e,t,n){if(!("minValue"in e)||!("maxValue"in e)){e.maxValue=e.minValue=t;return}n==="minValue"&&e.minValue>t?e.minValue=t:n==="maxValue"&&e.maxValue{a===i&&(o.includes(n)||(delete e.description[o],e.description[o+", "+n]=i),s=!0)}),s||Object.assign(e.description,t)}updateName(e,t){let n=e.name;e.name=t,delete this.variables[n],this.setVariable(e)}deleteVariable(e){e in this.variables?delete this.variables[e]:console.error(`Variable "${e}" does not exist.`)}};var ft=class{constructor(e){this.ignored_variables=new Set(st);this.verbose=!1;this.extractedArrays=new Map;this.extractedObjects=new Map;this.arrayJoinKeys=["trial_index"];this.mixedColumns=new Set;this.metadata={},this.setMetadataField("name","title"),this.setMetadataField("schemaVersion","Psych-DS 0.4.0"),this.setMetadataField("@context","https://schema.org"),this.setMetadataField("@type","Dataset"),this.setMetadataField("description","Dataset generated using JsPsych"),this.authors=new ge,this.variables=new Le,this.pluginCache=new me,this.verbose=e}setMetadataField(e,t){this.metadata[e]=t}getMetadataField(e){return this.metadata[e]}containsMetadataField(e){return e in this.metadata}deleteMetadataField(e){e in this.metadata?delete this.metadata[e]:console.error(`Metadata "${e}" does not exist.`)}getMetadata(){let e=this.metadata;return e.author=this.authors.getList(),e.variableMeasured=this.variables.getList(),e}getUserMetadataFields(){let e={},t=new Set(["schemaVersion","@type","@context","author","variableMeasured"]);for(let n in this.metadata)t.has(n)||(e[n]=this.metadata[n]);return e}getMetadataFields(){let e=this.metadata;return delete e.author,delete e.variableMeasured,e}setAuthor(e){this.authors.setAuthor(e)}getAuthor(e){return this.authors.getAuthor(e)}getAuthorList(){return this.authors.getList()}deleteAuthor(e){this.authors.deleteAuthor(e)}setVariable(e){this.variables.setVariable(e)}getVariable(e){return this.variables.getVariable(e)}getVariableList(){return this.variables.getList()}containsVariable(e){return this.variables.containsVariable(e)}updateVariable(e,t,n){this.variables.updateVariable(e,t,n)}deleteVariable(e){this.variables.deleteVariable(e)}getVariableNames(){return this.variables.getVariableNames()}getExtractedArrays(){return this.extractedArrays}getExtractedObjects(){return this.extractedObjects}getArrayJoinKeys(){return[...this.arrayJoinKeys]}warnJoinKeyUniqueness(e){let t=this.arrayJoinKeys.join(", "),n=e.duplicateValues.slice(0,3).map(s=>Object.entries(s).map(([o,a])=>`${o}=${a}`).join(", ")).join("; "),i=`[jspsych-metadata] Join key (${t}) is not unique in this dataset
+  (${e.duplicateCount} duplicate rows; e.g. ${n})
+`;if(e.suggestedAdditionalKeys!==null&&e.suggestedAdditionalKeys.length===0){let s=e.candidates.filter(a=>a.makesUnique).map(a=>a.column),o=JSON.stringify([s[0],...this.arrayJoinKeys]);i+=`  Sufficient fix: add one of these columns to arrayJoinKeys:
+    ${s.join(", ")}
+  Pass { arrayJoinKeys: ${o} } as the options argument to generate().`}else if(e.suggestedAdditionalKeys!==null&&e.suggestedAdditionalKeys.length>0){let s=JSON.stringify([...e.suggestedAdditionalKeys,...this.arrayJoinKeys]);i+=`  No single column makes rows unique. Suggested combination:
+    ${e.suggestedAdditionalKeys.join(" + ")}
+  Pass { arrayJoinKeys: ${s} } as the options argument to generate().`}else i+=`  No combination of available columns was found to make rows unique.
+  Your data may contain genuinely duplicate rows.
+  Extracted array CSVs will have non-unique join keys.`;console.warn(i)}displayMetadata(e){let t="jspsych-metadata-display",n=JSON.stringify(this.getMetadata(),null,2);e.innerHTML+=`

Metadata

`,document.getElementById(t).textContent+=n}localSave(){let e=JSON.stringify(this.getMetadata());ar(e,"dataset_description.json")}loadMetadata(e){let t=JSON.parse(e);for(let n in t)if(n==="variableMeasured")for(let i of t[n])this.setVariable(i);else if(n==="author")for(let i of t[n])this.setAuthor(i);else this.setMetadataField(n,t[n])}async generate(e,t={},n="json",i={}){this.extractedArrays=new Map,this.extractedObjects=new Map,this.arrayJoinKeys=i.arrayJoinKeys??["trial_index"];var s;let o=i.synthesizedSourceRecordId??!1;if(Array.isArray(e))s=e;else if(n==="csv")s=await ut(e);else if(n==="json"){let d={};s=it(e,{tagSourceRecordId:!0},d),o=d.synthesizedSourceRecordId===!0}if(!Array.isArray(s))throw new Error("Parsed data is not in correct format: Expected an array of observations");let{dropped:a}=Ve(s);a.length>0&&console.warn(`Dropped ${a.length} unnamed column${a.length>1?"s":""} from the data \u2014 Psych-DS requires every column to have a name (usually a row-index column added by R's write.csv). Excluded from variableMeasured.`);let l=s,u=d=>n==="json"&&l.some(m=>m&&typeof m=="object"&&d in m),c=u("source_record_id")?"source_record_id":u("participant_id")?"participant_id":void 0;c&&!this.arrayJoinKeys.includes(c)&&(this.arrayJoinKeys=[c,...this.arrayJoinKeys]);let h=ot(s,this.arrayJoinKeys);!h.isUnique&&!i.suppressJoinKeyWarning&&this.warnJoinKeyUniqueness(h);for(let d of s)await this.generateObservation(d);if(o&&this.containsVariable("source_record_id")){let d=this.getVariable("source_record_id");this.setVariable({...d,description:{default:"Synthetic source-record identifier (0-based), assigned one per source record (one JSON-Lines line, which is usually but not always one participant) because the raw data carried no identifier column. NOT a real subject ID from the experiment \u2014 it only orders/links records as they appeared in the source file, and serves as a join key connecting each trial to its extracted array/object rows."}})}await this.updateMetadata(t)}async generateObservation(e){let t=e.plugin_version?e.plugin_version:null,n=e.trial_type,i=e.extension_type,s=e.extension_version,o=this.arrayJoinKeys.reduce((u,c)=>(u[c]=e[c],u),{});for(let u in e){var a=e[u],l=typeof a;if(this.containsVariable(u)||(this.ignored_variables.has(u)?this.variables.registerSystemVariable(u):this.setVariable({"@type":"PropertyValue",name:u,description:{default:"unknown"},value:"unknown"})),!(a==null||a===""||a==="null")){if(l==="string"){let c=Number(a);if(a.trim()!==""&&Number.isFinite(c))l="number",a=c;else if(a.startsWith("{")||a.startsWith("[")){let h=nt(a);h!==null&&(a=h,l=Array.isArray(h)?"array":"object")}}if(this.ignored_variables.has(u))this.updateFields(u,a,l);else{if(l==="object"&&a!==null&&!Array.isArray(a)){let c={...o};await this.expandObjectFields(u,a,n,t,o,c);let h=this.extractedObjects.get(u)??[];h.push(c),this.extractedObjects.set(u,h)}else if(l==="array"||l==="object"&&Array.isArray(a)){await this.generateMetadata(u,a,n,t);let h=(this.containsVariable(u)?this.getVariable(u):null)?.value;h!=="string"&&h!=="number"&&h!=="boolean"&&this.updateVariable(u,"value","array"),await this.accumulateArrayColumn(u,a,o,n,t)}else await this.generateMetadata(u,a,n,t);i&&await Promise.all(i.map(async(c,h)=>{c&&s[h]&&await this.generateMetadata(u,a,c,s[h],!0)}))}}}}async generateMetadata(e,t,n,i,s){let o=typeof t;if(this.containsVariable(e))this.getVariable(e).value==="unknown"&&this.updateVariable(e,"value",o);else{let a={"@type":"PropertyValue",name:e,description:{default:"unknown"},value:o};this.setVariable(a)}if(n){let l=(await this.getPluginInfo(n,e,i,s)).description,u=l?{[n]:l}:{[n]:"unknown"};this.updateVariable(e,"description",u)}this.updateFields(e,t,o)}updateFields(e,t,n){if(n==="boolean")return;let i=this.getVariable(e);if(n==="number"){if(Array.isArray(i.levels)){this.mixedColumns.has(e)||(this.mixedColumns.add(e),console.warn(`Variable "${e}" has mixed numeric and non-numeric values; treating as categorical.`)),this.updateVariable(e,"levels",String(t));return}this.updateVariable(e,"minValue",t),this.updateVariable(e,"maxValue",t);return}if(n!=="object"){if(("minValue"in i||"maxValue"in i)&&(this.mixedColumns.has(e)||(this.mixedColumns.add(e),console.warn(`Variable "${e}" has mixed numeric and non-numeric values; treating as categorical.`)),"minValue"in i&&this.updateVariable(e,"levels",String(i.minValue)),"maxValue"in i&&i.maxValue!==i.minValue&&this.updateVariable(e,"levels",String(i.maxValue)),delete i.minValue,delete i.maxValue,this.updateVariable(e,"value","string")),i.value==="boolean"&&(t==="true"||t==="false"))return;this.updateVariable(e,"levels",t)}}async updateMetadata(e){for(let t in e)await this.processMetadata(e,t)}processMetadata(e,t){let n=e[t];if(t==="variables"){if(typeof n!="object"||n===null){console.warn("Variable object is either null or incorrect type");return}for(let i in n){if(!this.containsVariable(i)){console.warn("Metadata does not contain variable:",i);continue}let s=n[i];if(typeof s!="object"||s===null){console.warn("Parameters of variable:",i,"is either null or incorrect type. The value",s,"is either null or not an object.");continue}for(let o in s){let a=s[o];this.updateVariable(i,o,a),o==="value"&&a==="boolean"&&this.applyBooleanOverride(i),o==="name"&&(i=a)}}}else if(t==="author"){if(typeof n!="object"||n===null){console.warn("Author object is not correct type");return}for(let i in n){let s=n[i];typeof s!="string"&&!("name"in s)&&(s.name=i),this.setAuthor(s)}}else this.setMetadataField(t,n)}applyBooleanOverride(e){let t=this.getVariable(e),n=s=>{let o=String(s).trim().toLowerCase();return o==="true"||o==="false"||o==="0"||o==="1"},i=new Set;if(Array.isArray(t.levels))for(let s of t.levels)n(s)||i.add(String(s));if(typeof t.minValue=="number"&&!n(t.minValue)&&i.add(String(t.minValue)),typeof t.maxValue=="number"&&!n(t.maxValue)&&i.add(String(t.maxValue)),i.size>0){let s=[...i].slice(0,10).join(", "),o=i.size>10?`, \u2026(+${i.size-10} more)`:"";console.warn(`Variable "${e}" was set to value:"boolean", but the detected values don't map cleanly to true/false: ${s}${o}. Double-check this is the intended type.`)}delete t.levels,delete t.minValue,delete t.maxValue}async expandObjectFields(e,t,n,i,s,o){await this.generateMetadata(e,t,n,i);for(let a of Object.keys(t)){let l=`${e}.${a}`,u=t[a];o&&(o[l]=u),u!==null&&typeof u=="object"&&!Array.isArray(u)?await this.expandObjectFields(l,u,n,i,s,o):Array.isArray(u)?(await this.generateMetadata(l,u,n,i),this.updateVariable(l,"value","array"),await this.accumulateArrayColumn(l,u,s,n,i)):await this.generateMetadata(l,u,n,i)}}async accumulateArrayColumn(e,t,n,i,s){let o=[];if(t.forEach((l,u)=>{l!=null&&o.push({element:l,index:u})}),o.length===0)return;this.containsVariable("element_index")||this.setVariable({"@type":"PropertyValue",name:"element_index",description:{default:"Position of this element within its source array column (0-based)."},value:"number"});for(let l of Object.keys(n))this.containsVariable(l)||this.setVariable({"@type":"PropertyValue",name:l,description:{default:"Join key referencing the position of an enclosing array element (0-based index)."},value:"number"});let a=this.extractedArrays.get(e)??[];for(let{element:l,index:u}of o){let c={...n,element_index:u},h={...n,[`${e}.element_index`]:u};if(typeof l=="object"&&!Array.isArray(l))await this.expandElementFields(e,l,c,h,i,s);else{let d=`${e}.value`;c[d]=l,Array.isArray(l)?(await this.registerNodeVariable(d,l,"array",i,s),await this.accumulateArrayColumn(d,l,h,i,s)):await this.registerScalarField(d,l,i,s)}a.push(c)}this.extractedArrays.set(e,a)}async expandElementFields(e,t,n,i,s,o){for(let a of Object.keys(t)){let l=`${e}.${a}`,u=t[a];n[l]=u,u!==null&&typeof u=="object"&&!Array.isArray(u)?(await this.registerNodeVariable(l,u,"object",s,o),await this.expandElementFields(l,u,n,i,s,o)):Array.isArray(u)?(await this.registerNodeVariable(l,u,"array",s,o),await this.accumulateArrayColumn(l,u,i,s,o)):await this.registerScalarField(l,u,s,o)}}async registerNodeVariable(e,t,n,i,s){this.containsVariable(e)&&this.getVariable(e).value!=="unknown"||(await this.generateMetadata(e,t,i,s),this.containsVariable(e)?this.updateVariable(e,"value",n):this.setVariable({"@type":"PropertyValue",name:e,description:{default:"unknown"},value:n}))}async registerScalarField(e,t,n,i){if(t==null||t===""||t==="null"){this.containsVariable(e)||this.setVariable({"@type":"PropertyValue",name:e,description:{default:"unknown"},value:"unknown"});return}let s=typeof t;!this.containsVariable(e)||this.getVariable(e).value==="unknown"?(await this.generateMetadata(e,t,n,i),this.containsVariable(e)||(this.setVariable({"@type":"PropertyValue",name:e,description:{default:"unknown"},value:s}),this.updateFields(e,t,s))):this.updateFields(e,t,s)}async getPluginInfo(e,t,n,i){return this.pluginCache.getPluginInfo(e,t,n,this.verbose,i)}};})();
diff --git a/functions/metadata/dist/index.cjs b/functions/metadata/dist/index.cjs
new file mode 100644
index 0000000..2298864
--- /dev/null
+++ b/functions/metadata/dist/index.cjs
@@ -0,0 +1,3103 @@
+var __defProp = Object.defineProperty;
+var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+var __getOwnPropNames = Object.getOwnPropertyNames;
+var __hasOwnProp = Object.prototype.hasOwnProperty;
+var __export = (target, all) => {
+  for (var name in all)
+    __defProp(target, name, { get: all[name], enumerable: true });
+};
+var __copyProps = (to, from, except, desc) => {
+  if (from && typeof from === "object" || typeof from === "function") {
+    for (let key of __getOwnPropNames(from))
+      if (!__hasOwnProp.call(to, key) && key !== except)
+        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+  }
+  return to;
+};
+var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
+
+// src/index.ts
+var index_exports = {};
+__export(index_exports, {
+  PSYCHDS_IGNORE_CONTENT: () => PSYCHDS_IGNORE_CONTENT,
+  PSYCHDS_IGNORE_FILENAME: () => PSYCHDS_IGNORE_FILENAME,
+  analyzeJoinKeys: () => analyzeJoinKeys,
+  buildPsychDSDataFiles: () => buildPsychDSDataFiles,
+  default: () => JsPsychMetadata,
+  deriveArrayFilename: () => deriveArrayFilename,
+  deriveFallbackBase: () => deriveFallbackBase,
+  disambiguateArrayFilename: () => disambiguateArrayFilename,
+  hasUnnamedColumns: () => hasUnnamedColumns,
+  isValidPsychDSDataFilename: () => isValidPsychDSDataFilename,
+  objectsToCSV: () => objectsToCSV,
+  parseCSV: () => parseCSV,
+  parseJsonData: () => parseJsonData,
+  stripUnnamedColumns: () => stripUnnamedColumns,
+  toPsychDSValue: () => toPsychDSValue,
+  unwrapTrials: () => unwrapTrials
+});
+module.exports = __toCommonJS(index_exports);
+
+// src/AuthorsMap.ts
+var AuthorsMap = class {
+  /**
+   * Creates an empty instance of authors map. Doesn't generate default metadata because
+   * can't assume anything about the authors.
+   *
+   * @constructor
+   */
+  constructor() {
+    this.authors = {};
+  }
+  /**
+   * Returns the final list format of the authors according to Psych-DS standards.
+   *
+   * @returns {(AuthorFields | string)[]} - List of authors
+   */
+  getList() {
+    const author_list = [];
+    for (const key of Object.keys(this.authors)) {
+      author_list.push(this.authors[key]);
+    }
+    return author_list;
+  }
+  /**
+   * Method that creates an author. This method can also be used to overwrite existing authors
+   * with the same name in order to update fields.
+   *
+   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+   */
+  setAuthor(author) {
+    if (typeof author === "string") {
+      this.authors[author] = author;
+      return;
+    }
+    if (!author.name) {
+      console.warn("Name field is missing. Author not added.");
+      return;
+    }
+    const { name, ...rest } = author;
+    if (Object.keys(rest).length == 0) {
+      this.authors[name] = name;
+    } else {
+      const newAuthor = { name, ...rest };
+      this.authors[name] = newAuthor;
+      const unexpectedFields = Object.keys(author).filter(
+        (key) => !["@type", "name", "givenName", "familyName", "identifier"].includes(key)
+      );
+      if (unexpectedFields.length > 0) {
+        console.warn(
+          `Unexpected fields (${unexpectedFields.join(
+            ", "
+          )}) detected and included in the author object.`
+        );
+      }
+    }
+  }
+  /**
+   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+   *
+   * @param {string} name - Name of author to be used as key.
+   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+   */
+  getAuthor(name) {
+    if (name in this.authors) {
+      return this.authors[name];
+    } else {
+      console.warn("Author (", name, ") not found.");
+      return {};
+    }
+  }
+  /**
+   * Deletes the author if it exists, printing out warning if doesn't exist. 
+   *
+   * @param {string} author_name - Name of author to be deleted
+   */
+  deleteAuthor(author_name) {
+    if (author_name in this.authors) {
+      delete this.authors[author_name];
+    } else {
+      console.error(`Author "${author_name}" does not exist.`);
+    }
+  }
+};
+
+// src/PluginCache.ts
+var PluginCache = class {
+  constructor() {
+    this.pluginFields = {};
+  }
+  /**
+   * Gets the description of a variable in a plugin by fetching the source code of the plugin
+   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+   * to extract the description for the variable (present as JSDoc); caches the result for future use.
+   *
+   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+   * @param {string} variableName - The name of the variable for which information is to be fetched.
+   * @param {string} version - The name of the variable for which information is to be fetched. 
+   * @param {boolean} verbose - Indicates whether should run with verbose mode
+   * @param {boolean} [extension] - An optional flag to indicate if an extension should be used.
+   * @returns {Promise} The description of the plugin variable if found, otherwise null.
+   * @throws Will throw an error if the fetch operation fails.
+   */
+  async getPluginInfo(pluginType, variableName, version, verbose, extension) {
+    if (!(pluginType in this.pluginFields)) {
+      const fields = await this.generatePluginFields(pluginType, version, verbose, extension);
+      this.pluginFields[pluginType] = fields;
+    }
+    if (variableName in this.pluginFields[pluginType])
+      return this.pluginFields[pluginType][variableName];
+    else
+      return {
+        description: "unknown",
+        type: "unknown"
+      };
+  }
+  /**
+   * Method that handles the generation of the fields and calls helpers methods that 
+   * fetch and parse the plugin data.
+   *
+   * @private
+   * @async
+   * @param {string} pluginType - Name of plugin or extension to fetch.
+   * @param {string} version - String version to fetch
+   * @param {boolean} verbose - Boolean indicating verbose mode
+   * @param {?boolean} [extension] - Optional flag if pluginType is extension
+   * @returns {unknown}
+   */
+  async generatePluginFields(pluginType, version, verbose, extension) {
+    const script = await this.fetchScript(pluginType, version, verbose, extension);
+    if (script !== void 0 && script !== null && script !== "") {
+      try {
+        return this.parseJavadocString(script);
+      } catch (err) {
+        console.warn("* Error parsing", pluginType, err);
+        return {};
+      }
+    } else {
+      return {};
+    }
+  }
+  /**
+   * The method that generates the unpkg links based on whether extension vs plugin and the 
+   * specific type.
+   *
+   * @private
+   * @param {string} pluginType - Name of plugin or extension to fetch
+   * @param {string} version - String version used
+   * @param {?boolean} [extension] - Optional flag if pluginType is extension
+   * @returns {string}
+   */
+  generateUnpkg(pluginType, version, extension) {
+    if (extension) {
+      if (version) {
+        return `https://unpkg.com/@jspsych/extension-${pluginType}@${version}/src/index.ts`;
+      } else return `https://unpkg.com/@jspsych/extension-${pluginType}/src/index.ts`;
+    }
+    if (version) {
+      return `https://unpkg.com/@jspsych/plugin-${pluginType}@${version}/src/index.ts`;
+    } else return `https://unpkg.com/@jspsych/plugin-${pluginType}/src/index.ts`;
+  }
+  /**
+   * Fetches the actual script text content from unpkg. Calls the method to generate the link 
+   * and then handles error checking and fetching.
+   *
+   * @private
+   * @async
+   * @param {string} pluginType - The plugin or extension name to be fetched
+   * @param {string} version - The string version of the plugin
+   * @param {boolean} verbose - Boolean indicating verbose mode
+   * @param {?boolean} [extension] - Whether pluginType is extension
+   * @returns {unknown}
+   */
+  async fetchScript(pluginType, version, verbose, extension) {
+    const unpkgUrl = this.generateUnpkg(pluginType, version, extension);
+    if (verbose) console.log("-> fetching information for [", pluginType, "] from ->", unpkgUrl);
+    try {
+      const response = await fetch(unpkgUrl);
+      if (!response.ok) {
+        console.warn(`Plugin source not found for: ${pluginType} (HTTP ${response.status}). Descriptions will default to "unknown".`);
+        return void 0;
+      }
+      const scriptContent = await response.text();
+      return scriptContent;
+    } catch (error) {
+      console.error(
+        `Plugin fetching failed for:`,
+        pluginType,
+        "with error",
+        error,
+        "Note: if you are using a plugin not supported the main JsPsych branch this will always fail."
+      );
+      return void 0;
+    }
+  }
+  /**
+   * Extracts the content of the top-level `data: { ... }` block from a jsPsych plugin source
+   * file using brace counting. This is more robust than a regex approach because the data block
+   * ends with `},` (not `};`), and plugin sources contain deeply nested objects that would
+   * cause a lazy regex to stop at the wrong closing brace.
+   *
+   * Known limitations (acceptable for current jsPsych plugin sources):
+   * - Matches the first `data:` property in the file; a plugin with a `data:` field inside its
+   *   `parameters` block before the top-level `info.data` block would extract the wrong object.
+   * - Brace counting treats every `{`/`}` as structural; braces inside string literals or JSDoc
+   *   comments (e.g. `/** e.g. {foo: 1} *\/`) would throw off the counter.
+   *
+   * @private
+   * @param {string} script - Full plugin source text.
+   * @returns {string | null} Content between the outer braces of the data block, or null if not found.
+   */
+  extractDataBlock(script) {
+    const dataStart = script.search(/\bdata:\s*\{/);
+    if (dataStart === -1) return null;
+    const braceStart = script.indexOf("{", dataStart);
+    if (braceStart === -1) return null;
+    const braceEnd = this.findMatchingBrace(script, braceStart);
+    if (braceEnd === -1) return null;
+    return script.substring(braceStart + 1, braceEnd);
+  }
+  /**
+   * Parses JSDoc comments and variable blocks from the data section of a jsPsych plugin source.
+   *
+   * @private
+   * @param {string} script - The script text content of the fetching.
+   * @returns {{}}
+   */
+  parseJavadocString(script) {
+    const dataBlock = this.extractDataBlock(script);
+    if (!dataBlock) return {};
+    return this.extractJsdocFields(dataBlock);
+  }
+  /**
+   * Extracts JSDoc-annotated fields from a data block string. Uses brace counting to find
+   * each variable's true closing brace, then recursively processes any `nested:` sub-object
+   * so that nested parameter descriptions are also captured.
+   *
+   * @private
+   * @param {string} block - Content of a data or nested block (without outer braces).
+   * @returns {Record}
+   */
+  extractJsdocFields(block) {
+    const result = {};
+    const varStartRegex = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(\w+):\s*\{/g;
+    const propRegex = /(\w+):\s*([^,\s{}]+)/g;
+    let match;
+    while ((match = varStartRegex.exec(block)) !== null) {
+      const description = match[1].replace(/^[ \t]*\*[ \t]?/gm, "").trim().replace(/\s+/g, " ");
+      const varName = match[2];
+      const braceStart = match.index + match[0].length - 1;
+      const braceEnd = this.findMatchingBrace(block, braceStart);
+      if (braceEnd === -1) continue;
+      varStartRegex.lastIndex = braceEnd + 1;
+      const varContent = block.substring(braceStart + 1, braceEnd);
+      const propsObj = {};
+      let propMatch;
+      propRegex.lastIndex = 0;
+      while ((propMatch = propRegex.exec(varContent)) !== null) {
+        propsObj[propMatch[1]] = propMatch[2];
+      }
+      result[varName] = { description, ...propsObj };
+      const nestedSearch = /\bnested:\s*\{/.exec(varContent);
+      if (nestedSearch) {
+        const nestedBraceStart = varContent.indexOf("{", nestedSearch.index);
+        const nestedBraceEnd = this.findMatchingBrace(varContent, nestedBraceStart);
+        if (nestedBraceEnd !== -1) {
+          Object.assign(result, this.extractJsdocFields(varContent.substring(nestedBraceStart + 1, nestedBraceEnd)));
+        }
+      }
+    }
+    return result;
+  }
+  /**
+   * Returns the index of the `}` that closes the `{` at `startIndex`, using brace counting.
+   * Returns -1 if the source is unbalanced (no matching closing brace found).
+   *
+   * @private
+   * @param {string} str - String to search.
+   * @param {number} startIndex - Index of the opening `{`.
+   * @returns {number}
+   */
+  findMatchingBrace(str, startIndex) {
+    let depth = 0;
+    for (let i = startIndex; i < str.length; i++) {
+      if (str[i] === "{") depth++;
+      else if (str[i] === "}" && --depth === 0) return i;
+    }
+    return -1;
+  }
+};
+
+// ../../node_modules/csv-parse/lib/index.js
+var import_stream = require("stream");
+
+// ../../node_modules/csv-parse/lib/utils/is_object.js
+var is_object = function(obj) {
+  return typeof obj === "object" && obj !== null && !Array.isArray(obj);
+};
+
+// ../../node_modules/csv-parse/lib/api/CsvError.js
+var CsvError = class _CsvError extends Error {
+  constructor(code, message, options, ...contexts) {
+    if (Array.isArray(message)) message = message.join(" ").trim();
+    super(message);
+    if (Error.captureStackTrace !== void 0) {
+      Error.captureStackTrace(this, _CsvError);
+    }
+    this.code = code;
+    for (const context of contexts) {
+      for (const key in context) {
+        const value = context[key];
+        this[key] = Buffer.isBuffer(value) ? value.toString(options.encoding) : value == null ? value : JSON.parse(JSON.stringify(value));
+      }
+    }
+  }
+};
+
+// ../../node_modules/csv-parse/lib/api/normalize_columns_array.js
+var normalize_columns_array = function(columns) {
+  const normalizedColumns = [];
+  for (let i = 0, l = columns.length; i < l; i++) {
+    const column = columns[i];
+    if (column === void 0 || column === null || column === false) {
+      normalizedColumns[i] = { disabled: true };
+    } else if (typeof column === "string") {
+      normalizedColumns[i] = { name: column };
+    } else if (is_object(column)) {
+      if (typeof column.name !== "string") {
+        throw new CsvError("CSV_OPTION_COLUMNS_MISSING_NAME", [
+          "Option columns missing name:",
+          `property "name" is required at position ${i}`,
+          "when column is an object literal"
+        ]);
+      }
+      normalizedColumns[i] = column;
+    } else {
+      throw new CsvError("CSV_INVALID_COLUMN_DEFINITION", [
+        "Invalid column definition:",
+        "expect a string or a literal object,",
+        `got ${JSON.stringify(column)} at position ${i}`
+      ]);
+    }
+  }
+  return normalizedColumns;
+};
+
+// ../../node_modules/csv-parse/lib/utils/ResizeableBuffer.js
+var ResizeableBuffer = class {
+  constructor(size = 100) {
+    this.size = size;
+    this.length = 0;
+    this.buf = Buffer.allocUnsafe(size);
+  }
+  prepend(val) {
+    if (Buffer.isBuffer(val)) {
+      const length = this.length + val.length;
+      if (length >= this.size) {
+        this.resize();
+        if (length >= this.size) {
+          throw Error("INVALID_BUFFER_STATE");
+        }
+      }
+      const buf = this.buf;
+      this.buf = Buffer.allocUnsafe(this.size);
+      val.copy(this.buf, 0);
+      buf.copy(this.buf, val.length);
+      this.length += val.length;
+    } else {
+      const length = this.length++;
+      if (length === this.size) {
+        this.resize();
+      }
+      const buf = this.clone();
+      this.buf[0] = val;
+      buf.copy(this.buf, 1, 0, length);
+    }
+  }
+  append(val) {
+    const length = this.length++;
+    if (length === this.size) {
+      this.resize();
+    }
+    this.buf[length] = val;
+  }
+  clone() {
+    return Buffer.from(this.buf.slice(0, this.length));
+  }
+  resize() {
+    const length = this.length;
+    this.size = this.size * 2;
+    const buf = Buffer.allocUnsafe(this.size);
+    this.buf.copy(buf, 0, 0, length);
+    this.buf = buf;
+  }
+  toString(encoding) {
+    if (encoding) {
+      return this.buf.slice(0, this.length).toString(encoding);
+    } else {
+      return Uint8Array.prototype.slice.call(this.buf.slice(0, this.length));
+    }
+  }
+  toJSON() {
+    return this.toString("utf8");
+  }
+  reset() {
+    this.length = 0;
+  }
+};
+var ResizeableBuffer_default = ResizeableBuffer;
+
+// ../../node_modules/csv-parse/lib/api/init_state.js
+var np = 12;
+var cr = 13;
+var nl = 10;
+var space = 32;
+var tab = 9;
+var init_state = function(options) {
+  return {
+    bomSkipped: false,
+    bufBytesStart: 0,
+    castField: options.cast_function,
+    commenting: false,
+    // Current error encountered by a record
+    error: void 0,
+    enabled: options.from_line === 1,
+    escaping: false,
+    escapeIsQuote: Buffer.isBuffer(options.escape) && Buffer.isBuffer(options.quote) && Buffer.compare(options.escape, options.quote) === 0,
+    // columns can be `false`, `true`, `Array`
+    expectedRecordLength: Array.isArray(options.columns) ? options.columns.length : void 0,
+    field: new ResizeableBuffer_default(20),
+    firstLineToHeaders: options.cast_first_line_to_header,
+    needMoreDataSize: Math.max(
+      // Skip if the remaining buffer smaller than comment
+      options.comment !== null ? options.comment.length : 0,
+      ...options.delimiter.map((delimiter) => delimiter.length),
+      // Skip if the remaining buffer can be escape sequence
+      options.quote !== null ? options.quote.length : 0
+    ),
+    previousBuf: void 0,
+    quoting: false,
+    stop: false,
+    rawBuffer: new ResizeableBuffer_default(100),
+    record: [],
+    recordHasError: false,
+    record_length: 0,
+    recordDelimiterMaxLength: options.record_delimiter.length === 0 ? 0 : Math.max(...options.record_delimiter.map((v) => v.length)),
+    trimChars: [Buffer.from(" ", options.encoding)[0], Buffer.from("	", options.encoding)[0]],
+    wasQuoting: false,
+    wasRowDelimiter: false,
+    timchars: [
+      Buffer.from(Buffer.from([cr], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([nl], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([np], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([space], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([tab], "utf8").toString(), options.encoding)
+    ]
+  };
+};
+
+// ../../node_modules/csv-parse/lib/utils/underscore.js
+var underscore = function(str) {
+  return str.replace(/([A-Z])/g, function(_, match) {
+    return "_" + match.toLowerCase();
+  });
+};
+
+// ../../node_modules/csv-parse/lib/api/normalize_options.js
+var normalize_options = function(opts) {
+  const options = {};
+  for (const opt in opts) {
+    options[underscore(opt)] = opts[opt];
+  }
+  if (options.encoding === void 0 || options.encoding === true) {
+    options.encoding = "utf8";
+  } else if (options.encoding === null || options.encoding === false) {
+    options.encoding = null;
+  } else if (typeof options.encoding !== "string" && options.encoding !== null) {
+    throw new CsvError("CSV_INVALID_OPTION_ENCODING", [
+      "Invalid option encoding:",
+      "encoding must be a string or null to return a buffer,",
+      `got ${JSON.stringify(options.encoding)}`
+    ], options);
+  }
+  if (options.bom === void 0 || options.bom === null || options.bom === false) {
+    options.bom = false;
+  } else if (options.bom !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_BOM", [
+      "Invalid option bom:",
+      "bom must be true,",
+      `got ${JSON.stringify(options.bom)}`
+    ], options);
+  }
+  options.cast_function = null;
+  if (options.cast === void 0 || options.cast === null || options.cast === false || options.cast === "") {
+    options.cast = void 0;
+  } else if (typeof options.cast === "function") {
+    options.cast_function = options.cast;
+    options.cast = true;
+  } else if (options.cast !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_CAST", [
+      "Invalid option cast:",
+      "cast must be true or a function,",
+      `got ${JSON.stringify(options.cast)}`
+    ], options);
+  }
+  if (options.cast_date === void 0 || options.cast_date === null || options.cast_date === false || options.cast_date === "") {
+    options.cast_date = false;
+  } else if (options.cast_date === true) {
+    options.cast_date = function(value) {
+      const date = Date.parse(value);
+      return !isNaN(date) ? new Date(date) : value;
+    };
+  } else if (typeof options.cast_date !== "function") {
+    throw new CsvError("CSV_INVALID_OPTION_CAST_DATE", [
+      "Invalid option cast_date:",
+      "cast_date must be true or a function,",
+      `got ${JSON.stringify(options.cast_date)}`
+    ], options);
+  }
+  options.cast_first_line_to_header = null;
+  if (options.columns === true) {
+    options.cast_first_line_to_header = void 0;
+  } else if (typeof options.columns === "function") {
+    options.cast_first_line_to_header = options.columns;
+    options.columns = true;
+  } else if (Array.isArray(options.columns)) {
+    options.columns = normalize_columns_array(options.columns);
+  } else if (options.columns === void 0 || options.columns === null || options.columns === false) {
+    options.columns = false;
+  } else {
+    throw new CsvError("CSV_INVALID_OPTION_COLUMNS", [
+      "Invalid option columns:",
+      "expect an array, a function or true,",
+      `got ${JSON.stringify(options.columns)}`
+    ], options);
+  }
+  if (options.group_columns_by_name === void 0 || options.group_columns_by_name === null || options.group_columns_by_name === false) {
+    options.group_columns_by_name = false;
+  } else if (options.group_columns_by_name !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [
+      "Invalid option group_columns_by_name:",
+      "expect an boolean,",
+      `got ${JSON.stringify(options.group_columns_by_name)}`
+    ], options);
+  } else if (options.columns === false) {
+    throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [
+      "Invalid option group_columns_by_name:",
+      "the `columns` mode must be activated."
+    ], options);
+  }
+  if (options.comment === void 0 || options.comment === null || options.comment === false || options.comment === "") {
+    options.comment = null;
+  } else {
+    if (typeof options.comment === "string") {
+      options.comment = Buffer.from(options.comment, options.encoding);
+    }
+    if (!Buffer.isBuffer(options.comment)) {
+      throw new CsvError("CSV_INVALID_OPTION_COMMENT", [
+        "Invalid option comment:",
+        "comment must be a buffer or a string,",
+        `got ${JSON.stringify(options.comment)}`
+      ], options);
+    }
+  }
+  if (options.comment_no_infix === void 0 || options.comment_no_infix === null || options.comment_no_infix === false) {
+    options.comment_no_infix = false;
+  } else if (options.comment_no_infix !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_COMMENT", [
+      "Invalid option comment_no_infix:",
+      "value must be a boolean,",
+      `got ${JSON.stringify(options.comment_no_infix)}`
+    ], options);
+  }
+  const delimiter_json = JSON.stringify(options.delimiter);
+  if (!Array.isArray(options.delimiter)) options.delimiter = [options.delimiter];
+  if (options.delimiter.length === 0) {
+    throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [
+      "Invalid option delimiter:",
+      "delimiter must be a non empty string or buffer or array of string|buffer,",
+      `got ${delimiter_json}`
+    ], options);
+  }
+  options.delimiter = options.delimiter.map(function(delimiter) {
+    if (delimiter === void 0 || delimiter === null || delimiter === false) {
+      return Buffer.from(",", options.encoding);
+    }
+    if (typeof delimiter === "string") {
+      delimiter = Buffer.from(delimiter, options.encoding);
+    }
+    if (!Buffer.isBuffer(delimiter) || delimiter.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [
+        "Invalid option delimiter:",
+        "delimiter must be a non empty string or buffer or array of string|buffer,",
+        `got ${delimiter_json}`
+      ], options);
+    }
+    return delimiter;
+  });
+  if (options.escape === void 0 || options.escape === true) {
+    options.escape = Buffer.from('"', options.encoding);
+  } else if (typeof options.escape === "string") {
+    options.escape = Buffer.from(options.escape, options.encoding);
+  } else if (options.escape === null || options.escape === false) {
+    options.escape = null;
+  }
+  if (options.escape !== null) {
+    if (!Buffer.isBuffer(options.escape)) {
+      throw new Error(`Invalid Option: escape must be a buffer, a string or a boolean, got ${JSON.stringify(options.escape)}`);
+    }
+  }
+  if (options.from === void 0 || options.from === null) {
+    options.from = 1;
+  } else {
+    if (typeof options.from === "string" && /\d+/.test(options.from)) {
+      options.from = parseInt(options.from);
+    }
+    if (Number.isInteger(options.from)) {
+      if (options.from < 0) {
+        throw new Error(`Invalid Option: from must be a positive integer, got ${JSON.stringify(opts.from)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: from must be an integer, got ${JSON.stringify(options.from)}`);
+    }
+  }
+  if (options.from_line === void 0 || options.from_line === null) {
+    options.from_line = 1;
+  } else {
+    if (typeof options.from_line === "string" && /\d+/.test(options.from_line)) {
+      options.from_line = parseInt(options.from_line);
+    }
+    if (Number.isInteger(options.from_line)) {
+      if (options.from_line <= 0) {
+        throw new Error(`Invalid Option: from_line must be a positive integer greater than 0, got ${JSON.stringify(opts.from_line)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: from_line must be an integer, got ${JSON.stringify(opts.from_line)}`);
+    }
+  }
+  if (options.ignore_last_delimiters === void 0 || options.ignore_last_delimiters === null) {
+    options.ignore_last_delimiters = false;
+  } else if (typeof options.ignore_last_delimiters === "number") {
+    options.ignore_last_delimiters = Math.floor(options.ignore_last_delimiters);
+    if (options.ignore_last_delimiters === 0) {
+      options.ignore_last_delimiters = false;
+    }
+  } else if (typeof options.ignore_last_delimiters !== "boolean") {
+    throw new CsvError("CSV_INVALID_OPTION_IGNORE_LAST_DELIMITERS", [
+      "Invalid option `ignore_last_delimiters`:",
+      "the value must be a boolean value or an integer,",
+      `got ${JSON.stringify(options.ignore_last_delimiters)}`
+    ], options);
+  }
+  if (options.ignore_last_delimiters === true && options.columns === false) {
+    throw new CsvError("CSV_IGNORE_LAST_DELIMITERS_REQUIRES_COLUMNS", [
+      "The option `ignore_last_delimiters`",
+      "requires the activation of the `columns` option"
+    ], options);
+  }
+  if (options.info === void 0 || options.info === null || options.info === false) {
+    options.info = false;
+  } else if (options.info !== true) {
+    throw new Error(`Invalid Option: info must be true, got ${JSON.stringify(options.info)}`);
+  }
+  if (options.max_record_size === void 0 || options.max_record_size === null || options.max_record_size === false) {
+    options.max_record_size = 0;
+  } else if (Number.isInteger(options.max_record_size) && options.max_record_size >= 0) {
+  } else if (typeof options.max_record_size === "string" && /\d+/.test(options.max_record_size)) {
+    options.max_record_size = parseInt(options.max_record_size);
+  } else {
+    throw new Error(`Invalid Option: max_record_size must be a positive integer, got ${JSON.stringify(options.max_record_size)}`);
+  }
+  if (options.objname === void 0 || options.objname === null || options.objname === false) {
+    options.objname = void 0;
+  } else if (Buffer.isBuffer(options.objname)) {
+    if (options.objname.length === 0) {
+      throw new Error(`Invalid Option: objname must be a non empty buffer`);
+    }
+    if (options.encoding === null) {
+    } else {
+      options.objname = options.objname.toString(options.encoding);
+    }
+  } else if (typeof options.objname === "string") {
+    if (options.objname.length === 0) {
+      throw new Error(`Invalid Option: objname must be a non empty string`);
+    }
+  } else if (typeof options.objname === "number") {
+  } else {
+    throw new Error(`Invalid Option: objname must be a string or a buffer, got ${options.objname}`);
+  }
+  if (options.objname !== void 0) {
+    if (typeof options.objname === "number") {
+      if (options.columns !== false) {
+        throw Error("Invalid Option: objname index cannot be combined with columns or be defined as a field");
+      }
+    } else {
+      if (options.columns === false) {
+        throw Error("Invalid Option: objname field must be combined with columns or be defined as an index");
+      }
+    }
+  }
+  if (options.on_record === void 0 || options.on_record === null) {
+    options.on_record = void 0;
+  } else if (typeof options.on_record !== "function") {
+    throw new CsvError("CSV_INVALID_OPTION_ON_RECORD", [
+      "Invalid option `on_record`:",
+      "expect a function,",
+      `got ${JSON.stringify(options.on_record)}`
+    ], options);
+  }
+  if (options.on_skip !== void 0 && options.on_skip !== null && typeof options.on_skip !== "function") {
+    throw new Error(`Invalid Option: on_skip must be a function, got ${JSON.stringify(options.on_skip)}`);
+  }
+  if (options.quote === null || options.quote === false || options.quote === "") {
+    options.quote = null;
+  } else {
+    if (options.quote === void 0 || options.quote === true) {
+      options.quote = Buffer.from('"', options.encoding);
+    } else if (typeof options.quote === "string") {
+      options.quote = Buffer.from(options.quote, options.encoding);
+    }
+    if (!Buffer.isBuffer(options.quote)) {
+      throw new Error(`Invalid Option: quote must be a buffer or a string, got ${JSON.stringify(options.quote)}`);
+    }
+  }
+  if (options.raw === void 0 || options.raw === null || options.raw === false) {
+    options.raw = false;
+  } else if (options.raw !== true) {
+    throw new Error(`Invalid Option: raw must be true, got ${JSON.stringify(options.raw)}`);
+  }
+  if (options.record_delimiter === void 0) {
+    options.record_delimiter = [];
+  } else if (typeof options.record_delimiter === "string" || Buffer.isBuffer(options.record_delimiter)) {
+    if (options.record_delimiter.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a non empty string or buffer,",
+        `got ${JSON.stringify(options.record_delimiter)}`
+      ], options);
+    }
+    options.record_delimiter = [options.record_delimiter];
+  } else if (!Array.isArray(options.record_delimiter)) {
+    throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+      "Invalid option `record_delimiter`:",
+      "value must be a string, a buffer or array of string|buffer,",
+      `got ${JSON.stringify(options.record_delimiter)}`
+    ], options);
+  }
+  options.record_delimiter = options.record_delimiter.map(function(rd, i) {
+    if (typeof rd !== "string" && !Buffer.isBuffer(rd)) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a string, a buffer or array of string|buffer",
+        `at index ${i},`,
+        `got ${JSON.stringify(rd)}`
+      ], options);
+    } else if (rd.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a non empty string or buffer",
+        `at index ${i},`,
+        `got ${JSON.stringify(rd)}`
+      ], options);
+    }
+    if (typeof rd === "string") {
+      rd = Buffer.from(rd, options.encoding);
+    }
+    return rd;
+  });
+  if (typeof options.relax_column_count === "boolean") {
+  } else if (options.relax_column_count === void 0 || options.relax_column_count === null) {
+    options.relax_column_count = false;
+  } else {
+    throw new Error(`Invalid Option: relax_column_count must be a boolean, got ${JSON.stringify(options.relax_column_count)}`);
+  }
+  if (typeof options.relax_column_count_less === "boolean") {
+  } else if (options.relax_column_count_less === void 0 || options.relax_column_count_less === null) {
+    options.relax_column_count_less = false;
+  } else {
+    throw new Error(`Invalid Option: relax_column_count_less must be a boolean, got ${JSON.stringify(options.relax_column_count_less)}`);
+  }
+  if (typeof options.relax_column_count_more === "boolean") {
+  } else if (options.relax_column_count_more === void 0 || options.relax_column_count_more === null) {
+    options.relax_column_count_more = false;
+  } else {
+    throw new Error(`Invalid Option: relax_column_count_more must be a boolean, got ${JSON.stringify(options.relax_column_count_more)}`);
+  }
+  if (typeof options.relax_quotes === "boolean") {
+  } else if (options.relax_quotes === void 0 || options.relax_quotes === null) {
+    options.relax_quotes = false;
+  } else {
+    throw new Error(`Invalid Option: relax_quotes must be a boolean, got ${JSON.stringify(options.relax_quotes)}`);
+  }
+  if (typeof options.skip_empty_lines === "boolean") {
+  } else if (options.skip_empty_lines === void 0 || options.skip_empty_lines === null) {
+    options.skip_empty_lines = false;
+  } else {
+    throw new Error(`Invalid Option: skip_empty_lines must be a boolean, got ${JSON.stringify(options.skip_empty_lines)}`);
+  }
+  if (typeof options.skip_records_with_empty_values === "boolean") {
+  } else if (options.skip_records_with_empty_values === void 0 || options.skip_records_with_empty_values === null) {
+    options.skip_records_with_empty_values = false;
+  } else {
+    throw new Error(`Invalid Option: skip_records_with_empty_values must be a boolean, got ${JSON.stringify(options.skip_records_with_empty_values)}`);
+  }
+  if (typeof options.skip_records_with_error === "boolean") {
+  } else if (options.skip_records_with_error === void 0 || options.skip_records_with_error === null) {
+    options.skip_records_with_error = false;
+  } else {
+    throw new Error(`Invalid Option: skip_records_with_error must be a boolean, got ${JSON.stringify(options.skip_records_with_error)}`);
+  }
+  if (options.rtrim === void 0 || options.rtrim === null || options.rtrim === false) {
+    options.rtrim = false;
+  } else if (options.rtrim !== true) {
+    throw new Error(`Invalid Option: rtrim must be a boolean, got ${JSON.stringify(options.rtrim)}`);
+  }
+  if (options.ltrim === void 0 || options.ltrim === null || options.ltrim === false) {
+    options.ltrim = false;
+  } else if (options.ltrim !== true) {
+    throw new Error(`Invalid Option: ltrim must be a boolean, got ${JSON.stringify(options.ltrim)}`);
+  }
+  if (options.trim === void 0 || options.trim === null || options.trim === false) {
+    options.trim = false;
+  } else if (options.trim !== true) {
+    throw new Error(`Invalid Option: trim must be a boolean, got ${JSON.stringify(options.trim)}`);
+  }
+  if (options.trim === true && opts.ltrim !== false) {
+    options.ltrim = true;
+  } else if (options.ltrim !== true) {
+    options.ltrim = false;
+  }
+  if (options.trim === true && opts.rtrim !== false) {
+    options.rtrim = true;
+  } else if (options.rtrim !== true) {
+    options.rtrim = false;
+  }
+  if (options.to === void 0 || options.to === null) {
+    options.to = -1;
+  } else {
+    if (typeof options.to === "string" && /\d+/.test(options.to)) {
+      options.to = parseInt(options.to);
+    }
+    if (Number.isInteger(options.to)) {
+      if (options.to <= 0) {
+        throw new Error(`Invalid Option: to must be a positive integer greater than 0, got ${JSON.stringify(opts.to)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: to must be an integer, got ${JSON.stringify(opts.to)}`);
+    }
+  }
+  if (options.to_line === void 0 || options.to_line === null) {
+    options.to_line = -1;
+  } else {
+    if (typeof options.to_line === "string" && /\d+/.test(options.to_line)) {
+      options.to_line = parseInt(options.to_line);
+    }
+    if (Number.isInteger(options.to_line)) {
+      if (options.to_line <= 0) {
+        throw new Error(`Invalid Option: to_line must be a positive integer greater than 0, got ${JSON.stringify(opts.to_line)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: to_line must be an integer, got ${JSON.stringify(opts.to_line)}`);
+    }
+  }
+  return options;
+};
+
+// ../../node_modules/csv-parse/lib/api/index.js
+var isRecordEmpty = function(record) {
+  return record.every((field) => field == null || field.toString && field.toString().trim() === "");
+};
+var cr2 = 13;
+var nl2 = 10;
+var boms = {
+  // Note, the following are equals:
+  // Buffer.from("\ufeff")
+  // Buffer.from([239, 187, 191])
+  // Buffer.from('EFBBBF', 'hex')
+  "utf8": Buffer.from([239, 187, 191]),
+  // Note, the following are equals:
+  // Buffer.from "\ufeff", 'utf16le
+  // Buffer.from([255, 254])
+  "utf16le": Buffer.from([255, 254])
+};
+var transform = function(original_options = {}) {
+  const info = {
+    bytes: 0,
+    comment_lines: 0,
+    empty_lines: 0,
+    invalid_field_length: 0,
+    lines: 1,
+    records: 0
+  };
+  const options = normalize_options(original_options);
+  return {
+    info,
+    original_options,
+    options,
+    state: init_state(options),
+    __needMoreData: function(i, bufLen, end) {
+      if (end) return false;
+      const { encoding, escape, quote } = this.options;
+      const { quoting, needMoreDataSize, recordDelimiterMaxLength } = this.state;
+      const numOfCharLeft = bufLen - i - 1;
+      const requiredLength = Math.max(
+        needMoreDataSize,
+        // Skip if the remaining buffer smaller than record delimiter
+        // If "record_delimiter" is yet to be discovered:
+        // 1. It is equals to `[]` and "recordDelimiterMaxLength" equals `0`
+        // 2. We set the length to windows line ending in the current encoding
+        // Note, that encoding is known from user or bom discovery at that point
+        // recordDelimiterMaxLength,
+        recordDelimiterMaxLength === 0 ? Buffer.from("\r\n", encoding).length : recordDelimiterMaxLength,
+        // Skip if remaining buffer can be an escaped quote
+        quoting ? (escape === null ? 0 : escape.length) + quote.length : 0,
+        // Skip if remaining buffer can be record delimiter following the closing quote
+        quoting ? quote.length + recordDelimiterMaxLength : 0
+      );
+      return numOfCharLeft < requiredLength;
+    },
+    // Central parser implementation
+    parse: function(nextBuf, end, push, close) {
+      const { bom, comment_no_infix, encoding, from_line, ltrim, max_record_size, raw, relax_quotes, rtrim, skip_empty_lines, to, to_line } = this.options;
+      let { comment, escape, quote, record_delimiter } = this.options;
+      const { bomSkipped, previousBuf, rawBuffer, escapeIsQuote } = this.state;
+      let buf;
+      if (previousBuf === void 0) {
+        if (nextBuf === void 0) {
+          close();
+          return;
+        } else {
+          buf = nextBuf;
+        }
+      } else if (previousBuf !== void 0 && nextBuf === void 0) {
+        buf = previousBuf;
+      } else {
+        buf = Buffer.concat([previousBuf, nextBuf]);
+      }
+      if (bomSkipped === false) {
+        if (bom === false) {
+          this.state.bomSkipped = true;
+        } else if (buf.length < 3) {
+          if (end === false) {
+            this.state.previousBuf = buf;
+            return;
+          }
+        } else {
+          for (const encoding2 in boms) {
+            if (boms[encoding2].compare(buf, 0, boms[encoding2].length) === 0) {
+              const bomLength = boms[encoding2].length;
+              this.state.bufBytesStart += bomLength;
+              buf = buf.slice(bomLength);
+              this.options = normalize_options({ ...this.original_options, encoding: encoding2 });
+              ({ comment, escape, quote } = this.options);
+              break;
+            }
+          }
+          this.state.bomSkipped = true;
+        }
+      }
+      const bufLen = buf.length;
+      let pos;
+      for (pos = 0; pos < bufLen; pos++) {
+        if (this.__needMoreData(pos, bufLen, end)) {
+          break;
+        }
+        if (this.state.wasRowDelimiter === true) {
+          this.info.lines++;
+          this.state.wasRowDelimiter = false;
+        }
+        if (to_line !== -1 && this.info.lines > to_line) {
+          this.state.stop = true;
+          close();
+          return;
+        }
+        if (this.state.quoting === false && record_delimiter.length === 0) {
+          const record_delimiterCount = this.__autoDiscoverRecordDelimiter(buf, pos);
+          if (record_delimiterCount) {
+            record_delimiter = this.options.record_delimiter;
+          }
+        }
+        const chr = buf[pos];
+        if (raw === true) {
+          rawBuffer.append(chr);
+        }
+        if ((chr === cr2 || chr === nl2) && this.state.wasRowDelimiter === false) {
+          this.state.wasRowDelimiter = true;
+        }
+        if (this.state.escaping === true) {
+          this.state.escaping = false;
+        } else {
+          if (escape !== null && this.state.quoting === true && this.__isEscape(buf, pos, chr) && pos + escape.length < bufLen) {
+            if (escapeIsQuote) {
+              if (this.__isQuote(buf, pos + escape.length)) {
+                this.state.escaping = true;
+                pos += escape.length - 1;
+                continue;
+              }
+            } else {
+              this.state.escaping = true;
+              pos += escape.length - 1;
+              continue;
+            }
+          }
+          if (this.state.commenting === false && this.__isQuote(buf, pos)) {
+            if (this.state.quoting === true) {
+              const nextChr = buf[pos + quote.length];
+              const isNextChrTrimable = rtrim && this.__isCharTrimable(buf, pos + quote.length);
+              const isNextChrComment = comment !== null && this.__compareBytes(comment, buf, pos + quote.length, nextChr);
+              const isNextChrDelimiter = this.__isDelimiter(buf, pos + quote.length, nextChr);
+              const isNextChrRecordDelimiter = record_delimiter.length === 0 ? this.__autoDiscoverRecordDelimiter(buf, pos + quote.length) : this.__isRecordDelimiter(nextChr, buf, pos + quote.length);
+              if (escape !== null && this.__isEscape(buf, pos, chr) && this.__isQuote(buf, pos + escape.length)) {
+                pos += escape.length - 1;
+              } else if (!nextChr || isNextChrDelimiter || isNextChrRecordDelimiter || isNextChrComment || isNextChrTrimable) {
+                this.state.quoting = false;
+                this.state.wasQuoting = true;
+                pos += quote.length - 1;
+                continue;
+              } else if (relax_quotes === false) {
+                const err = this.__error(
+                  new CsvError("CSV_INVALID_CLOSING_QUOTE", [
+                    "Invalid Closing Quote:",
+                    `got "${String.fromCharCode(nextChr)}"`,
+                    `at line ${this.info.lines}`,
+                    "instead of delimiter, record delimiter, trimable character",
+                    "(if activated) or comment"
+                  ], this.options, this.__infoField())
+                );
+                if (err !== void 0) return err;
+              } else {
+                this.state.quoting = false;
+                this.state.wasQuoting = true;
+                this.state.field.prepend(quote);
+                pos += quote.length - 1;
+              }
+            } else {
+              if (this.state.field.length !== 0) {
+                if (relax_quotes === false) {
+                  const info2 = this.__infoField();
+                  const bom2 = Object.keys(boms).map((b) => boms[b].equals(this.state.field.toString()) ? b : false).filter(Boolean)[0];
+                  const err = this.__error(
+                    new CsvError("INVALID_OPENING_QUOTE", [
+                      "Invalid Opening Quote:",
+                      `a quote is found on field ${JSON.stringify(info2.column)} at line ${info2.lines}, value is ${JSON.stringify(this.state.field.toString(encoding))}`,
+                      bom2 ? `(${bom2} bom)` : void 0
+                    ], this.options, info2, {
+                      field: this.state.field
+                    })
+                  );
+                  if (err !== void 0) return err;
+                }
+              } else {
+                this.state.quoting = true;
+                pos += quote.length - 1;
+                continue;
+              }
+            }
+          }
+          if (this.state.quoting === false) {
+            const recordDelimiterLength = this.__isRecordDelimiter(chr, buf, pos);
+            if (recordDelimiterLength !== 0) {
+              const skipCommentLine = this.state.commenting && (this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0);
+              if (skipCommentLine) {
+                this.info.comment_lines++;
+              } else {
+                if (this.state.enabled === false && this.info.lines + (this.state.wasRowDelimiter === true ? 1 : 0) >= from_line) {
+                  this.state.enabled = true;
+                  this.__resetField();
+                  this.__resetRecord();
+                  pos += recordDelimiterLength - 1;
+                  continue;
+                }
+                if (skip_empty_lines === true && this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0) {
+                  this.info.empty_lines++;
+                  pos += recordDelimiterLength - 1;
+                  continue;
+                }
+                this.info.bytes = this.state.bufBytesStart + pos;
+                const errField = this.__onField();
+                if (errField !== void 0) return errField;
+                this.info.bytes = this.state.bufBytesStart + pos + recordDelimiterLength;
+                const errRecord = this.__onRecord(push);
+                if (errRecord !== void 0) return errRecord;
+                if (to !== -1 && this.info.records >= to) {
+                  this.state.stop = true;
+                  close();
+                  return;
+                }
+              }
+              this.state.commenting = false;
+              pos += recordDelimiterLength - 1;
+              continue;
+            }
+            if (this.state.commenting) {
+              continue;
+            }
+            if (comment !== null && (comment_no_infix === false || this.state.record.length === 0 && this.state.field.length === 0)) {
+              const commentCount = this.__compareBytes(comment, buf, pos, chr);
+              if (commentCount !== 0) {
+                this.state.commenting = true;
+                continue;
+              }
+            }
+            const delimiterLength = this.__isDelimiter(buf, pos, chr);
+            if (delimiterLength !== 0) {
+              this.info.bytes = this.state.bufBytesStart + pos;
+              const errField = this.__onField();
+              if (errField !== void 0) return errField;
+              pos += delimiterLength - 1;
+              continue;
+            }
+          }
+        }
+        if (this.state.commenting === false) {
+          if (max_record_size !== 0 && this.state.record_length + this.state.field.length > max_record_size) {
+            return this.__error(
+              new CsvError("CSV_MAX_RECORD_SIZE", [
+                "Max Record Size:",
+                "record exceed the maximum number of tolerated bytes",
+                `of ${max_record_size}`,
+                `at line ${this.info.lines}`
+              ], this.options, this.__infoField())
+            );
+          }
+        }
+        const lappend = ltrim === false || this.state.quoting === true || this.state.field.length !== 0 || !this.__isCharTrimable(buf, pos);
+        const rappend = rtrim === false || this.state.wasQuoting === false;
+        if (lappend === true && rappend === true) {
+          this.state.field.append(chr);
+        } else if (rtrim === true && !this.__isCharTrimable(buf, pos)) {
+          return this.__error(
+            new CsvError("CSV_NON_TRIMABLE_CHAR_AFTER_CLOSING_QUOTE", [
+              "Invalid Closing Quote:",
+              "found non trimable byte after quote",
+              `at line ${this.info.lines}`
+            ], this.options, this.__infoField())
+          );
+        } else {
+          if (lappend === false) {
+            pos += this.__isCharTrimable(buf, pos) - 1;
+          }
+          continue;
+        }
+      }
+      if (end === true) {
+        if (this.state.quoting === true) {
+          const err = this.__error(
+            new CsvError("CSV_QUOTE_NOT_CLOSED", [
+              "Quote Not Closed:",
+              `the parsing is finished with an opening quote at line ${this.info.lines}`
+            ], this.options, this.__infoField())
+          );
+          if (err !== void 0) return err;
+        } else {
+          if (this.state.wasQuoting === true || this.state.record.length !== 0 || this.state.field.length !== 0) {
+            this.info.bytes = this.state.bufBytesStart + pos;
+            const errField = this.__onField();
+            if (errField !== void 0) return errField;
+            const errRecord = this.__onRecord(push);
+            if (errRecord !== void 0) return errRecord;
+          } else if (this.state.wasRowDelimiter === true) {
+            this.info.empty_lines++;
+          } else if (this.state.commenting === true) {
+            this.info.comment_lines++;
+          }
+        }
+      } else {
+        this.state.bufBytesStart += pos;
+        this.state.previousBuf = buf.slice(pos);
+      }
+      if (this.state.wasRowDelimiter === true) {
+        this.info.lines++;
+        this.state.wasRowDelimiter = false;
+      }
+    },
+    __onRecord: function(push) {
+      const { columns, group_columns_by_name, encoding, info: info2, from, relax_column_count, relax_column_count_less, relax_column_count_more, raw, skip_records_with_empty_values } = this.options;
+      const { enabled, record } = this.state;
+      if (enabled === false) {
+        return this.__resetRecord();
+      }
+      const recordLength = record.length;
+      if (columns === true) {
+        if (skip_records_with_empty_values === true && isRecordEmpty(record)) {
+          this.__resetRecord();
+          return;
+        }
+        return this.__firstLineToColumns(record);
+      }
+      if (columns === false && this.info.records === 0) {
+        this.state.expectedRecordLength = recordLength;
+      }
+      if (recordLength !== this.state.expectedRecordLength) {
+        const err = columns === false ? new CsvError("CSV_RECORD_INCONSISTENT_FIELDS_LENGTH", [
+          "Invalid Record Length:",
+          `expect ${this.state.expectedRecordLength},`,
+          `got ${recordLength} on line ${this.info.lines}`
+        ], this.options, this.__infoField(), {
+          record
+        }) : new CsvError("CSV_RECORD_INCONSISTENT_COLUMNS", [
+          "Invalid Record Length:",
+          `columns length is ${columns.length},`,
+          // rename columns
+          `got ${recordLength} on line ${this.info.lines}`
+        ], this.options, this.__infoField(), {
+          record
+        });
+        if (relax_column_count === true || relax_column_count_less === true && recordLength < this.state.expectedRecordLength || relax_column_count_more === true && recordLength > this.state.expectedRecordLength) {
+          this.info.invalid_field_length++;
+          this.state.error = err;
+        } else {
+          const finalErr = this.__error(err);
+          if (finalErr) return finalErr;
+        }
+      }
+      if (skip_records_with_empty_values === true && isRecordEmpty(record)) {
+        this.__resetRecord();
+        return;
+      }
+      if (this.state.recordHasError === true) {
+        this.__resetRecord();
+        this.state.recordHasError = false;
+        return;
+      }
+      this.info.records++;
+      if (from === 1 || this.info.records >= from) {
+        const { objname } = this.options;
+        if (columns !== false) {
+          const obj = {};
+          for (let i = 0, l = record.length; i < l; i++) {
+            if (columns[i] === void 0 || columns[i].disabled) continue;
+            if (group_columns_by_name === true && obj[columns[i].name] !== void 0) {
+              if (Array.isArray(obj[columns[i].name])) {
+                obj[columns[i].name] = obj[columns[i].name].concat(record[i]);
+              } else {
+                obj[columns[i].name] = [obj[columns[i].name], record[i]];
+              }
+            } else {
+              obj[columns[i].name] = record[i];
+            }
+          }
+          if (raw === true || info2 === true) {
+            const extRecord = Object.assign(
+              { record: obj },
+              raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {},
+              info2 === true ? { info: this.__infoRecord() } : {}
+            );
+            const err = this.__push(
+              objname === void 0 ? extRecord : [obj[objname], extRecord],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          } else {
+            const err = this.__push(
+              objname === void 0 ? obj : [obj[objname], obj],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          }
+        } else {
+          if (raw === true || info2 === true) {
+            const extRecord = Object.assign(
+              { record },
+              raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {},
+              info2 === true ? { info: this.__infoRecord() } : {}
+            );
+            const err = this.__push(
+              objname === void 0 ? extRecord : [record[objname], extRecord],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          } else {
+            const err = this.__push(
+              objname === void 0 ? record : [record[objname], record],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          }
+        }
+      }
+      this.__resetRecord();
+    },
+    __firstLineToColumns: function(record) {
+      const { firstLineToHeaders } = this.state;
+      try {
+        const headers = firstLineToHeaders === void 0 ? record : firstLineToHeaders.call(null, record);
+        if (!Array.isArray(headers)) {
+          return this.__error(
+            new CsvError("CSV_INVALID_COLUMN_MAPPING", [
+              "Invalid Column Mapping:",
+              "expect an array from column function,",
+              `got ${JSON.stringify(headers)}`
+            ], this.options, this.__infoField(), {
+              headers
+            })
+          );
+        }
+        const normalizedHeaders = normalize_columns_array(headers);
+        this.state.expectedRecordLength = normalizedHeaders.length;
+        this.options.columns = normalizedHeaders;
+        this.__resetRecord();
+        return;
+      } catch (err) {
+        return err;
+      }
+    },
+    __resetRecord: function() {
+      if (this.options.raw === true) {
+        this.state.rawBuffer.reset();
+      }
+      this.state.error = void 0;
+      this.state.record = [];
+      this.state.record_length = 0;
+    },
+    __onField: function() {
+      const { cast, encoding, rtrim, max_record_size } = this.options;
+      const { enabled, wasQuoting } = this.state;
+      if (enabled === false) {
+        return this.__resetField();
+      }
+      let field = this.state.field.toString(encoding);
+      if (rtrim === true && wasQuoting === false) {
+        field = field.trimRight();
+      }
+      if (cast === true) {
+        const [err, f] = this.__cast(field);
+        if (err !== void 0) return err;
+        field = f;
+      }
+      this.state.record.push(field);
+      if (max_record_size !== 0 && typeof field === "string") {
+        this.state.record_length += field.length;
+      }
+      this.__resetField();
+    },
+    __resetField: function() {
+      this.state.field.reset();
+      this.state.wasQuoting = false;
+    },
+    __push: function(record, push) {
+      const { on_record } = this.options;
+      if (on_record !== void 0) {
+        const info2 = this.__infoRecord();
+        try {
+          record = on_record.call(null, record, info2);
+        } catch (err) {
+          return err;
+        }
+        if (record === void 0 || record === null) {
+          return;
+        }
+      }
+      push(record);
+    },
+    // Return a tuple with the error and the casted value
+    __cast: function(field) {
+      const { columns, relax_column_count } = this.options;
+      const isColumns = Array.isArray(columns);
+      if (isColumns === true && relax_column_count && this.options.columns.length <= this.state.record.length) {
+        return [void 0, void 0];
+      }
+      if (this.state.castField !== null) {
+        try {
+          const info2 = this.__infoField();
+          return [void 0, this.state.castField.call(null, field, info2)];
+        } catch (err) {
+          return [err];
+        }
+      }
+      if (this.__isFloat(field)) {
+        return [void 0, parseFloat(field)];
+      } else if (this.options.cast_date !== false) {
+        const info2 = this.__infoField();
+        return [void 0, this.options.cast_date.call(null, field, info2)];
+      }
+      return [void 0, field];
+    },
+    // Helper to test if a character is a space or a line delimiter
+    __isCharTrimable: function(buf, pos) {
+      const isTrim = (buf2, pos2) => {
+        const { timchars } = this.state;
+        loop1: for (let i = 0; i < timchars.length; i++) {
+          const timchar = timchars[i];
+          for (let j = 0; j < timchar.length; j++) {
+            if (timchar[j] !== buf2[pos2 + j]) continue loop1;
+          }
+          return timchar.length;
+        }
+        return 0;
+      };
+      return isTrim(buf, pos);
+    },
+    // Keep it in case we implement the `cast_int` option
+    // __isInt(value){
+    //   // return Number.isInteger(parseInt(value))
+    //   // return !isNaN( parseInt( obj ) );
+    //   return /^(\-|\+)?[1-9][0-9]*$/.test(value)
+    // }
+    __isFloat: function(value) {
+      return value - parseFloat(value) + 1 >= 0;
+    },
+    __compareBytes: function(sourceBuf, targetBuf, targetPos, firstByte) {
+      if (sourceBuf[0] !== firstByte) return 0;
+      const sourceLength = sourceBuf.length;
+      for (let i = 1; i < sourceLength; i++) {
+        if (sourceBuf[i] !== targetBuf[targetPos + i]) return 0;
+      }
+      return sourceLength;
+    },
+    __isDelimiter: function(buf, pos, chr) {
+      const { delimiter, ignore_last_delimiters } = this.options;
+      if (ignore_last_delimiters === true && this.state.record.length === this.options.columns.length - 1) {
+        return 0;
+      } else if (ignore_last_delimiters !== false && typeof ignore_last_delimiters === "number" && this.state.record.length === ignore_last_delimiters - 1) {
+        return 0;
+      }
+      loop1: for (let i = 0; i < delimiter.length; i++) {
+        const del = delimiter[i];
+        if (del[0] === chr) {
+          for (let j = 1; j < del.length; j++) {
+            if (del[j] !== buf[pos + j]) continue loop1;
+          }
+          return del.length;
+        }
+      }
+      return 0;
+    },
+    __isRecordDelimiter: function(chr, buf, pos) {
+      const { record_delimiter } = this.options;
+      const recordDelimiterLength = record_delimiter.length;
+      loop1: for (let i = 0; i < recordDelimiterLength; i++) {
+        const rd = record_delimiter[i];
+        const rdLength = rd.length;
+        if (rd[0] !== chr) {
+          continue;
+        }
+        for (let j = 1; j < rdLength; j++) {
+          if (rd[j] !== buf[pos + j]) {
+            continue loop1;
+          }
+        }
+        return rd.length;
+      }
+      return 0;
+    },
+    __isEscape: function(buf, pos, chr) {
+      const { escape } = this.options;
+      if (escape === null) return false;
+      const l = escape.length;
+      if (escape[0] === chr) {
+        for (let i = 0; i < l; i++) {
+          if (escape[i] !== buf[pos + i]) {
+            return false;
+          }
+        }
+        return true;
+      }
+      return false;
+    },
+    __isQuote: function(buf, pos) {
+      const { quote } = this.options;
+      if (quote === null) return false;
+      const l = quote.length;
+      for (let i = 0; i < l; i++) {
+        if (quote[i] !== buf[pos + i]) {
+          return false;
+        }
+      }
+      return true;
+    },
+    __autoDiscoverRecordDelimiter: function(buf, pos) {
+      const { encoding } = this.options;
+      const rds = [
+        // Important, the windows line ending must be before mac os 9
+        Buffer.from("\r\n", encoding),
+        Buffer.from("\n", encoding),
+        Buffer.from("\r", encoding)
+      ];
+      loop: for (let i = 0; i < rds.length; i++) {
+        const l = rds[i].length;
+        for (let j = 0; j < l; j++) {
+          if (rds[i][j] !== buf[pos + j]) {
+            continue loop;
+          }
+        }
+        this.options.record_delimiter.push(rds[i]);
+        this.state.recordDelimiterMaxLength = rds[i].length;
+        return rds[i].length;
+      }
+      return 0;
+    },
+    __error: function(msg) {
+      const { encoding, raw, skip_records_with_error } = this.options;
+      const err = typeof msg === "string" ? new Error(msg) : msg;
+      if (skip_records_with_error) {
+        this.state.recordHasError = true;
+        if (this.options.on_skip !== void 0) {
+          this.options.on_skip(err, raw ? this.state.rawBuffer.toString(encoding) : void 0);
+        }
+        return void 0;
+      } else {
+        return err;
+      }
+    },
+    __infoDataSet: function() {
+      return {
+        ...this.info,
+        columns: this.options.columns
+      };
+    },
+    __infoRecord: function() {
+      const { columns, raw, encoding } = this.options;
+      return {
+        ...this.__infoDataSet(),
+        error: this.state.error,
+        header: columns === true,
+        index: this.state.record.length,
+        raw: raw ? this.state.rawBuffer.toString(encoding) : void 0
+      };
+    },
+    __infoField: function() {
+      const { columns } = this.options;
+      const isColumns = Array.isArray(columns);
+      return {
+        ...this.__infoRecord(),
+        column: isColumns === true ? columns.length > this.state.record.length ? columns[this.state.record.length].name : null : this.state.record.length,
+        quoting: this.state.wasQuoting
+      };
+    }
+  };
+};
+
+// ../../node_modules/csv-parse/lib/index.js
+var Parser = class extends import_stream.Transform {
+  constructor(opts = {}) {
+    super({ ...{ readableObjectMode: true }, ...opts, encoding: null });
+    this.api = transform({ on_skip: (err, chunk) => {
+      this.emit("skip", err, chunk);
+    }, ...opts });
+    this.state = this.api.state;
+    this.options = this.api.options;
+    this.info = this.api.info;
+  }
+  // Implementation of `Transform._transform`
+  _transform(buf, _, callback) {
+    if (this.state.stop === true) {
+      return;
+    }
+    const err = this.api.parse(buf, false, (record) => {
+      this.push(record);
+    }, () => {
+      this.push(null);
+      this.end();
+      this.on("end", this.destroy);
+    });
+    if (err !== void 0) {
+      this.state.stop = true;
+    }
+    callback(err);
+  }
+  // Implementation of `Transform._flush`
+  _flush(callback) {
+    if (this.state.stop === true) {
+      return;
+    }
+    const err = this.api.parse(void 0, true, (record) => {
+      this.push(record);
+    }, () => {
+      this.push(null);
+      this.on("end", this.destroy);
+    });
+    callback(err);
+  }
+};
+var parse = function() {
+  let data, options, callback;
+  for (const i in arguments) {
+    const argument = arguments[i];
+    const type = typeof argument;
+    if (data === void 0 && (typeof argument === "string" || Buffer.isBuffer(argument))) {
+      data = argument;
+    } else if (options === void 0 && is_object(argument)) {
+      options = argument;
+    } else if (callback === void 0 && type === "function") {
+      callback = argument;
+    } else {
+      throw new CsvError("CSV_INVALID_ARGUMENT", [
+        "Invalid argument:",
+        `got ${JSON.stringify(argument)} at index ${i}`
+      ], options || {});
+    }
+  }
+  const parser = new Parser(options);
+  if (callback) {
+    const records = options === void 0 || options.objname === void 0 ? [] : {};
+    parser.on("readable", function() {
+      let record;
+      while ((record = this.read()) !== null) {
+        if (options === void 0 || options.objname === void 0) {
+          records.push(record);
+        } else {
+          records[record[0]] = record[1];
+        }
+      }
+    });
+    parser.on("error", function(err) {
+      callback(err, void 0, parser.api.__infoDataSet());
+    });
+    parser.on("end", function() {
+      callback(void 0, records, parser.api.__infoDataSet());
+    });
+  }
+  if (data !== void 0) {
+    const writer = function() {
+      parser.write(data);
+      parser.end();
+    };
+    if (typeof setImmediate === "function") {
+      setImmediate(writer);
+    } else {
+      setTimeout(writer, 0);
+    }
+  }
+  return parser;
+};
+
+// src/utils.ts
+var PSYCHDS_IGNORE_FILENAME = ".psychds-ignore";
+var PSYCHDS_IGNORE_CONTENT = "**/raw/\n.psychds-ignore\n";
+function saveTextToFile(textstr, filename) {
+  const blobToSave = new Blob([textstr], {
+    type: "text/plain"
+  });
+  let blobURL = "";
+  if (typeof window.webkitURL !== "undefined") {
+    blobURL = window.webkitURL.createObjectURL(blobToSave);
+  } else {
+    blobURL = window.URL.createObjectURL(blobToSave);
+  }
+  const link = document.createElement("a");
+  link.id = "jspsych-download-as-text-link";
+  link.style.display = "none";
+  link.download = filename;
+  link.href = blobURL;
+  link.click();
+}
+function tryParseJSON(value) {
+  try {
+    return JSON.parse(value);
+  } catch {
+    return null;
+  }
+}
+function unwrapTrials(data) {
+  const parsed = typeof data === "string" ? JSON.parse(data) : data;
+  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
+    const keys = Object.keys(parsed);
+    if (keys.length === 1 && keys[0] === "trials" && Array.isArray(parsed.trials)) {
+      return parsed.trials;
+    }
+  }
+  return parsed;
+}
+function parseJsonData(content, options = {}, stats) {
+  if (content.charCodeAt(0) === 65279) content = content.slice(1);
+  const whole = tryParseJSON(content);
+  if (whole !== null) return unwrapTrials(whole);
+  const lines = content.split(/\r?\n/);
+  const out = [];
+  let parsedAny = false;
+  let recordIndex = 0;
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim();
+    if (!line) continue;
+    let value;
+    try {
+      value = JSON.parse(line);
+    } catch {
+      throw new Error(
+        `Could not parse data as JSON or JSON-Lines: line ${i + 1} is not valid JSON.`
+      );
+    }
+    parsedAny = true;
+    const observations = Array.isArray(value) ? value : [value];
+    if (options.tagSourceRecordId) {
+      for (const obs of observations) {
+        if (obs !== null && typeof obs === "object" && !Array.isArray(obs) && !("source_record_id" in obs) && !("participant_id" in obs)) {
+          obs.source_record_id = recordIndex;
+          if (stats) stats.synthesizedSourceRecordId = true;
+        }
+      }
+    }
+    out.push(...observations);
+    recordIndex++;
+  }
+  if (!parsedAny) {
+    throw new Error("Could not parse data: input is empty or not valid JSON/JSON-Lines.");
+  }
+  return out;
+}
+var SYSTEM_COLUMNS = /* @__PURE__ */ new Set([
+  "trial_type",
+  "trial_index",
+  "time_elapsed",
+  "extension_type",
+  "extension_version"
+]);
+function analyzeJoinKeys(parsedData, keys) {
+  if (parsedData.length === 0) {
+    return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+  }
+  const compositeKeys = parsedData.map(
+    (row) => keys.map((k) => String(row[k] ?? "")).join("\0")
+  );
+  const keyCount = /* @__PURE__ */ new Map();
+  for (const ck of compositeKeys) keyCount.set(ck, (keyCount.get(ck) ?? 0) + 1);
+  const duplicateCount = [...keyCount.values()].reduce((n, c) => n + (c > 1 ? c - 1 : 0), 0);
+  const isUnique = duplicateCount === 0;
+  const duplicateValues = [];
+  for (let i = 0; i < parsedData.length && duplicateValues.length < 5; i++) {
+    if ((keyCount.get(compositeKeys[i]) ?? 0) > 1) {
+      const vals = keys.reduce((acc, k) => {
+        acc[k] = parsedData[i][k];
+        return acc;
+      }, {});
+      if (!duplicateValues.some((v) => JSON.stringify(v) === JSON.stringify(vals))) {
+        duplicateValues.push(vals);
+      }
+    }
+  }
+  if (isUnique) {
+    return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+  }
+  const keySet = new Set(keys);
+  const allColumns = /* @__PURE__ */ new Set();
+  for (const row of parsedData) for (const col of Object.keys(row)) allColumns.add(col);
+  const candidateColumns = [...allColumns].filter(
+    (col) => !isUnnamedHeader(col) && !keySet.has(col) && !SYSTEM_COLUMNS.has(col)
+  );
+  const candidates = candidateColumns.map((col) => {
+    const extended = parsedData.map(
+      (row) => [...keys, col].map((k) => String(row[k] ?? "")).join("\0")
+    );
+    return { column: col, makesUnique: new Set(extended).size === parsedData.length };
+  });
+  if (candidates.some((c) => c.makesUnique)) {
+    return { isUnique, duplicateCount, duplicateValues, candidates, suggestedAdditionalKeys: [] };
+  }
+  const workingKeys = [...keys];
+  const available = [...candidateColumns];
+  while (available.length > 0) {
+    const current = parsedData.map(
+      (row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0")
+    );
+    if (new Set(current).size === parsedData.length) break;
+    let bestCol = null;
+    let bestCount = new Set(current).size;
+    for (const col of available) {
+      const test = parsedData.map(
+        (row) => [...workingKeys, col].map((k) => String(row[k] ?? "")).join("\0")
+      );
+      const count = new Set(test).size;
+      if (count > bestCount) {
+        bestCount = count;
+        bestCol = col;
+      }
+    }
+    if (bestCol === null) break;
+    workingKeys.push(bestCol);
+    available.splice(available.indexOf(bestCol), 1);
+  }
+  const added = workingKeys.slice(keys.length);
+  const greedyIsUnique = new Set(
+    parsedData.map((row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0"))
+  ).size === parsedData.length;
+  return {
+    isUnique,
+    duplicateCount,
+    duplicateValues,
+    candidates,
+    suggestedAdditionalKeys: added.length > 0 && greedyIsUnique ? added : null
+  };
+}
+var PSYCH_DS_FILENAME_RE = /^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\.(csv|tsv)$/;
+function isValidPsychDSDataFilename(name) {
+  return PSYCH_DS_FILENAME_RE.test(name);
+}
+function toPsychDSValue(name, fallback = "value") {
+  const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
+  if (parts.length === 0) return fallback;
+  return parts[0] + parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
+}
+function deriveFallbackBase(stem) {
+  return `subject-${toPsychDSValue(stem, "file")}`;
+}
+function deriveArrayFilename(parentBase, columnName) {
+  return `${parentBase}_measure-${toPsychDSValue(columnName, "col")}_data.csv`;
+}
+function objectsToCSV(rows, priorityCols = ["trial_index", "element_index"]) {
+  if (rows.length === 0) return "";
+  const allKeys = /* @__PURE__ */ new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row)) allKeys.add(key);
+  }
+  const otherCols = [...allKeys].filter((k) => !priorityCols.includes(k));
+  const headers = [...priorityCols.filter((c) => allKeys.has(c)), ...otherCols];
+  const escape = (val) => {
+    if (val === null || val === void 0) return "";
+    const str = typeof val === "object" ? JSON.stringify(val) : String(val);
+    return str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r") ? `"${str.replace(/"/g, '""')}"` : str;
+  };
+  const lines = [headers.join(",")];
+  for (const row of rows) {
+    lines.push(headers.map((h) => escape(row[h])).join(","));
+  }
+  return lines.join("\r\n");
+}
+function disambiguateArrayFilename(base, used) {
+  if (!used.has(base)) return base;
+  const suffix = "_data.csv";
+  const root = base.endsWith(suffix) ? base.slice(0, -suffix.length) : base.replace(/\.csv$/i, "");
+  let n = 2;
+  let candidate = `${root}${n}${suffix}`;
+  while (used.has(candidate)) {
+    n += 1;
+    candidate = `${root}${n}${suffix}`;
+  }
+  return candidate;
+}
+var isUnnamedHeader = (key) => key.trim() === "";
+function hasUnnamedColumns(rows) {
+  return rows.some((row) => Object.keys(row).some(isUnnamedHeader));
+}
+function stripUnnamedColumns(rows) {
+  const unnamed = /* @__PURE__ */ new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row)) {
+      if (isUnnamedHeader(key)) unnamed.add(key);
+    }
+  }
+  if (unnamed.size > 0) {
+    for (const row of rows) {
+      for (const key of unnamed) delete row[key];
+    }
+  }
+  return { rows, dropped: [...unnamed] };
+}
+function buildPsychDSDataFiles(args) {
+  const {
+    base,
+    mainRows,
+    mainContent,
+    extractedArrays = /* @__PURE__ */ new Map(),
+    extractedObjects = /* @__PURE__ */ new Map(),
+    joinKeys = ["trial_index"],
+    usedArrayFilenames = /* @__PURE__ */ new Set()
+  } = args;
+  const out = [];
+  const reserve = (name) => {
+    if (!isValidPsychDSDataFilename(name)) {
+      throw new Error(`Refusing to write non-Psych-DS-compliant data filename "${name}".`);
+    }
+    usedArrayFilenames.add(name);
+    return name;
+  };
+  const mainName = reserve(disambiguateArrayFilename(`${base}_data.csv`, usedArrayFilenames));
+  const { rows: cleanedMainRows, dropped: droppedMain } = stripUnnamedColumns(mainRows);
+  out.push({
+    filename: mainName,
+    content: mainContent !== void 0 && droppedMain.length === 0 ? mainContent : objectsToCSV(cleanedMainRows, ["trial_index"]),
+    kind: "main"
+  });
+  const arrayPriority = [...joinKeys, "element_index"];
+  for (const [colName, rows] of extractedArrays) {
+    const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+    out.push({ filename: name, content: objectsToCSV(rows, arrayPriority), kind: "array" });
+  }
+  for (const [colName, rows] of extractedObjects) {
+    const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+    out.push({ filename: name, content: objectsToCSV(rows, joinKeys), kind: "object" });
+  }
+  return out;
+}
+async function parseCSV(input) {
+  if (!parse) {
+    throw new Error("Parser module not loaded");
+  }
+  return new Promise((resolve, reject) => {
+    parse(input, {
+      columns: true,
+      // Treat the first row as headers
+      delimiter: ",",
+      // Specify the delimiter (e.g., comma)
+      bom: true
+      // Strip a leading UTF-8 BOM so the first header name isn't corrupted (e.g. "Participant_ID")
+    }, (err, records) => {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(records);
+      }
+    });
+  });
+}
+
+// src/VariablesMap.ts
+var VariablesMap = class _VariablesMap {
+  /**
+   *  Creates the VariablesMap by initialising an empty variable map. The jsPsych system
+   * variables (trial_type, trial_index, time_elapsed, extension_*) are NOT seeded here — they
+   * are registered lazily when their column is actually observed in the data (see
+   * {@link registerSystemVariable}). Seeding them unconditionally produced orphan
+   * variableMeasured entries (e.g. time_elapsed) for datasets that omit those columns, which
+   * fails Psych-DS validation (VARIABLE_MISSING_FROM_CSV_COLUMNS).
+   *
+   * @constructor
+   */
+  constructor() {
+    this.generateDefaultVariables();
+  }
+  /**
+   * The fixed jsPsych definition for a system column, or null if `name` is not a known system
+   * variable. Returns a fresh object on each call so callers never share/mutate one template.
+   */
+  static systemVariableTemplate(name) {
+    switch (name) {
+      case "trial_type":
+        return {
+          "@type": "PropertyValue",
+          name: "trial_type",
+          description: { default: "unknown", jsPsych: "The name of the plugin used to run the trial." },
+          value: "string"
+        };
+      case "trial_index":
+        return {
+          "@type": "PropertyValue",
+          name: "trial_index",
+          description: { default: "unknown", jsPsych: "The index of the current trial across the whole experiment." },
+          value: "number"
+        };
+      case "time_elapsed":
+        return {
+          "@type": "PropertyValue",
+          name: "time_elapsed",
+          description: {
+            default: "unknown",
+            jsPsych: "The number of milliseconds between the start of the experiment and when the trial ended."
+          },
+          value: "number"
+        };
+      case "extension_type":
+        return {
+          "@type": "PropertyValue",
+          name: "extension_type",
+          description: { default: "unknown", jsPsych: "The name(s) of the extension(s) used in the trial." },
+          value: "string"
+        };
+      case "extension_version":
+        return {
+          "@type": "PropertyValue",
+          name: "extension_version",
+          description: { default: "unknown", jsPsych: "The version(s) of the extension(s) used in the trial." },
+          value: "number"
+        };
+      default:
+        return null;
+    }
+  }
+  /**
+   * Lazily registers the default jsPsych definition for a system column the first time it is
+   * observed in the data. No-op (returns false) when `name` is not a known system variable or
+   * is already present; returns true when a new variable was registered. This is what keeps a
+   * system variable out of variableMeasured unless the data actually contains that column.
+   *
+   * @param {string} name - The column / system-variable name.
+   * @returns {boolean} - True if a variable was registered, false otherwise.
+   */
+  registerSystemVariable(name) {
+    if (this.containsVariable(name)) return false;
+    const template = _VariablesMap.systemVariableTemplate(name);
+    if (!template) return false;
+    this.setVariable(template);
+    return true;
+  }
+  /**
+   * Initialises the variable map. System variables are registered lazily (see the constructor
+   * and {@link registerSystemVariable}), so this just resets the map to empty.
+   */
+  generateDefaultVariables() {
+    this.variables = {};
+  }
+  /**
+   * Returns a list of the variables instead of an object according to the Psych-DS format.
+   *
+   * @returns {{}[]} - The list of variables represented as objects.
+   */
+  getList() {
+    var var_list = [];
+    for (const key of Object.keys(this.variables)) {
+      const variable = this.variables[key];
+      variable["description"] = this.collapseDescription(variable["description"]);
+      var_list.push(variable);
+    }
+    return var_list;
+  }
+  /**
+   * Collapses an internal { pluginType: description } map into a single schema.org-valid
+   * Text value. Descriptions are stored per-plugin and only ever hold multiple keys when the
+   * texts genuinely differ (identical texts are merged upstream in updateDescription). Psych-DS /
+   * schema.org require `description` to be Text, so an object value triggers an OBJECT_TYPE_MISSING
+   * validator warning — this folds everything down to a string.
+   *
+   * @private
+   * @param {*} description - The description value (a { pluginType: text } map, or already a string).
+   * @returns {string} - A single Text description.
+   */
+  collapseDescription(description) {
+    if (typeof description !== "object" || description === null) {
+      return description;
+    }
+    if (Object.keys(description).length === 0) {
+      console.error("Empty description");
+      return "unknown";
+    }
+    if (Object.keys(description).length > 1 && "default" in description) {
+      delete description["default"];
+    }
+    for (const descKey of Object.keys(description)) {
+      if (description[descKey] === "unknown" && Object.keys(description).length > 1) {
+        delete description[descKey];
+      }
+    }
+    return Object.values(description).join(" | ");
+  }
+  /**
+   * Allows user to set a variable and includes all the fields that are possible according to
+   * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable.
+   * Can also be used to overwrite existing variables if they have the same name.
+   *
+   * @param {VariableFields} variable - The fields of the variable that is being created.
+   */
+  setVariable(variable) {
+    if (!variable.name) {
+      console.warn("Name field is missing. Variable not added.", variable);
+      return;
+    }
+    this.variables[variable.name] = variable;
+    const unexpectedFields = Object.keys(variable).filter(
+      (key) => ![
+        "@type",
+        "name",
+        "description",
+        "value",
+        "identifier",
+        "minValue",
+        "maxValue",
+        "levels",
+        "levelsOrdered",
+        "na",
+        "naValue",
+        "alternateName",
+        "privacy"
+      ].includes(key)
+    );
+    if (unexpectedFields.length > 0) {
+      console.warn(
+        `Unexpected fields (${unexpectedFields.join(
+          ", "
+        )}) detected and included in the variable object.`
+      );
+    }
+  }
+  /**
+   * Allows you to get information for a single variable returning empty dict if it doesn't exist.
+   * Allows you to update fields but not recommended in favor of updateVariable.
+   *
+   * @param {string} name
+   * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist
+   */
+  getVariable(name) {
+    return this.variables[name] || {};
+  }
+  /**
+   * Checks if variable exists in VariablesMap.
+   *
+   * @param {string} name - Name of variable
+   * @returns {boolean} - True if exists, false if doesn't.
+   */
+  containsVariable(name) {
+    return name in this.variables;
+  }
+  /**
+   * Method that gets a list of the names of variables.
+   *
+   * @returns {string[]} - String list containing names of existing variables.
+   */
+  getVariableNames() {
+    var var_list = [];
+    for (const key of Object.keys(this.variables)) {
+      var_list.push(this.variables[key]["name"]);
+    }
+    return var_list;
+  }
+  /**
+   * Allows you to update a variable or add a value in the case of updating values. In other situations will
+   * replace the existing value with the new value. Has special cases and logic for levels and names making it
+   * easier to update variable values.
+   *
+   *
+   * @param {string} var_name - Name of variable to be updated.
+   * @param {string} field_name - Specific field to be updated.
+   * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType.
+   */
+  updateVariable(var_name, field_name, added_value) {
+    const updated_var = this.getVariable(var_name);
+    if (Object.keys(updated_var).length === 0) {
+      console.error(`Variable "${var_name}" does not exist.`);
+      return;
+    }
+    if (field_name === "levels") {
+      this.updateLevels(updated_var, added_value);
+    } else if (field_name === "minValue" || field_name === "maxValue") {
+      this.updateMinMax(updated_var, added_value, field_name);
+    } else if (field_name === "description") {
+      this.updateDescription(updated_var, added_value);
+    } else if (field_name === "name") {
+      this.updateName(updated_var, added_value);
+    } else {
+      updated_var[field_name] = added_value;
+    }
+  }
+  /**
+   * Logic that handles updates to levels field by creating new array if necessary, otherwise
+   * pushing the value if it doesn't already exist. Levels can only be added to with strings.
+   *
+   * @private
+   * @param {*} updated_var - The variable object to be updated.
+   * @param {*} added_value - The value being added to the levels field.
+   */
+  updateLevels(updated_var, added_value) {
+    if (typeof added_value === "object")
+      return;
+    const MAX_LENGTH = 50;
+    if (added_value.length > MAX_LENGTH) {
+      added_value = added_value.substring(0, MAX_LENGTH) + "...";
+    }
+    if (!Array.isArray(updated_var["levels"])) {
+      updated_var["levels"] = [];
+    }
+    if (!updated_var["levels"].includes(added_value)) {
+      updated_var["levels"].push(added_value);
+    }
+  }
+  /**
+   * Logic to update the min and max for the specific value.
+   *
+   * @private
+   * @param {*} updated_var - The variable object to be updated.
+   * @param {*} added_value - The value that is being checked against current min/max.
+   * @param {*} field_name - The name of field that is being checked (min or max).
+   */
+  updateMinMax(updated_var, added_value, field_name) {
+    if (!("minValue" in updated_var) || !("maxValue" in updated_var)) {
+      updated_var["maxValue"] = updated_var["minValue"] = added_value;
+      return;
+    }
+    if (field_name === "minValue" && updated_var["minValue"] > added_value) {
+      updated_var["minValue"] = added_value;
+    } else if (field_name === "maxValue" && updated_var["maxValue"] < added_value) {
+      updated_var["maxValue"] = added_value;
+    }
+  }
+  /**
+   * Logic for updating description field that checks to see value already exists. If it does,
+   * appends the pluginType to the current key and pushes that along with the value. Creates
+   * map if it does not exist.
+   *
+   * @private
+   * @param {*} updated_var - The variable to be updated.
+   * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field.
+   */
+  updateDescription(updated_var, added_value) {
+    const add_key = Object.keys(added_value)[0];
+    const add_value = Object.values(added_value)[0];
+    if (add_key === "undefined" || add_value === "undefined") {
+      console.error("New value is passed in bad format", added_value);
+      return;
+    }
+    var exists = false;
+    if (typeof updated_var["description"] !== "object") {
+      const existing = updated_var["description"];
+      updated_var["description"] = typeof existing === "string" && existing && existing !== "unknown" ? { default: existing } : {};
+    }
+    Object.entries(updated_var["description"]).forEach(([key, value]) => {
+      if (value === add_value) {
+        if (!key.includes(add_key)) {
+          delete updated_var["description"][key];
+          updated_var["description"][key + ", " + add_key] = add_value;
+        }
+        exists = true;
+      }
+    });
+    if (!exists) Object.assign(updated_var["description"], added_value);
+  }
+  /**
+   * Logic for updating name. Needs to retain all the old values while creating a new reference in the map
+   * while keeping the same perspe
+   *
+   * @private
+   * @param {*} updated_var
+   * @param {*} added_value
+   */
+  updateName(updated_var, added_value) {
+    const old_name = updated_var["name"];
+    updated_var["name"] = added_value;
+    delete this.variables[old_name];
+    this.setVariable(updated_var);
+  }
+  /**
+   * Allows you to delete a variable by key/name. Returns console error if not found.
+   *
+   * @param {string} var_name - Name of variable to be deleted.
+   */
+  deleteVariable(var_name) {
+    if (var_name in this.variables) {
+      delete this.variables[var_name];
+    } else {
+      console.error(`Variable "${var_name}" does not exist.`);
+    }
+  }
+};
+
+// src/index.ts
+var JsPsychMetadata = class {
+  /**
+   * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context
+   *  allowing it to access the screen printing information.
+   *
+   * @constructor
+   * @param {JsPsych} JsPsych
+   */
+  constructor(verbose) {
+    /**
+     * Initializes a set that contains the variable fields that are to be ignored, so can help with later 
+     * logic when generating data.
+     *
+     * @private
+     * @type {*}
+     */
+    this.ignored_variables = new Set(SYSTEM_COLUMNS);
+    /**
+     * Verbose mode that is used in by the tools that call this to print fetching messages and 
+     * reading messages.
+     *
+     * @private
+     * @type {boolean}
+     */
+    this.verbose = false;
+    this.extractedArrays = /* @__PURE__ */ new Map();
+    // Plain (non-array) object columns expanded by expandObjectFields. One row per trial,
+    // keyed by the same arrayJoinKeys as extractedArrays, with a column for every dotted
+    // descendant variable (leaf scalars, intermediate object nodes, and nested-array parents).
+    // The CLI writes these as separate Psych-DS CSVs so those dotted names map to real columns.
+    this.extractedObjects = /* @__PURE__ */ new Map();
+    this.arrayJoinKeys = ["trial_index"];
+    this.mixedColumns = /* @__PURE__ */ new Set();
+    this.metadata = {};
+    this.setMetadataField("name", "title");
+    this.setMetadataField("schemaVersion", "Psych-DS 0.4.0");
+    this.setMetadataField("@context", "https://schema.org");
+    this.setMetadataField("@type", "Dataset");
+    this.setMetadataField("description", "Dataset generated using JsPsych");
+    this.authors = new AuthorsMap();
+    this.variables = new VariablesMap();
+    this.pluginCache = new PluginCache();
+    this.verbose = verbose;
+  }
+  /**
+   * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields.
+   *
+   * @param {string} key - Metadata field name
+   * @param {*} value - Data associated with the field
+   */
+  setMetadataField(key, value) {
+    this.metadata[key] = value;
+  }
+  /**
+   * Simple get that accesses the data associated with a field.
+   *
+   * @param {string} key - Field name
+   * @returns {*} - Data associated with the field
+   */
+  getMetadataField(key) {
+    return this.metadata[key];
+  }
+  /**
+   * Checks if the metadata field exists in the metadata.
+   *
+   * @param {string} key - Key of metadata being checked.
+   * @returns {*} - Boolean
+   */
+  containsMetadataField(key) {
+    return key in this.metadata;
+  }
+  /**
+   * Deletes a metadata from the metadata if it exists. 
+   *
+   * @param {string} key - Name of field to be deleted
+   */
+  deleteMetadataField(key) {
+    if (key in this.metadata) {
+      delete this.metadata[key];
+    } else {
+      console.error(`Metadata "${key}" does not exist.`);
+    }
+  }
+  /**
+   * Returns the final Metadata in a single javascript object. Bundles together the author and variables
+   * together in a list rather than object compliant with Psych-DS standards. Seems that javascript get
+   * are implictly called.
+   *
+   * @returns {{}} - Final Metadata object
+   */
+  getMetadata() {
+    const res = this.metadata;
+    res["author"] = this.authors.getList();
+    res["variableMeasured"] = this.variables.getList();
+    return res;
+  }
+  getUserMetadataFields() {
+    const res = {};
+    const ignored_fields = /* @__PURE__ */ new Set(["schemaVersion", "@type", "@context", "author", "variableMeasured"]);
+    for (const key in this.metadata) {
+      if (!ignored_fields.has(key)) {
+        res[key] = this.metadata[key];
+      }
+    }
+    return res;
+  }
+  /**
+   * Returns the variable fields while excluding the authors and variables.`
+   *
+   * @returns {{}} - Final Metadata object
+   */
+  getMetadataFields() {
+    const res = this.metadata;
+    delete res["author"];
+    delete res["variableMeasured"];
+    return res;
+  }
+  /**
+   * Method that creates an author. This method can also be used to overwrite existing authors
+   * with the same name in order to update fields.
+   *
+   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+   */
+  setAuthor(fields) {
+    this.authors.setAuthor(fields);
+  }
+  /**
+   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+   *
+   * @param {string} name - Name of author to be used as key.
+   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+   */
+  getAuthor(name) {
+    return this.authors.getAuthor(name);
+  }
+  /**
+   * Returns a list of the authors defined in the metadata.
+   *
+   * @returns {(string | AuthorFields)[]} - Authors
+   */
+  getAuthorList() {
+    return this.authors.getList();
+  }
+  /**
+   * Deletes an author from the authorsField.
+   *
+   * @param {string} name - Name of author to be deleted.
+   */
+  deleteAuthor(name) {
+    this.authors.deleteAuthor(name);
+  }
+  /**
+   * Method that creates a variable. This method can also be used to overwrite variables with the same name
+   * as a way to update fields.
+   *
+   * @param {{
+   *     @type?: string;
+   *     name: string; // required
+   *     description?: string | {};
+   *     value?: string; // string, boolean, or number
+   *     identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
+   *     minValue?: number;
+   *     maxValue?: number;
+   *     levels?: string[] | []; // technically property values in the other one but not sure how to format it
+   *     levelsOrdered?: boolean;
+   *     na?: boolean;
+   *     naValue?: string;
+   *     alternateName?: string;
+   *     privacy?: string;
+   *   }} fields - Fields associated with the current Psych-DS standard.
+   */
+  setVariable(variable) {
+    this.variables.setVariable(variable);
+  }
+  /**
+   * Allows you to access a variable's information by using the name of the variable. Can
+   * be used to update fields within a variable, but suggest using updateVariable() to prevent errors.
+   *
+   * @param {string} name - Name of variable to be accessed
+   * @returns {{}} - Returns object of fields
+   */
+  getVariable(name) {
+    return this.variables.getVariable(name);
+  }
+  /**
+   * Returns a list of the variables defined in the metadata.
+   *
+   * @returns {{}[]} - Authors
+   */
+  getVariableList() {
+    return this.variables.getList();
+  }
+  /**
+   * Allows you to check if the name of the variable exists in variablesMap.
+   *
+   * @param {string} name - Name of variable
+   * @returns {boolean} - Does variable exist in variables
+   */
+  containsVariable(name) {
+    return this.variables.containsVariable(name);
+  }
+  /**
+   * Allows you to update a variable or add a value in the case of updating values. In other situations will
+   * replace the existing value with the new value.
+   *
+   * @param {string} var_name - Name of variable to be updated.
+   * @param {string} field_name - Name of field to be updated.
+   * @param {(string | boolean | number | {})} added_value - Value to be used in the update.
+   */
+  updateVariable(var_name, field_name, added_value) {
+    this.variables.updateVariable(var_name, field_name, added_value);
+  }
+  /**
+   * Allows you to delete a variable by key/name.
+   *
+   * @param {string} var_name - Name of variable to be deleted.
+   */
+  deleteVariable(var_name) {
+    this.variables.deleteVariable(var_name);
+  }
+  /**
+   * Gets a list of all the variable names.
+   *
+   * @returns {string[]} - List of variable string names.
+   */
+  getVariableNames() {
+    return this.variables.getVariableNames();
+  }
+  /**
+   * Returns accumulated array-column data keyed by column name.
+   * Each entry is a list of rows with join key columns, element_index, and the element's own fields.
+   * Used by the CLI to write Psych-DS compliant separate CSV files.
+   */
+  getExtractedArrays() {
+    return this.extractedArrays;
+  }
+  /**
+   * Returns accumulated plain-object-column data keyed by the top-level column name.
+   * Each entry is one row per trial: the join key columns plus a column for every dotted
+   * descendant variable expanded from that object (matching the names in variableMeasured).
+   * Used by the CLI to write a separate Psych-DS CSV per object column, so those dotted
+   * sub-variables resolve to real columns. No element_index (one row per trial, not per element).
+   */
+  getExtractedObjects() {
+    return this.extractedObjects;
+  }
+  /**
+   * Returns the join key columns used in the most recent generate() call.
+   * The CLI uses this to order columns correctly in extracted array CSVs.
+   */
+  getArrayJoinKeys() {
+    return [...this.arrayJoinKeys];
+  }
+  warnJoinKeyUniqueness(analysis) {
+    const keyStr = this.arrayJoinKeys.join(", ");
+    const exampleStr = analysis.duplicateValues.slice(0, 3).map((v) => Object.entries(v).map(([k, val]) => `${k}=${val}`).join(", ")).join("; ");
+    let msg = `[jspsych-metadata] Join key (${keyStr}) is not unique in this dataset
+  (${analysis.duplicateCount} duplicate rows; e.g. ${exampleStr})
+`;
+    if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length === 0) {
+      const sufficient = analysis.candidates.filter((c) => c.makesUnique).map((c) => c.column);
+      const example = JSON.stringify([sufficient[0], ...this.arrayJoinKeys]);
+      msg += `  Sufficient fix: add one of these columns to arrayJoinKeys:
+    ${sufficient.join(", ")}
+  Pass { arrayJoinKeys: ${example} } as the options argument to generate().`;
+    } else if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length > 0) {
+      const combined = JSON.stringify([...analysis.suggestedAdditionalKeys, ...this.arrayJoinKeys]);
+      msg += `  No single column makes rows unique. Suggested combination:
+    ${analysis.suggestedAdditionalKeys.join(" + ")}
+  Pass { arrayJoinKeys: ${combined} } as the options argument to generate().`;
+    } else {
+      msg += `  No combination of available columns was found to make rows unique.
+  Your data may contain genuinely duplicate rows.
+  Extracted array CSVs will have non-unique join keys.`;
+    }
+    console.warn(msg);
+  }
+  /**
+   * Method that allows you to display metadata at the end of an experiment.
+   *
+   * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling.
+   */
+  displayMetadata(display_element) {
+    const elementId = "jspsych-metadata-display";
+    const metadata_string = JSON.stringify(this.getMetadata(), null, 2);
+    display_element.innerHTML += `

Metadata

`;
+    document.getElementById(elementId).textContent += metadata_string;
+  }
+  /**
+   * Method that begins a download for the dataset_description.json at the end of experiment.
+   * Allows you to download the metadat.
+   */
+  localSave() {
+    let data_string = JSON.stringify(this.getMetadata());
+    saveTextToFile(data_string, "dataset_description.json");
+  }
+  /**
+   * This method loads the metadata into the metadata object. This takes in the"dataset_description.json" string content 
+   * and first parses it as an object. This then loads in all the fields, authors and variables into the metadata object by calling all the 
+   * relevant methods that overwrites the default data.
+   *
+   * @param {string} stringMetadata - String version of the metadata to be loaded from "dataset_description.json".
+   */
+  loadMetadata(stringMetadata) {
+    const meta = JSON.parse(stringMetadata);
+    for (const field_key in meta) {
+      if (field_key === "variableMeasured") {
+        for (const variable of meta[field_key]) {
+          this.setVariable(variable);
+        }
+      } else if (field_key === "author") {
+        for (const author of meta[field_key]) {
+          this.setAuthor(author);
+        }
+      } else {
+        this.setMetadataField(field_key, meta[field_key]);
+      }
+    }
+  }
+  /**
+   * Generates observations based on the input data and processes optional metadata. This is the
+   * outer wrapper function that should called and handles the logic of reading individual observations.
+   *
+   * This method accepts data as a JSON string, a CSV string, or an already-parsed array of
+   * observation objects. A string is parsed according to `ext`; an array is consumed as-is.
+   * Each observation is processed asynchronously via `generateObservation`. Optionally, metadata
+   * options can be provided as an object, and each key-value pair is processed by `processMetadata`.
+   *
+   * NOTE: when `data` is a pre-parsed array it is consumed in place and MUTATED — unnamed
+   * (blank-header) columns are deleted from the row objects. Callers that need the rows to stay
+   * pristine must pass a copy. This lets a caller parse a file once and share the rows with
+   * generate() instead of having generate() re-parse the same content.
+   *
+   * @async
+   * @param {Array|String} data - Observations to generate from: a pre-parsed array (consumed as-is and mutated in place), a JSON string, or a CSV string.
+   * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
+   * @param {'json'|'csv'} [ext='json'] - Format of a string `data`; ignored when `data` is already an array.
+   * @param {Object} [options={}] - arrayJoinKeys / suppressJoinKeyWarning, plus synthesizedSourceRecordId for pre-parsed callers that tagged a synthetic source_record_id themselves.
+   */
+  async generate(data, metadata = {}, ext = "json", options = {}) {
+    this.extractedArrays = /* @__PURE__ */ new Map();
+    this.extractedObjects = /* @__PURE__ */ new Map();
+    this.arrayJoinKeys = options.arrayJoinKeys ?? ["trial_index"];
+    var parsed_data;
+    let synthesizedSourceRecordId = options.synthesizedSourceRecordId ?? false;
+    if (Array.isArray(data)) {
+      parsed_data = data;
+    } else if (ext === "csv") {
+      parsed_data = await parseCSV(data);
+    } else if (ext === "json") {
+      const parseStats = {};
+      parsed_data = parseJsonData(data, { tagSourceRecordId: true }, parseStats);
+      synthesizedSourceRecordId = parseStats.synthesizedSourceRecordId === true;
+    }
+    if (!Array.isArray(parsed_data)) {
+      throw new Error("Parsed data is not in correct format: Expected an array of observations");
+    }
+    const { dropped } = stripUnnamedColumns(parsed_data);
+    if (dropped.length > 0) {
+      console.warn(
+        `Dropped ${dropped.length} unnamed column${dropped.length > 1 ? "s" : ""} from the data \u2014 Psych-DS requires every column to have a name (usually a row-index column added by R's write.csv). Excluded from variableMeasured.`
+      );
+    }
+    const rows = parsed_data;
+    const hasColumn = (col) => ext === "json" && rows.some((row) => row && typeof row === "object" && col in row);
+    const idColumn = hasColumn("source_record_id") ? "source_record_id" : hasColumn("participant_id") ? "participant_id" : void 0;
+    if (idColumn && !this.arrayJoinKeys.includes(idColumn)) {
+      this.arrayJoinKeys = [idColumn, ...this.arrayJoinKeys];
+    }
+    const analysis = analyzeJoinKeys(parsed_data, this.arrayJoinKeys);
+    if (!analysis.isUnique && !options.suppressJoinKeyWarning) this.warnJoinKeyUniqueness(analysis);
+    for (const observation of parsed_data) {
+      await this.generateObservation(observation);
+    }
+    if (synthesizedSourceRecordId && this.containsVariable("source_record_id")) {
+      const existing = this.getVariable("source_record_id");
+      this.setVariable({
+        ...existing,
+        description: { default: "Synthetic source-record identifier (0-based), assigned one per source record (one JSON-Lines line, which is usually but not always one participant) because the raw data carried no identifier column. NOT a real subject ID from the experiment \u2014 it only orders/links records as they appeared in the source file, and serves as a join key connecting each trial to its extracted array/object rows." }
+      });
+    }
+    await this.updateMetadata(metadata);
+  }
+  /**
+   * This function iterates through the entire row of data stepping through one column at a time.
+   * It is designed to only be accessed through calling generate on an entire data file. 
+   * Searching for plugin, plugin version, extension, extension it then calls the 
+   * helper methods that process the individual row of data. There is limited error chcking and 
+   * type conversion from csv due to the way that csv data is represented as strings.
+   * This method also handles extensions, declaring them if necessary and iterate through each.
+   * This method also skips generating descriptions the variables that should the same for 
+   * all variables and instead updates their fields. 
+   *
+   * @private
+   * @async
+   * @param {*} observation Dictionary that represent one row of data
+   * @returns {*}
+   */
+  async generateObservation(observation) {
+    const version = observation["plugin_version"] ? observation["plugin_version"] : null;
+    const pluginType = observation["trial_type"];
+    const extensionType = observation["extension_type"];
+    const extensionVersion = observation["extension_version"];
+    const joinValues = this.arrayJoinKeys.reduce((acc, k) => {
+      acc[k] = observation[k];
+      return acc;
+    }, {});
+    for (const variable in observation) {
+      var value = observation[variable];
+      var type = typeof value;
+      if (!this.containsVariable(variable)) {
+        if (this.ignored_variables.has(variable)) {
+          this.variables.registerSystemVariable(variable);
+        } else {
+          this.setVariable({
+            "@type": "PropertyValue",
+            name: variable,
+            description: { default: "unknown" },
+            value: "unknown"
+          });
+        }
+      }
+      if (value === null || value === void 0 || value === "" || value === "null") {
+        continue;
+      }
+      if (type === "string") {
+        const asNumber = Number(value);
+        if (value.trim() !== "" && Number.isFinite(asNumber)) {
+          type = "number";
+          value = asNumber;
+        } else if (value.startsWith("{") || value.startsWith("[")) {
+          const parsed = tryParseJSON(value);
+          if (parsed !== null) {
+            value = parsed;
+            type = Array.isArray(parsed) ? "array" : "object";
+          }
+        }
+      }
+      if (this.ignored_variables.has(variable)) {
+        this.updateFields(variable, value, type);
+      } else {
+        if (type === "object" && value !== null && !Array.isArray(value)) {
+          const objectRow = { ...joinValues };
+          await this.expandObjectFields(variable, value, pluginType, version, joinValues, objectRow);
+          const existingObjects = this.extractedObjects.get(variable) ?? [];
+          existingObjects.push(objectRow);
+          this.extractedObjects.set(variable, existingObjects);
+        } else if (type === "array" || type === "object" && Array.isArray(value)) {
+          await this.generateMetadata(variable, value, pluginType, version);
+          const existingVar = this.containsVariable(variable) ? this.getVariable(variable) : null;
+          const existingType = existingVar?.value;
+          if (existingType !== "string" && existingType !== "number" && existingType !== "boolean") {
+            this.updateVariable(variable, "value", "array");
+          }
+          await this.accumulateArrayColumn(variable, value, joinValues, pluginType, version);
+        } else {
+          await this.generateMetadata(variable, value, pluginType, version);
+        }
+        if (extensionType) {
+          await Promise.all(
+            extensionType.map(async (ext, index) => {
+              if (ext && extensionVersion[index])
+                await this.generateMetadata(variable, value, ext, extensionVersion[index], true);
+            })
+          );
+        }
+      }
+    }
+  }
+  /**
+   * Iterates through one single datapoint which can be thought of as one row-column pair. 
+   * This method keeps in mind the versionType or pluginType and uses this to generate the 
+   * metadata. 
+   *
+   * @private
+   * @async
+   * @param {*} variable - The column name
+   * @param {*} value - The value at the row-column mapping that is being used to update fields
+   * @param {*} pluginType - The type of the plugin that is used for the fetching (can also be extension if extension?=true)
+   * @param {*} version - The version of the plugin that is not necessary but is used post v8 to ensure accurate fetching
+   * @param {?*} [extension] - This boolean determines whether is a extension to change fetching
+   * @returns {*}
+   */
+  async generateMetadata(variable, value, pluginType, version, extension) {
+    const type = typeof value;
+    if (!this.containsVariable(variable)) {
+      const new_var = {
+        "@type": "PropertyValue",
+        name: variable,
+        description: { default: "unknown" },
+        value: type
+      };
+      this.setVariable(new_var);
+    } else {
+      const existing = this.getVariable(variable);
+      if (existing.value === "unknown") this.updateVariable(variable, "value", type);
+    }
+    if (pluginType) {
+      const pluginInfo = await this.getPluginInfo(pluginType, variable, version, extension);
+      const description = pluginInfo["description"];
+      const new_description = description ? { [pluginType]: description } : { [pluginType]: "unknown" };
+      this.updateVariable(variable, "description", new_description);
+    }
+    this.updateFields(variable, value, type);
+  }
+  /**
+   * This calls an update to the individual fields of the metadata, updating levels and 
+   * minValue and maxValue depeneding on the variable type.
+   *
+   * @private
+   * @param {*} variable - The column of the data and name of variable
+   * @param {*} value - The datapoint 
+   * @param {*} type - The type of the datapoint
+   */
+  updateFields(variable, value, type) {
+    if (type === "boolean") return;
+    const existing = this.getVariable(variable);
+    if (type === "number") {
+      if (Array.isArray(existing.levels)) {
+        if (!this.mixedColumns.has(variable)) {
+          this.mixedColumns.add(variable);
+          console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+        }
+        this.updateVariable(variable, "levels", String(value));
+        return;
+      }
+      this.updateVariable(variable, "minValue", value);
+      this.updateVariable(variable, "maxValue", value);
+      return;
+    }
+    if (type !== "object") {
+      if ("minValue" in existing || "maxValue" in existing) {
+        if (!this.mixedColumns.has(variable)) {
+          this.mixedColumns.add(variable);
+          console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+        }
+        if ("minValue" in existing) this.updateVariable(variable, "levels", String(existing.minValue));
+        if ("maxValue" in existing && existing.maxValue !== existing.minValue) {
+          this.updateVariable(variable, "levels", String(existing.maxValue));
+        }
+        delete existing.minValue;
+        delete existing.maxValue;
+        this.updateVariable(variable, "value", "string");
+      }
+      if (existing.value === "boolean" && (value === "true" || value === "false")) {
+        return;
+      }
+      this.updateVariable(variable, "levels", value);
+    }
+  }
+  /**
+   * Iterates through the entire metadata options object by calling processMetadata() to act upon each of the 
+   * individual fields at one time. 
+   *
+   * @async
+   * @param {*} metadata - Metadata options that contains all the metadata according to Psych-DS formatting. 
+   */
+  async updateMetadata(metadata) {
+    for (const key in metadata) {
+      await this.processMetadata(metadata, key);
+    }
+  }
+  /**
+   * This is the method that processes each individual element of the metadata options to be updated. This can be called through generate or outside of it, 
+   * and this processes each element. 
+   *
+   * @private
+   * @param {*} metadata - An object that contains all of the metadata. This is used to access the value. 
+   * @param {*} key - String key that denotes what key-value mapping is being iterated upon. 
+   */
+  processMetadata(metadata, key) {
+    const value = metadata[key];
+    if (key === "variables") {
+      if (typeof value !== "object" || value === null) {
+        console.warn("Variable object is either null or incorrect type");
+        return;
+      }
+      for (let variable_key in value) {
+        if (!this.containsVariable(variable_key)) {
+          console.warn("Metadata does not contain variable:", variable_key);
+          continue;
+        }
+        const variable_parameters = value[variable_key];
+        if (typeof variable_parameters !== "object" || variable_parameters === null) {
+          console.warn(
+            "Parameters of variable:",
+            variable_key,
+            "is either null or incorrect type. The value",
+            variable_parameters,
+            "is either null or not an object."
+          );
+          continue;
+        }
+        for (const parameter in variable_parameters) {
+          const parameter_value = variable_parameters[parameter];
+          this.updateVariable(variable_key, parameter, parameter_value);
+          if (parameter === "value" && parameter_value === "boolean") {
+            this.applyBooleanOverride(variable_key);
+          }
+          if (parameter === "name") variable_key = parameter_value;
+        }
+      }
+    } else if (key === "author") {
+      if (typeof value !== "object" || value === null) {
+        console.warn("Author object is not correct type");
+        return;
+      }
+      for (const author_key in value) {
+        const author = value[author_key];
+        if (typeof author !== "string" && !("name" in author)) author["name"] = author_key;
+        this.setAuthor(author);
+      }
+    } else this.setMetadataField(key, value);
+  }
+  /**
+   * Applies a user-chosen `value:"boolean"` override to an already-populated variable.
+   * Warns when the values detected from the data don't map cleanly to boolean logic
+   * (anything other than true/false/0/1, case-insensitive), then drops the detected
+   * levels/min/max so the variable matches how genuine booleans are recorded (no levels).
+   */
+  applyBooleanOverride(variableName) {
+    const existing = this.getVariable(variableName);
+    const isBooleanLike = (v) => {
+      const s = String(v).trim().toLowerCase();
+      return s === "true" || s === "false" || s === "0" || s === "1";
+    };
+    const offenders = /* @__PURE__ */ new Set();
+    if (Array.isArray(existing.levels)) {
+      for (const level of existing.levels) if (!isBooleanLike(level)) offenders.add(String(level));
+    }
+    if (typeof existing.minValue === "number" && !isBooleanLike(existing.minValue)) offenders.add(String(existing.minValue));
+    if (typeof existing.maxValue === "number" && !isBooleanLike(existing.maxValue)) offenders.add(String(existing.maxValue));
+    if (offenders.size > 0) {
+      const sample = [...offenders].slice(0, 10).join(", ");
+      const more = offenders.size > 10 ? `, \u2026(+${offenders.size - 10} more)` : "";
+      console.warn(
+        `Variable "${variableName}" was set to value:"boolean", but the detected values don't map cleanly to true/false: ${sample}${more}. Double-check this is the intended type.`
+      );
+    }
+    delete existing.levels;
+    delete existing.minValue;
+    delete existing.maxValue;
+  }
+  /**
+   * Registers the keys of a plain JSON object as dotted sub-variables
+   * (e.g. response.Q0, response.Q1) and registers the parent with value: "object".
+   *
+   * Recurses into nested plain objects so structures more than one level deep are
+   * fully expanded (e.g. response.address.city). Nested arrays are registered with
+   * value: "array" (typeof [] === "object", so the inferred type must be overridden)
+   * and, when they hold objects, extracted into a separate CSV keyed by their dotted
+   * column name — mirroring how top-level array columns are handled.
+   *
+   * @param joinValues - The current row's join key values, prepended to every
+   *   extracted nested-array row so the sub-table can be rejoined to the main data.
+   */
+  async expandObjectFields(parentName, obj, pluginType, version, joinValues, row) {
+    await this.generateMetadata(parentName, obj, pluginType, version);
+    for (const key of Object.keys(obj)) {
+      const childName = `${parentName}.${key}`;
+      const childValue = obj[key];
+      if (row) row[childName] = childValue;
+      if (childValue !== null && typeof childValue === "object" && !Array.isArray(childValue)) {
+        await this.expandObjectFields(childName, childValue, pluginType, version, joinValues, row);
+      } else if (Array.isArray(childValue)) {
+        await this.generateMetadata(childName, childValue, pluginType, version);
+        this.updateVariable(childName, "value", "array");
+        await this.accumulateArrayColumn(childName, childValue, joinValues, pluginType, version);
+      } else {
+        await this.generateMetadata(childName, childValue, pluginType, version);
+      }
+    }
+  }
+  /**
+   * Accumulates the object elements of an array column into `extractedArrays` for
+   * separate Psych-DS CSV output, keyed by the column's (possibly dotted) name.
+   * Each emitted row is the join key values, an `element_index`, then the element's
+   * fields under DOTTED names (`columnName.field`) so they don't collide with top-level
+   * columns or with fields of other array columns. Every emitted column is registered in
+   * variableMeasured so the sidecar CSV has no columns missing from the metadata.
+   *
+   * Element fields recurse (see expandElementFields): a nested plain object is expanded
+   * into deeper dotted columns in the SAME row; a nested array is extracted into its own
+   * grandchild CSV, joinable via `${columnName}.element_index` (this element's position)
+   * carried alongside the existing join keys.
+   *
+   * Null / primitive top-level array elements are skipped; arrays with no object elements
+   * produce no rows.
+   */
+  async accumulateArrayColumn(columnName, arr, joinValues, pluginType, version) {
+    const elements = [];
+    arr.forEach((element, index) => {
+      if (element !== null && element !== void 0) elements.push({ element, index });
+    });
+    if (elements.length === 0) return;
+    if (!this.containsVariable("element_index")) {
+      this.setVariable({
+        "@type": "PropertyValue",
+        name: "element_index",
+        description: { default: "Position of this element within its source array column (0-based)." },
+        value: "number"
+      });
+    }
+    for (const joinKey of Object.keys(joinValues)) {
+      if (!this.containsVariable(joinKey)) {
+        this.setVariable({
+          "@type": "PropertyValue",
+          name: joinKey,
+          description: { default: "Join key referencing the position of an enclosing array element (0-based index)." },
+          value: "number"
+        });
+      }
+    }
+    const existing = this.extractedArrays.get(columnName) ?? [];
+    for (const { element, index } of elements) {
+      const row = { ...joinValues, element_index: index };
+      const nestedJoin = { ...joinValues, [`${columnName}.element_index`]: index };
+      if (typeof element === "object" && !Array.isArray(element)) {
+        await this.expandElementFields(columnName, element, row, nestedJoin, pluginType, version);
+      } else {
+        const valueName = `${columnName}.value`;
+        row[valueName] = element;
+        if (Array.isArray(element)) {
+          await this.registerNodeVariable(valueName, element, "array", pluginType, version);
+          await this.accumulateArrayColumn(valueName, element, nestedJoin, pluginType, version);
+        } else {
+          await this.registerScalarField(valueName, element, pluginType, version);
+        }
+      }
+      existing.push(row);
+    }
+    this.extractedArrays.set(columnName, existing);
+  }
+  /**
+   * Recursively records one array element's fields into `row` under dotted names. Scalars become
+   * columns with type + min/max/levels tracking; nested plain objects are expanded into the SAME
+   * row (deeper dotted columns); nested arrays are extracted into their own grandchild CSV via
+   * accumulateArrayColumn (keyed by `nestedJoin`). Object/array nodes are also kept as a single
+   * dotted JSON column so their own name is represented as a column too.
+   */
+  async expandElementFields(prefix, obj, row, nestedJoin, pluginType, version) {
+    for (const key of Object.keys(obj)) {
+      const name = `${prefix}.${key}`;
+      const value = obj[key];
+      row[name] = value;
+      if (value !== null && typeof value === "object" && !Array.isArray(value)) {
+        await this.registerNodeVariable(name, value, "object", pluginType, version);
+        await this.expandElementFields(name, value, row, nestedJoin, pluginType, version);
+      } else if (Array.isArray(value)) {
+        await this.registerNodeVariable(name, value, "array", pluginType, version);
+        await this.accumulateArrayColumn(name, value, nestedJoin, pluginType, version);
+      } else {
+        await this.registerScalarField(name, value, pluginType, version);
+      }
+    }
+  }
+  /** Registers an object/array node variable once (with its plugin description, if any). */
+  async registerNodeVariable(name, value, type, pluginType, version) {
+    if (this.containsVariable(name) && this.getVariable(name).value !== "unknown") return;
+    await this.generateMetadata(name, value, pluginType, version);
+    if (!this.containsVariable(name)) {
+      this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+    } else {
+      this.updateVariable(name, "value", type);
+    }
+  }
+  /**
+   * Registers one scalar array-element field under its dotted name (so the sidecar column is
+   * represented in variableMeasured), then folds later values into min/max/levels. Empty values
+   * still declare the column (placeholder) without polluting min/max/levels.
+   */
+  async registerScalarField(name, value, pluginType, version) {
+    if (value === null || value === void 0 || value === "" || value === "null") {
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: "unknown" });
+      }
+      return;
+    }
+    const type = typeof value;
+    const needsRegister = !this.containsVariable(name) || this.getVariable(name).value === "unknown";
+    if (needsRegister) {
+      await this.generateMetadata(name, value, pluginType, version);
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+        this.updateFields(name, value, type);
+      }
+    } else {
+      this.updateFields(name, value, type);
+    }
+  }
+  /**
+   * Gets the description of a variable in a plugin by fetching the source code of the plugin
+   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+   * to extract the description for the variable (present as JSDoc); caches the result for future use.
+   *
+   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+   * @param {string} variableName - The name of the variable for which information is to be fetched.
+   * @param {string} version - The version of the plugin or extension
+   * @param {string} extension - Boolean indicating if pluginType refers to extension
+   * @returns {Promise} The description of the plugin variable if found, otherwise null.
+   * @throws Will throw an error if the fetch operation fails.
+   */
+  async getPluginInfo(pluginType, variableName, version, extension) {
+    return this.pluginCache.getPluginInfo(pluginType, variableName, version, this.verbose, extension);
+  }
+};
+// Annotate the CommonJS export names for ESM import in node:
+0 && (module.exports = {
+  PSYCHDS_IGNORE_CONTENT,
+  PSYCHDS_IGNORE_FILENAME,
+  analyzeJoinKeys,
+  buildPsychDSDataFiles,
+  deriveArrayFilename,
+  deriveFallbackBase,
+  disambiguateArrayFilename,
+  hasUnnamedColumns,
+  isValidPsychDSDataFilename,
+  objectsToCSV,
+  parseCSV,
+  parseJsonData,
+  stripUnnamedColumns,
+  toPsychDSValue,
+  unwrapTrials
+});
diff --git a/functions/metadata/dist/index.d.ts b/functions/metadata/dist/index.d.ts
new file mode 100644
index 0000000..58bf61e
--- /dev/null
+++ b/functions/metadata/dist/index.d.ts
@@ -0,0 +1,393 @@
+import { AuthorFields } from "./AuthorsMap";
+import { VariableFields } from "./VariablesMap";
+/**
+ * Class that handles the storage, update and retrieval of metadata according to Psych-DS
+ * standards.
+ *
+ * @export
+ * @class JsPsychMetadata
+ * @typedef {JsPsychMetadata}
+ */
+export default class JsPsychMetadata {
+    /**
+     * Field that contains all metadata fields that aren't represented as a list.
+     *
+     * @private
+     * @type {{}}
+     */
+    private metadata;
+    /**
+     * Custom class that stores and handles the storage, update and retrieval of author metadata.
+     *
+     * @private
+     * @type {AuthorsMap}
+     */
+    private authors;
+    /**
+     * Custom class that stores and handles the storage, update and retrieval of variable metadata.
+     *
+     * @private
+     * @type {VariablesMap}
+     */
+    private variables;
+    /**
+     * Custom class that handles the fetching and retrieval of the metadata information from the
+     * default descriptions defined in the javadoc of the plugins and extensions. Caches the data
+     * to save time and fetching.
+     *
+     * @private
+     * @type {PluginCache}
+     */
+    private pluginCache;
+    /**
+     * Initializes a set that contains the variable fields that are to be ignored, so can help with later
+     * logic when generating data.
+     *
+     * @private
+     * @type {*}
+     */
+    private ignored_variables;
+    /**
+     * Verbose mode that is used in by the tools that call this to print fetching messages and
+     * reading messages.
+     *
+     * @private
+     * @type {boolean}
+     */
+    private verbose;
+    private extractedArrays;
+    private extractedObjects;
+    private arrayJoinKeys;
+    private mixedColumns;
+    /**
+     * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context
+     *  allowing it to access the screen printing information.
+     *
+     * @constructor
+     * @param {JsPsych} JsPsych
+     */
+    constructor(verbose?: boolean);
+    /**
+     * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields.
+     *
+     * @param {string} key - Metadata field name
+     * @param {*} value - Data associated with the field
+     */
+    setMetadataField(key: string, value: any): void;
+    /**
+     * Simple get that accesses the data associated with a field.
+     *
+     * @param {string} key - Field name
+     * @returns {*} - Data associated with the field
+     */
+    getMetadataField(key: string): any;
+    /**
+     * Checks if the metadata field exists in the metadata.
+     *
+     * @param {string} key - Key of metadata being checked.
+     * @returns {*} - Boolean
+     */
+    containsMetadataField(key: string): any;
+    /**
+     * Deletes a metadata from the metadata if it exists.
+     *
+     * @param {string} key - Name of field to be deleted
+     */
+    deleteMetadataField(key: string): void;
+    /**
+     * Returns the final Metadata in a single javascript object. Bundles together the author and variables
+     * together in a list rather than object compliant with Psych-DS standards. Seems that javascript get
+     * are implictly called.
+     *
+     * @returns {{}} - Final Metadata object
+     */
+    getMetadata(): {};
+    getUserMetadataFields(): Record;
+    /**
+     * Returns the variable fields while excluding the authors and variables.`
+     *
+     * @returns {{}} - Final Metadata object
+     */
+    getMetadataFields(): {};
+    /**
+     * Method that creates an author. This method can also be used to overwrite existing authors
+     * with the same name in order to update fields.
+     *
+     * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+     */
+    setAuthor(fields: AuthorFields): void;
+    /**
+     * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+     *
+     * @param {string} name - Name of author to be used as key.
+     * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+     */
+    getAuthor(name: string): AuthorFields | string | {};
+    /**
+     * Returns a list of the authors defined in the metadata.
+     *
+     * @returns {(string | AuthorFields)[]} - Authors
+     */
+    getAuthorList(): (string | AuthorFields)[];
+    /**
+     * Deletes an author from the authorsField.
+     *
+     * @param {string} name - Name of author to be deleted.
+     */
+    deleteAuthor(name: string): void;
+    /**
+     * Method that creates a variable. This method can also be used to overwrite variables with the same name
+     * as a way to update fields.
+     *
+     * @param {{
+     *     @type?: string;
+     *     name: string; // required
+     *     description?: string | {};
+     *     value?: string; // string, boolean, or number
+     *     identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
+     *     minValue?: number;
+     *     maxValue?: number;
+     *     levels?: string[] | []; // technically property values in the other one but not sure how to format it
+     *     levelsOrdered?: boolean;
+     *     na?: boolean;
+     *     naValue?: string;
+     *     alternateName?: string;
+     *     privacy?: string;
+     *   }} fields - Fields associated with the current Psych-DS standard.
+     */
+    setVariable(variable: VariableFields): void;
+    /**
+     * Allows you to access a variable's information by using the name of the variable. Can
+     * be used to update fields within a variable, but suggest using updateVariable() to prevent errors.
+     *
+     * @param {string} name - Name of variable to be accessed
+     * @returns {{}} - Returns object of fields
+     */
+    getVariable(name: string): {};
+    /**
+     * Returns a list of the variables defined in the metadata.
+     *
+     * @returns {{}[]} - Authors
+     */
+    getVariableList(): ({})[];
+    /**
+     * Allows you to check if the name of the variable exists in variablesMap.
+     *
+     * @param {string} name - Name of variable
+     * @returns {boolean} - Does variable exist in variables
+     */
+    containsVariable(name: string): boolean;
+    /**
+     * Allows you to update a variable or add a value in the case of updating values. In other situations will
+     * replace the existing value with the new value.
+     *
+     * @param {string} var_name - Name of variable to be updated.
+     * @param {string} field_name - Name of field to be updated.
+     * @param {(string | boolean | number | {})} added_value - Value to be used in the update.
+     */
+    updateVariable(var_name: string, field_name: string, added_value: string | boolean | number | {}): void;
+    /**
+     * Allows you to delete a variable by key/name.
+     *
+     * @param {string} var_name - Name of variable to be deleted.
+     */
+    deleteVariable(var_name: string): void;
+    /**
+     * Gets a list of all the variable names.
+     *
+     * @returns {string[]} - List of variable string names.
+     */
+    getVariableNames(): string[];
+    /**
+     * Returns accumulated array-column data keyed by column name.
+     * Each entry is a list of rows with join key columns, element_index, and the element's own fields.
+     * Used by the CLI to write Psych-DS compliant separate CSV files.
+     */
+    getExtractedArrays(): Map>>;
+    /**
+     * Returns accumulated plain-object-column data keyed by the top-level column name.
+     * Each entry is one row per trial: the join key columns plus a column for every dotted
+     * descendant variable expanded from that object (matching the names in variableMeasured).
+     * Used by the CLI to write a separate Psych-DS CSV per object column, so those dotted
+     * sub-variables resolve to real columns. No element_index (one row per trial, not per element).
+     */
+    getExtractedObjects(): Map>>;
+    /**
+     * Returns the join key columns used in the most recent generate() call.
+     * The CLI uses this to order columns correctly in extracted array CSVs.
+     */
+    getArrayJoinKeys(): string[];
+    private warnJoinKeyUniqueness;
+    /**
+     * Method that allows you to display metadata at the end of an experiment.
+     *
+     * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling.
+     */
+    displayMetadata(display_element: any): void;
+    /**
+     * Method that begins a download for the dataset_description.json at the end of experiment.
+     * Allows you to download the metadat.
+     */
+    localSave(): void;
+    /**
+     * This method loads the metadata into the metadata object. This takes in the"dataset_description.json" string content
+     * and first parses it as an object. This then loads in all the fields, authors and variables into the metadata object by calling all the
+     * relevant methods that overwrites the default data.
+     *
+     * @param {string} stringMetadata - String version of the metadata to be loaded from "dataset_description.json".
+     */
+    loadMetadata(stringMetadata: string): void;
+    /**
+     * Generates observations based on the input data and processes optional metadata. This is the
+     * outer wrapper function that should called and handles the logic of reading individual observations.
+     *
+     * This method accepts data as a JSON string, a CSV string, or an already-parsed array of
+     * observation objects. A string is parsed according to `ext`; an array is consumed as-is.
+     * Each observation is processed asynchronously via `generateObservation`. Optionally, metadata
+     * options can be provided as an object, and each key-value pair is processed by `processMetadata`.
+     *
+     * NOTE: when `data` is a pre-parsed array it is consumed in place and MUTATED — unnamed
+     * (blank-header) columns are deleted from the row objects. Callers that need the rows to stay
+     * pristine must pass a copy. This lets a caller parse a file once and share the rows with
+     * generate() instead of having generate() re-parse the same content.
+     *
+     * @async
+     * @param {Array|String} data - Observations to generate from: a pre-parsed array (consumed as-is and mutated in place), a JSON string, or a CSV string.
+     * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
+     * @param {'json'|'csv'} [ext='json'] - Format of a string `data`; ignored when `data` is already an array.
+     * @param {Object} [options={}] - arrayJoinKeys / suppressJoinKeyWarning, plus synthesizedSourceRecordId for pre-parsed callers that tagged a synthetic source_record_id themselves.
+     */
+    generate(data: any, metadata?: {}, ext?: string, options?: {
+        arrayJoinKeys?: string[];
+        suppressJoinKeyWarning?: boolean;
+        synthesizedSourceRecordId?: boolean;
+    }): Promise;
+    /**
+     * This function iterates through the entire row of data stepping through one column at a time.
+     * It is designed to only be accessed through calling generate on an entire data file.
+     * Searching for plugin, plugin version, extension, extension it then calls the
+     * helper methods that process the individual row of data. There is limited error chcking and
+     * type conversion from csv due to the way that csv data is represented as strings.
+     * This method also handles extensions, declaring them if necessary and iterate through each.
+     * This method also skips generating descriptions the variables that should the same for
+     * all variables and instead updates their fields.
+     *
+     * @private
+     * @async
+     * @param {*} observation Dictionary that represent one row of data
+     * @returns {*}
+     */
+    private generateObservation;
+    /**
+     * Iterates through one single datapoint which can be thought of as one row-column pair.
+     * This method keeps in mind the versionType or pluginType and uses this to generate the
+     * metadata.
+     *
+     * @private
+     * @async
+     * @param {*} variable - The column name
+     * @param {*} value - The value at the row-column mapping that is being used to update fields
+     * @param {*} pluginType - The type of the plugin that is used for the fetching (can also be extension if extension?=true)
+     * @param {*} version - The version of the plugin that is not necessary but is used post v8 to ensure accurate fetching
+     * @param {?*} [extension] - This boolean determines whether is a extension to change fetching
+     * @returns {*}
+     */
+    private generateMetadata;
+    /**
+     * This calls an update to the individual fields of the metadata, updating levels and
+     * minValue and maxValue depeneding on the variable type.
+     *
+     * @private
+     * @param {*} variable - The column of the data and name of variable
+     * @param {*} value - The datapoint
+     * @param {*} type - The type of the datapoint
+     */
+    private updateFields;
+    /**
+     * Iterates through the entire metadata options object by calling processMetadata() to act upon each of the
+     * individual fields at one time.
+     *
+     * @async
+     * @param {*} metadata - Metadata options that contains all the metadata according to Psych-DS formatting.
+     */
+    updateMetadata(metadata: any): Promise;
+    /**
+     * This is the method that processes each individual element of the metadata options to be updated. This can be called through generate or outside of it,
+     * and this processes each element.
+     *
+     * @private
+     * @param {*} metadata - An object that contains all of the metadata. This is used to access the value.
+     * @param {*} key - String key that denotes what key-value mapping is being iterated upon.
+     */
+    private processMetadata;
+    /**
+     * Applies a user-chosen `value:"boolean"` override to an already-populated variable.
+     * Warns when the values detected from the data don't map cleanly to boolean logic
+     * (anything other than true/false/0/1, case-insensitive), then drops the detected
+     * levels/min/max so the variable matches how genuine booleans are recorded (no levels).
+     */
+    private applyBooleanOverride;
+    /**
+     * Registers the keys of a plain JSON object as dotted sub-variables
+     * (e.g. response.Q0, response.Q1) and registers the parent with value: "object".
+     *
+     * Recurses into nested plain objects so structures more than one level deep are
+     * fully expanded (e.g. response.address.city). Nested arrays are registered with
+     * value: "array" (typeof [] === "object", so the inferred type must be overridden)
+     * and, when they hold objects, extracted into a separate CSV keyed by their dotted
+     * column name — mirroring how top-level array columns are handled.
+     *
+     * @param joinValues - The current row's join key values, prepended to every
+     *   extracted nested-array row so the sub-table can be rejoined to the main data.
+     */
+    private expandObjectFields;
+    /**
+     * Accumulates the object elements of an array column into `extractedArrays` for
+     * separate Psych-DS CSV output, keyed by the column's (possibly dotted) name.
+     * Each emitted row is the join key values, an `element_index`, then the element's
+     * fields under DOTTED names (`columnName.field`) so they don't collide with top-level
+     * columns or with fields of other array columns. Every emitted column is registered in
+     * variableMeasured so the sidecar CSV has no columns missing from the metadata.
+     *
+     * Element fields recurse (see expandElementFields): a nested plain object is expanded
+     * into deeper dotted columns in the SAME row; a nested array is extracted into its own
+     * grandchild CSV, joinable via `${columnName}.element_index` (this element's position)
+     * carried alongside the existing join keys.
+     *
+     * Null / primitive top-level array elements are skipped; arrays with no object elements
+     * produce no rows.
+     */
+    private accumulateArrayColumn;
+    /**
+     * Recursively records one array element's fields into `row` under dotted names. Scalars become
+     * columns with type + min/max/levels tracking; nested plain objects are expanded into the SAME
+     * row (deeper dotted columns); nested arrays are extracted into their own grandchild CSV via
+     * accumulateArrayColumn (keyed by `nestedJoin`). Object/array nodes are also kept as a single
+     * dotted JSON column so their own name is represented as a column too.
+     */
+    private expandElementFields;
+    /** Registers an object/array node variable once (with its plugin description, if any). */
+    private registerNodeVariable;
+    /**
+     * Registers one scalar array-element field under its dotted name (so the sidecar column is
+     * represented in variableMeasured), then folds later values into min/max/levels. Empty values
+     * still declare the column (placeholder) without polluting min/max/levels.
+     */
+    private registerScalarField;
+    /**
+     * Gets the description of a variable in a plugin by fetching the source code of the plugin
+     * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+     * to extract the description for the variable (present as JSDoc); caches the result for future use.
+     *
+     * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+     * @param {string} variableName - The name of the variable for which information is to be fetched.
+     * @param {string} version - The version of the plugin or extension
+     * @param {string} extension - Boolean indicating if pluginType refers to extension
+     * @returns {Promise} The description of the plugin variable if found, otherwise null.
+     * @throws Will throw an error if the fetch operation fails.
+     */
+    private getPluginInfo;
+}
+export { AuthorFields, VariableFields };
+export { analyzeJoinKeys, parseCSV, parseJsonData, unwrapTrials, isValidPsychDSDataFilename, toPsychDSValue, deriveArrayFilename, objectsToCSV, disambiguateArrayFilename, deriveFallbackBase, buildPsychDSDataFiles, stripUnnamedColumns, hasUnnamedColumns, PSYCHDS_IGNORE_FILENAME, PSYCHDS_IGNORE_CONTENT } from "./utils";
+export type { JoinKeyAnalysis, PsychDSDataFile, BuildPsychDSDataFilesArgs } from "./utils";
diff --git a/functions/metadata/dist/index.esm.js b/functions/metadata/dist/index.esm.js
new file mode 100644
index 0000000..ff18637
--- /dev/null
+++ b/functions/metadata/dist/index.esm.js
@@ -0,0 +1,6793 @@
+// src/AuthorsMap.ts
+var AuthorsMap = class {
+  /**
+   * Creates an empty instance of authors map. Doesn't generate default metadata because
+   * can't assume anything about the authors.
+   *
+   * @constructor
+   */
+  constructor() {
+    this.authors = {};
+  }
+  /**
+   * Returns the final list format of the authors according to Psych-DS standards.
+   *
+   * @returns {(AuthorFields | string)[]} - List of authors
+   */
+  getList() {
+    const author_list = [];
+    for (const key of Object.keys(this.authors)) {
+      author_list.push(this.authors[key]);
+    }
+    return author_list;
+  }
+  /**
+   * Method that creates an author. This method can also be used to overwrite existing authors
+   * with the same name in order to update fields.
+   *
+   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+   */
+  setAuthor(author) {
+    if (typeof author === "string") {
+      this.authors[author] = author;
+      return;
+    }
+    if (!author.name) {
+      console.warn("Name field is missing. Author not added.");
+      return;
+    }
+    const { name, ...rest } = author;
+    if (Object.keys(rest).length == 0) {
+      this.authors[name] = name;
+    } else {
+      const newAuthor = { name, ...rest };
+      this.authors[name] = newAuthor;
+      const unexpectedFields = Object.keys(author).filter(
+        (key) => !["@type", "name", "givenName", "familyName", "identifier"].includes(key)
+      );
+      if (unexpectedFields.length > 0) {
+        console.warn(
+          `Unexpected fields (${unexpectedFields.join(
+            ", "
+          )}) detected and included in the author object.`
+        );
+      }
+    }
+  }
+  /**
+   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+   *
+   * @param {string} name - Name of author to be used as key.
+   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+   */
+  getAuthor(name) {
+    if (name in this.authors) {
+      return this.authors[name];
+    } else {
+      console.warn("Author (", name, ") not found.");
+      return {};
+    }
+  }
+  /**
+   * Deletes the author if it exists, printing out warning if doesn't exist. 
+   *
+   * @param {string} author_name - Name of author to be deleted
+   */
+  deleteAuthor(author_name) {
+    if (author_name in this.authors) {
+      delete this.authors[author_name];
+    } else {
+      console.error(`Author "${author_name}" does not exist.`);
+    }
+  }
+};
+
+// src/PluginCache.ts
+var PluginCache = class {
+  constructor() {
+    this.pluginFields = {};
+  }
+  /**
+   * Gets the description of a variable in a plugin by fetching the source code of the plugin
+   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+   * to extract the description for the variable (present as JSDoc); caches the result for future use.
+   *
+   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+   * @param {string} variableName - The name of the variable for which information is to be fetched.
+   * @param {string} version - The name of the variable for which information is to be fetched. 
+   * @param {boolean} verbose - Indicates whether should run with verbose mode
+   * @param {boolean} [extension] - An optional flag to indicate if an extension should be used.
+   * @returns {Promise} The description of the plugin variable if found, otherwise null.
+   * @throws Will throw an error if the fetch operation fails.
+   */
+  async getPluginInfo(pluginType, variableName, version2, verbose, extension) {
+    if (!(pluginType in this.pluginFields)) {
+      const fields = await this.generatePluginFields(pluginType, version2, verbose, extension);
+      this.pluginFields[pluginType] = fields;
+    }
+    if (variableName in this.pluginFields[pluginType])
+      return this.pluginFields[pluginType][variableName];
+    else
+      return {
+        description: "unknown",
+        type: "unknown"
+      };
+  }
+  /**
+   * Method that handles the generation of the fields and calls helpers methods that 
+   * fetch and parse the plugin data.
+   *
+   * @private
+   * @async
+   * @param {string} pluginType - Name of plugin or extension to fetch.
+   * @param {string} version - String version to fetch
+   * @param {boolean} verbose - Boolean indicating verbose mode
+   * @param {?boolean} [extension] - Optional flag if pluginType is extension
+   * @returns {unknown}
+   */
+  async generatePluginFields(pluginType, version2, verbose, extension) {
+    const script = await this.fetchScript(pluginType, version2, verbose, extension);
+    if (script !== void 0 && script !== null && script !== "") {
+      try {
+        return this.parseJavadocString(script);
+      } catch (err) {
+        console.warn("* Error parsing", pluginType, err);
+        return {};
+      }
+    } else {
+      return {};
+    }
+  }
+  /**
+   * The method that generates the unpkg links based on whether extension vs plugin and the 
+   * specific type.
+   *
+   * @private
+   * @param {string} pluginType - Name of plugin or extension to fetch
+   * @param {string} version - String version used
+   * @param {?boolean} [extension] - Optional flag if pluginType is extension
+   * @returns {string}
+   */
+  generateUnpkg(pluginType, version2, extension) {
+    if (extension) {
+      if (version2) {
+        return `https://unpkg.com/@jspsych/extension-${pluginType}@${version2}/src/index.ts`;
+      } else return `https://unpkg.com/@jspsych/extension-${pluginType}/src/index.ts`;
+    }
+    if (version2) {
+      return `https://unpkg.com/@jspsych/plugin-${pluginType}@${version2}/src/index.ts`;
+    } else return `https://unpkg.com/@jspsych/plugin-${pluginType}/src/index.ts`;
+  }
+  /**
+   * Fetches the actual script text content from unpkg. Calls the method to generate the link 
+   * and then handles error checking and fetching.
+   *
+   * @private
+   * @async
+   * @param {string} pluginType - The plugin or extension name to be fetched
+   * @param {string} version - The string version of the plugin
+   * @param {boolean} verbose - Boolean indicating verbose mode
+   * @param {?boolean} [extension] - Whether pluginType is extension
+   * @returns {unknown}
+   */
+  async fetchScript(pluginType, version2, verbose, extension) {
+    const unpkgUrl = this.generateUnpkg(pluginType, version2, extension);
+    if (verbose) console.log("-> fetching information for [", pluginType, "] from ->", unpkgUrl);
+    try {
+      const response = await fetch(unpkgUrl);
+      if (!response.ok) {
+        console.warn(`Plugin source not found for: ${pluginType} (HTTP ${response.status}). Descriptions will default to "unknown".`);
+        return void 0;
+      }
+      const scriptContent = await response.text();
+      return scriptContent;
+    } catch (error) {
+      console.error(
+        `Plugin fetching failed for:`,
+        pluginType,
+        "with error",
+        error,
+        "Note: if you are using a plugin not supported the main JsPsych branch this will always fail."
+      );
+      return void 0;
+    }
+  }
+  /**
+   * Extracts the content of the top-level `data: { ... }` block from a jsPsych plugin source
+   * file using brace counting. This is more robust than a regex approach because the data block
+   * ends with `},` (not `};`), and plugin sources contain deeply nested objects that would
+   * cause a lazy regex to stop at the wrong closing brace.
+   *
+   * Known limitations (acceptable for current jsPsych plugin sources):
+   * - Matches the first `data:` property in the file; a plugin with a `data:` field inside its
+   *   `parameters` block before the top-level `info.data` block would extract the wrong object.
+   * - Brace counting treats every `{`/`}` as structural; braces inside string literals or JSDoc
+   *   comments (e.g. `/** e.g. {foo: 1} *\/`) would throw off the counter.
+   *
+   * @private
+   * @param {string} script - Full plugin source text.
+   * @returns {string | null} Content between the outer braces of the data block, or null if not found.
+   */
+  extractDataBlock(script) {
+    const dataStart = script.search(/\bdata:\s*\{/);
+    if (dataStart === -1) return null;
+    const braceStart = script.indexOf("{", dataStart);
+    if (braceStart === -1) return null;
+    const braceEnd = this.findMatchingBrace(script, braceStart);
+    if (braceEnd === -1) return null;
+    return script.substring(braceStart + 1, braceEnd);
+  }
+  /**
+   * Parses JSDoc comments and variable blocks from the data section of a jsPsych plugin source.
+   *
+   * @private
+   * @param {string} script - The script text content of the fetching.
+   * @returns {{}}
+   */
+  parseJavadocString(script) {
+    const dataBlock = this.extractDataBlock(script);
+    if (!dataBlock) return {};
+    return this.extractJsdocFields(dataBlock);
+  }
+  /**
+   * Extracts JSDoc-annotated fields from a data block string. Uses brace counting to find
+   * each variable's true closing brace, then recursively processes any `nested:` sub-object
+   * so that nested parameter descriptions are also captured.
+   *
+   * @private
+   * @param {string} block - Content of a data or nested block (without outer braces).
+   * @returns {Record}
+   */
+  extractJsdocFields(block) {
+    const result = {};
+    const varStartRegex = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(\w+):\s*\{/g;
+    const propRegex = /(\w+):\s*([^,\s{}]+)/g;
+    let match;
+    while ((match = varStartRegex.exec(block)) !== null) {
+      const description = match[1].replace(/^[ \t]*\*[ \t]?/gm, "").trim().replace(/\s+/g, " ");
+      const varName = match[2];
+      const braceStart = match.index + match[0].length - 1;
+      const braceEnd = this.findMatchingBrace(block, braceStart);
+      if (braceEnd === -1) continue;
+      varStartRegex.lastIndex = braceEnd + 1;
+      const varContent = block.substring(braceStart + 1, braceEnd);
+      const propsObj = {};
+      let propMatch;
+      propRegex.lastIndex = 0;
+      while ((propMatch = propRegex.exec(varContent)) !== null) {
+        propsObj[propMatch[1]] = propMatch[2];
+      }
+      result[varName] = { description, ...propsObj };
+      const nestedSearch = /\bnested:\s*\{/.exec(varContent);
+      if (nestedSearch) {
+        const nestedBraceStart = varContent.indexOf("{", nestedSearch.index);
+        const nestedBraceEnd = this.findMatchingBrace(varContent, nestedBraceStart);
+        if (nestedBraceEnd !== -1) {
+          Object.assign(result, this.extractJsdocFields(varContent.substring(nestedBraceStart + 1, nestedBraceEnd)));
+        }
+      }
+    }
+    return result;
+  }
+  /**
+   * Returns the index of the `}` that closes the `{` at `startIndex`, using brace counting.
+   * Returns -1 if the source is unbalanced (no matching closing brace found).
+   *
+   * @private
+   * @param {string} str - String to search.
+   * @param {number} startIndex - Index of the opening `{`.
+   * @returns {number}
+   */
+  findMatchingBrace(str, startIndex) {
+    let depth = 0;
+    for (let i = startIndex; i < str.length; i++) {
+      if (str[i] === "{") depth++;
+      else if (str[i] === "}" && --depth === 0) return i;
+    }
+    return -1;
+  }
+};
+
+// ../../node_modules/csv-parse/dist/esm/index.js
+var global$1 = typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {};
+var lookup = [];
+var revLookup = [];
+var Arr = typeof Uint8Array !== "undefined" ? Uint8Array : Array;
+var inited = false;
+function init() {
+  inited = true;
+  var code = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+  for (var i = 0, len = code.length; i < len; ++i) {
+    lookup[i] = code[i];
+    revLookup[code.charCodeAt(i)] = i;
+  }
+  revLookup["-".charCodeAt(0)] = 62;
+  revLookup["_".charCodeAt(0)] = 63;
+}
+function toByteArray(b64) {
+  if (!inited) {
+    init();
+  }
+  var i, j, l, tmp, placeHolders, arr;
+  var len = b64.length;
+  if (len % 4 > 0) {
+    throw new Error("Invalid string. Length must be a multiple of 4");
+  }
+  placeHolders = b64[len - 2] === "=" ? 2 : b64[len - 1] === "=" ? 1 : 0;
+  arr = new Arr(len * 3 / 4 - placeHolders);
+  l = placeHolders > 0 ? len - 4 : len;
+  var L = 0;
+  for (i = 0, j = 0; i < l; i += 4, j += 3) {
+    tmp = revLookup[b64.charCodeAt(i)] << 18 | revLookup[b64.charCodeAt(i + 1)] << 12 | revLookup[b64.charCodeAt(i + 2)] << 6 | revLookup[b64.charCodeAt(i + 3)];
+    arr[L++] = tmp >> 16 & 255;
+    arr[L++] = tmp >> 8 & 255;
+    arr[L++] = tmp & 255;
+  }
+  if (placeHolders === 2) {
+    tmp = revLookup[b64.charCodeAt(i)] << 2 | revLookup[b64.charCodeAt(i + 1)] >> 4;
+    arr[L++] = tmp & 255;
+  } else if (placeHolders === 1) {
+    tmp = revLookup[b64.charCodeAt(i)] << 10 | revLookup[b64.charCodeAt(i + 1)] << 4 | revLookup[b64.charCodeAt(i + 2)] >> 2;
+    arr[L++] = tmp >> 8 & 255;
+    arr[L++] = tmp & 255;
+  }
+  return arr;
+}
+function tripletToBase64(num) {
+  return lookup[num >> 18 & 63] + lookup[num >> 12 & 63] + lookup[num >> 6 & 63] + lookup[num & 63];
+}
+function encodeChunk(uint8, start, end) {
+  var tmp;
+  var output = [];
+  for (var i = start; i < end; i += 3) {
+    tmp = (uint8[i] << 16) + (uint8[i + 1] << 8) + uint8[i + 2];
+    output.push(tripletToBase64(tmp));
+  }
+  return output.join("");
+}
+function fromByteArray(uint8) {
+  if (!inited) {
+    init();
+  }
+  var tmp;
+  var len = uint8.length;
+  var extraBytes = len % 3;
+  var output = "";
+  var parts = [];
+  var maxChunkLength = 16383;
+  for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) {
+    parts.push(encodeChunk(uint8, i, i + maxChunkLength > len2 ? len2 : i + maxChunkLength));
+  }
+  if (extraBytes === 1) {
+    tmp = uint8[len - 1];
+    output += lookup[tmp >> 2];
+    output += lookup[tmp << 4 & 63];
+    output += "==";
+  } else if (extraBytes === 2) {
+    tmp = (uint8[len - 2] << 8) + uint8[len - 1];
+    output += lookup[tmp >> 10];
+    output += lookup[tmp >> 4 & 63];
+    output += lookup[tmp << 2 & 63];
+    output += "=";
+  }
+  parts.push(output);
+  return parts.join("");
+}
+function read(buffer, offset, isLE, mLen, nBytes) {
+  var e, m;
+  var eLen = nBytes * 8 - mLen - 1;
+  var eMax = (1 << eLen) - 1;
+  var eBias = eMax >> 1;
+  var nBits = -7;
+  var i = isLE ? nBytes - 1 : 0;
+  var d = isLE ? -1 : 1;
+  var s = buffer[offset + i];
+  i += d;
+  e = s & (1 << -nBits) - 1;
+  s >>= -nBits;
+  nBits += eLen;
+  for (; nBits > 0; e = e * 256 + buffer[offset + i], i += d, nBits -= 8) {
+  }
+  m = e & (1 << -nBits) - 1;
+  e >>= -nBits;
+  nBits += mLen;
+  for (; nBits > 0; m = m * 256 + buffer[offset + i], i += d, nBits -= 8) {
+  }
+  if (e === 0) {
+    e = 1 - eBias;
+  } else if (e === eMax) {
+    return m ? NaN : (s ? -1 : 1) * Infinity;
+  } else {
+    m = m + Math.pow(2, mLen);
+    e = e - eBias;
+  }
+  return (s ? -1 : 1) * m * Math.pow(2, e - mLen);
+}
+function write(buffer, value, offset, isLE, mLen, nBytes) {
+  var e, m, c;
+  var eLen = nBytes * 8 - mLen - 1;
+  var eMax = (1 << eLen) - 1;
+  var eBias = eMax >> 1;
+  var rt = mLen === 23 ? Math.pow(2, -24) - Math.pow(2, -77) : 0;
+  var i = isLE ? 0 : nBytes - 1;
+  var d = isLE ? 1 : -1;
+  var s = value < 0 || value === 0 && 1 / value < 0 ? 1 : 0;
+  value = Math.abs(value);
+  if (isNaN(value) || value === Infinity) {
+    m = isNaN(value) ? 1 : 0;
+    e = eMax;
+  } else {
+    e = Math.floor(Math.log(value) / Math.LN2);
+    if (value * (c = Math.pow(2, -e)) < 1) {
+      e--;
+      c *= 2;
+    }
+    if (e + eBias >= 1) {
+      value += rt / c;
+    } else {
+      value += rt * Math.pow(2, 1 - eBias);
+    }
+    if (value * c >= 2) {
+      e++;
+      c /= 2;
+    }
+    if (e + eBias >= eMax) {
+      m = 0;
+      e = eMax;
+    } else if (e + eBias >= 1) {
+      m = (value * c - 1) * Math.pow(2, mLen);
+      e = e + eBias;
+    } else {
+      m = value * Math.pow(2, eBias - 1) * Math.pow(2, mLen);
+      e = 0;
+    }
+  }
+  for (; mLen >= 8; buffer[offset + i] = m & 255, i += d, m /= 256, mLen -= 8) {
+  }
+  e = e << mLen | m;
+  eLen += mLen;
+  for (; eLen > 0; buffer[offset + i] = e & 255, i += d, e /= 256, eLen -= 8) {
+  }
+  buffer[offset + i - d] |= s * 128;
+}
+var toString = {}.toString;
+var isArray$1 = Array.isArray || function(arr) {
+  return toString.call(arr) == "[object Array]";
+};
+var INSPECT_MAX_BYTES = 50;
+Buffer.TYPED_ARRAY_SUPPORT = global$1.TYPED_ARRAY_SUPPORT !== void 0 ? global$1.TYPED_ARRAY_SUPPORT : true;
+kMaxLength();
+function kMaxLength() {
+  return Buffer.TYPED_ARRAY_SUPPORT ? 2147483647 : 1073741823;
+}
+function createBuffer(that, length) {
+  if (kMaxLength() < length) {
+    throw new RangeError("Invalid typed array length");
+  }
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    that = new Uint8Array(length);
+    that.__proto__ = Buffer.prototype;
+  } else {
+    if (that === null) {
+      that = new Buffer(length);
+    }
+    that.length = length;
+  }
+  return that;
+}
+function Buffer(arg, encodingOrOffset, length) {
+  if (!Buffer.TYPED_ARRAY_SUPPORT && !(this instanceof Buffer)) {
+    return new Buffer(arg, encodingOrOffset, length);
+  }
+  if (typeof arg === "number") {
+    if (typeof encodingOrOffset === "string") {
+      throw new Error(
+        "If encoding is specified then the first argument must be a string"
+      );
+    }
+    return allocUnsafe(this, arg);
+  }
+  return from(this, arg, encodingOrOffset, length);
+}
+Buffer.poolSize = 8192;
+Buffer._augment = function(arr) {
+  arr.__proto__ = Buffer.prototype;
+  return arr;
+};
+function from(that, value, encodingOrOffset, length) {
+  if (typeof value === "number") {
+    throw new TypeError('"value" argument must not be a number');
+  }
+  if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) {
+    return fromArrayBuffer(that, value, encodingOrOffset, length);
+  }
+  if (typeof value === "string") {
+    return fromString(that, value, encodingOrOffset);
+  }
+  return fromObject(that, value);
+}
+Buffer.from = function(value, encodingOrOffset, length) {
+  return from(null, value, encodingOrOffset, length);
+};
+if (Buffer.TYPED_ARRAY_SUPPORT) {
+  Buffer.prototype.__proto__ = Uint8Array.prototype;
+  Buffer.__proto__ = Uint8Array;
+  if (typeof Symbol !== "undefined" && Symbol.species && Buffer[Symbol.species] === Buffer) ;
+}
+function assertSize(size) {
+  if (typeof size !== "number") {
+    throw new TypeError('"size" argument must be a number');
+  } else if (size < 0) {
+    throw new RangeError('"size" argument must not be negative');
+  }
+}
+function alloc(that, size, fill2, encoding) {
+  assertSize(size);
+  if (size <= 0) {
+    return createBuffer(that, size);
+  }
+  if (fill2 !== void 0) {
+    return typeof encoding === "string" ? createBuffer(that, size).fill(fill2, encoding) : createBuffer(that, size).fill(fill2);
+  }
+  return createBuffer(that, size);
+}
+Buffer.alloc = function(size, fill2, encoding) {
+  return alloc(null, size, fill2, encoding);
+};
+function allocUnsafe(that, size) {
+  assertSize(size);
+  that = createBuffer(that, size < 0 ? 0 : checked(size) | 0);
+  if (!Buffer.TYPED_ARRAY_SUPPORT) {
+    for (var i = 0; i < size; ++i) {
+      that[i] = 0;
+    }
+  }
+  return that;
+}
+Buffer.allocUnsafe = function(size) {
+  return allocUnsafe(null, size);
+};
+Buffer.allocUnsafeSlow = function(size) {
+  return allocUnsafe(null, size);
+};
+function fromString(that, string, encoding) {
+  if (typeof encoding !== "string" || encoding === "") {
+    encoding = "utf8";
+  }
+  if (!Buffer.isEncoding(encoding)) {
+    throw new TypeError('"encoding" must be a valid string encoding');
+  }
+  var length = byteLength(string, encoding) | 0;
+  that = createBuffer(that, length);
+  var actual = that.write(string, encoding);
+  if (actual !== length) {
+    that = that.slice(0, actual);
+  }
+  return that;
+}
+function fromArrayLike(that, array) {
+  var length = array.length < 0 ? 0 : checked(array.length) | 0;
+  that = createBuffer(that, length);
+  for (var i = 0; i < length; i += 1) {
+    that[i] = array[i] & 255;
+  }
+  return that;
+}
+function fromArrayBuffer(that, array, byteOffset, length) {
+  array.byteLength;
+  if (byteOffset < 0 || array.byteLength < byteOffset) {
+    throw new RangeError("'offset' is out of bounds");
+  }
+  if (array.byteLength < byteOffset + (length || 0)) {
+    throw new RangeError("'length' is out of bounds");
+  }
+  if (byteOffset === void 0 && length === void 0) {
+    array = new Uint8Array(array);
+  } else if (length === void 0) {
+    array = new Uint8Array(array, byteOffset);
+  } else {
+    array = new Uint8Array(array, byteOffset, length);
+  }
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    that = array;
+    that.__proto__ = Buffer.prototype;
+  } else {
+    that = fromArrayLike(that, array);
+  }
+  return that;
+}
+function fromObject(that, obj) {
+  if (internalIsBuffer(obj)) {
+    var len = checked(obj.length) | 0;
+    that = createBuffer(that, len);
+    if (that.length === 0) {
+      return that;
+    }
+    obj.copy(that, 0, 0, len);
+    return that;
+  }
+  if (obj) {
+    if (typeof ArrayBuffer !== "undefined" && obj.buffer instanceof ArrayBuffer || "length" in obj) {
+      if (typeof obj.length !== "number" || isnan(obj.length)) {
+        return createBuffer(that, 0);
+      }
+      return fromArrayLike(that, obj);
+    }
+    if (obj.type === "Buffer" && isArray$1(obj.data)) {
+      return fromArrayLike(that, obj.data);
+    }
+  }
+  throw new TypeError("First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.");
+}
+function checked(length) {
+  if (length >= kMaxLength()) {
+    throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x" + kMaxLength().toString(16) + " bytes");
+  }
+  return length | 0;
+}
+Buffer.isBuffer = isBuffer;
+function internalIsBuffer(b) {
+  return !!(b != null && b._isBuffer);
+}
+Buffer.compare = function compare(a, b) {
+  if (!internalIsBuffer(a) || !internalIsBuffer(b)) {
+    throw new TypeError("Arguments must be Buffers");
+  }
+  if (a === b) return 0;
+  var x = a.length;
+  var y = b.length;
+  for (var i = 0, len = Math.min(x, y); i < len; ++i) {
+    if (a[i] !== b[i]) {
+      x = a[i];
+      y = b[i];
+      break;
+    }
+  }
+  if (x < y) return -1;
+  if (y < x) return 1;
+  return 0;
+};
+Buffer.isEncoding = function isEncoding(encoding) {
+  switch (String(encoding).toLowerCase()) {
+    case "hex":
+    case "utf8":
+    case "utf-8":
+    case "ascii":
+    case "latin1":
+    case "binary":
+    case "base64":
+    case "ucs2":
+    case "ucs-2":
+    case "utf16le":
+    case "utf-16le":
+      return true;
+    default:
+      return false;
+  }
+};
+Buffer.concat = function concat(list, length) {
+  if (!isArray$1(list)) {
+    throw new TypeError('"list" argument must be an Array of Buffers');
+  }
+  if (list.length === 0) {
+    return Buffer.alloc(0);
+  }
+  var i;
+  if (length === void 0) {
+    length = 0;
+    for (i = 0; i < list.length; ++i) {
+      length += list[i].length;
+    }
+  }
+  var buffer = Buffer.allocUnsafe(length);
+  var pos = 0;
+  for (i = 0; i < list.length; ++i) {
+    var buf = list[i];
+    if (!internalIsBuffer(buf)) {
+      throw new TypeError('"list" argument must be an Array of Buffers');
+    }
+    buf.copy(buffer, pos);
+    pos += buf.length;
+  }
+  return buffer;
+};
+function byteLength(string, encoding) {
+  if (internalIsBuffer(string)) {
+    return string.length;
+  }
+  if (typeof ArrayBuffer !== "undefined" && typeof ArrayBuffer.isView === "function" && (ArrayBuffer.isView(string) || string instanceof ArrayBuffer)) {
+    return string.byteLength;
+  }
+  if (typeof string !== "string") {
+    string = "" + string;
+  }
+  var len = string.length;
+  if (len === 0) return 0;
+  var loweredCase = false;
+  for (; ; ) {
+    switch (encoding) {
+      case "ascii":
+      case "latin1":
+      case "binary":
+        return len;
+      case "utf8":
+      case "utf-8":
+      case void 0:
+        return utf8ToBytes(string).length;
+      case "ucs2":
+      case "ucs-2":
+      case "utf16le":
+      case "utf-16le":
+        return len * 2;
+      case "hex":
+        return len >>> 1;
+      case "base64":
+        return base64ToBytes(string).length;
+      default:
+        if (loweredCase) return utf8ToBytes(string).length;
+        encoding = ("" + encoding).toLowerCase();
+        loweredCase = true;
+    }
+  }
+}
+Buffer.byteLength = byteLength;
+function slowToString(encoding, start, end) {
+  var loweredCase = false;
+  if (start === void 0 || start < 0) {
+    start = 0;
+  }
+  if (start > this.length) {
+    return "";
+  }
+  if (end === void 0 || end > this.length) {
+    end = this.length;
+  }
+  if (end <= 0) {
+    return "";
+  }
+  end >>>= 0;
+  start >>>= 0;
+  if (end <= start) {
+    return "";
+  }
+  if (!encoding) encoding = "utf8";
+  while (true) {
+    switch (encoding) {
+      case "hex":
+        return hexSlice(this, start, end);
+      case "utf8":
+      case "utf-8":
+        return utf8Slice(this, start, end);
+      case "ascii":
+        return asciiSlice(this, start, end);
+      case "latin1":
+      case "binary":
+        return latin1Slice(this, start, end);
+      case "base64":
+        return base64Slice(this, start, end);
+      case "ucs2":
+      case "ucs-2":
+      case "utf16le":
+      case "utf-16le":
+        return utf16leSlice(this, start, end);
+      default:
+        if (loweredCase) throw new TypeError("Unknown encoding: " + encoding);
+        encoding = (encoding + "").toLowerCase();
+        loweredCase = true;
+    }
+  }
+}
+Buffer.prototype._isBuffer = true;
+function swap(b, n, m) {
+  var i = b[n];
+  b[n] = b[m];
+  b[m] = i;
+}
+Buffer.prototype.swap16 = function swap16() {
+  var len = this.length;
+  if (len % 2 !== 0) {
+    throw new RangeError("Buffer size must be a multiple of 16-bits");
+  }
+  for (var i = 0; i < len; i += 2) {
+    swap(this, i, i + 1);
+  }
+  return this;
+};
+Buffer.prototype.swap32 = function swap32() {
+  var len = this.length;
+  if (len % 4 !== 0) {
+    throw new RangeError("Buffer size must be a multiple of 32-bits");
+  }
+  for (var i = 0; i < len; i += 4) {
+    swap(this, i, i + 3);
+    swap(this, i + 1, i + 2);
+  }
+  return this;
+};
+Buffer.prototype.swap64 = function swap64() {
+  var len = this.length;
+  if (len % 8 !== 0) {
+    throw new RangeError("Buffer size must be a multiple of 64-bits");
+  }
+  for (var i = 0; i < len; i += 8) {
+    swap(this, i, i + 7);
+    swap(this, i + 1, i + 6);
+    swap(this, i + 2, i + 5);
+    swap(this, i + 3, i + 4);
+  }
+  return this;
+};
+Buffer.prototype.toString = function toString2() {
+  var length = this.length | 0;
+  if (length === 0) return "";
+  if (arguments.length === 0) return utf8Slice(this, 0, length);
+  return slowToString.apply(this, arguments);
+};
+Buffer.prototype.equals = function equals(b) {
+  if (!internalIsBuffer(b)) throw new TypeError("Argument must be a Buffer");
+  if (this === b) return true;
+  return Buffer.compare(this, b) === 0;
+};
+Buffer.prototype.inspect = function inspect() {
+  var str = "";
+  var max = INSPECT_MAX_BYTES;
+  if (this.length > 0) {
+    str = this.toString("hex", 0, max).match(/.{2}/g).join(" ");
+    if (this.length > max) str += " ... ";
+  }
+  return "";
+};
+Buffer.prototype.compare = function compare2(target, start, end, thisStart, thisEnd) {
+  if (!internalIsBuffer(target)) {
+    throw new TypeError("Argument must be a Buffer");
+  }
+  if (start === void 0) {
+    start = 0;
+  }
+  if (end === void 0) {
+    end = target ? target.length : 0;
+  }
+  if (thisStart === void 0) {
+    thisStart = 0;
+  }
+  if (thisEnd === void 0) {
+    thisEnd = this.length;
+  }
+  if (start < 0 || end > target.length || thisStart < 0 || thisEnd > this.length) {
+    throw new RangeError("out of range index");
+  }
+  if (thisStart >= thisEnd && start >= end) {
+    return 0;
+  }
+  if (thisStart >= thisEnd) {
+    return -1;
+  }
+  if (start >= end) {
+    return 1;
+  }
+  start >>>= 0;
+  end >>>= 0;
+  thisStart >>>= 0;
+  thisEnd >>>= 0;
+  if (this === target) return 0;
+  var x = thisEnd - thisStart;
+  var y = end - start;
+  var len = Math.min(x, y);
+  var thisCopy = this.slice(thisStart, thisEnd);
+  var targetCopy = target.slice(start, end);
+  for (var i = 0; i < len; ++i) {
+    if (thisCopy[i] !== targetCopy[i]) {
+      x = thisCopy[i];
+      y = targetCopy[i];
+      break;
+    }
+  }
+  if (x < y) return -1;
+  if (y < x) return 1;
+  return 0;
+};
+function bidirectionalIndexOf(buffer, val, byteOffset, encoding, dir) {
+  if (buffer.length === 0) return -1;
+  if (typeof byteOffset === "string") {
+    encoding = byteOffset;
+    byteOffset = 0;
+  } else if (byteOffset > 2147483647) {
+    byteOffset = 2147483647;
+  } else if (byteOffset < -2147483648) {
+    byteOffset = -2147483648;
+  }
+  byteOffset = +byteOffset;
+  if (isNaN(byteOffset)) {
+    byteOffset = dir ? 0 : buffer.length - 1;
+  }
+  if (byteOffset < 0) byteOffset = buffer.length + byteOffset;
+  if (byteOffset >= buffer.length) {
+    if (dir) return -1;
+    else byteOffset = buffer.length - 1;
+  } else if (byteOffset < 0) {
+    if (dir) byteOffset = 0;
+    else return -1;
+  }
+  if (typeof val === "string") {
+    val = Buffer.from(val, encoding);
+  }
+  if (internalIsBuffer(val)) {
+    if (val.length === 0) {
+      return -1;
+    }
+    return arrayIndexOf(buffer, val, byteOffset, encoding, dir);
+  } else if (typeof val === "number") {
+    val = val & 255;
+    if (Buffer.TYPED_ARRAY_SUPPORT && typeof Uint8Array.prototype.indexOf === "function") {
+      if (dir) {
+        return Uint8Array.prototype.indexOf.call(buffer, val, byteOffset);
+      } else {
+        return Uint8Array.prototype.lastIndexOf.call(buffer, val, byteOffset);
+      }
+    }
+    return arrayIndexOf(buffer, [val], byteOffset, encoding, dir);
+  }
+  throw new TypeError("val must be string, number or Buffer");
+}
+function arrayIndexOf(arr, val, byteOffset, encoding, dir) {
+  var indexSize = 1;
+  var arrLength = arr.length;
+  var valLength = val.length;
+  if (encoding !== void 0) {
+    encoding = String(encoding).toLowerCase();
+    if (encoding === "ucs2" || encoding === "ucs-2" || encoding === "utf16le" || encoding === "utf-16le") {
+      if (arr.length < 2 || val.length < 2) {
+        return -1;
+      }
+      indexSize = 2;
+      arrLength /= 2;
+      valLength /= 2;
+      byteOffset /= 2;
+    }
+  }
+  function read2(buf, i2) {
+    if (indexSize === 1) {
+      return buf[i2];
+    } else {
+      return buf.readUInt16BE(i2 * indexSize);
+    }
+  }
+  var i;
+  if (dir) {
+    var foundIndex = -1;
+    for (i = byteOffset; i < arrLength; i++) {
+      if (read2(arr, i) === read2(val, foundIndex === -1 ? 0 : i - foundIndex)) {
+        if (foundIndex === -1) foundIndex = i;
+        if (i - foundIndex + 1 === valLength) return foundIndex * indexSize;
+      } else {
+        if (foundIndex !== -1) i -= i - foundIndex;
+        foundIndex = -1;
+      }
+    }
+  } else {
+    if (byteOffset + valLength > arrLength) byteOffset = arrLength - valLength;
+    for (i = byteOffset; i >= 0; i--) {
+      var found = true;
+      for (var j = 0; j < valLength; j++) {
+        if (read2(arr, i + j) !== read2(val, j)) {
+          found = false;
+          break;
+        }
+      }
+      if (found) return i;
+    }
+  }
+  return -1;
+}
+Buffer.prototype.includes = function includes(val, byteOffset, encoding) {
+  return this.indexOf(val, byteOffset, encoding) !== -1;
+};
+Buffer.prototype.indexOf = function indexOf(val, byteOffset, encoding) {
+  return bidirectionalIndexOf(this, val, byteOffset, encoding, true);
+};
+Buffer.prototype.lastIndexOf = function lastIndexOf(val, byteOffset, encoding) {
+  return bidirectionalIndexOf(this, val, byteOffset, encoding, false);
+};
+function hexWrite(buf, string, offset, length) {
+  offset = Number(offset) || 0;
+  var remaining = buf.length - offset;
+  if (!length) {
+    length = remaining;
+  } else {
+    length = Number(length);
+    if (length > remaining) {
+      length = remaining;
+    }
+  }
+  var strLen = string.length;
+  if (strLen % 2 !== 0) throw new TypeError("Invalid hex string");
+  if (length > strLen / 2) {
+    length = strLen / 2;
+  }
+  for (var i = 0; i < length; ++i) {
+    var parsed = parseInt(string.substr(i * 2, 2), 16);
+    if (isNaN(parsed)) return i;
+    buf[offset + i] = parsed;
+  }
+  return i;
+}
+function utf8Write(buf, string, offset, length) {
+  return blitBuffer(utf8ToBytes(string, buf.length - offset), buf, offset, length);
+}
+function asciiWrite(buf, string, offset, length) {
+  return blitBuffer(asciiToBytes(string), buf, offset, length);
+}
+function latin1Write(buf, string, offset, length) {
+  return asciiWrite(buf, string, offset, length);
+}
+function base64Write(buf, string, offset, length) {
+  return blitBuffer(base64ToBytes(string), buf, offset, length);
+}
+function ucs2Write(buf, string, offset, length) {
+  return blitBuffer(utf16leToBytes(string, buf.length - offset), buf, offset, length);
+}
+Buffer.prototype.write = function write2(string, offset, length, encoding) {
+  if (offset === void 0) {
+    encoding = "utf8";
+    length = this.length;
+    offset = 0;
+  } else if (length === void 0 && typeof offset === "string") {
+    encoding = offset;
+    length = this.length;
+    offset = 0;
+  } else if (isFinite(offset)) {
+    offset = offset | 0;
+    if (isFinite(length)) {
+      length = length | 0;
+      if (encoding === void 0) encoding = "utf8";
+    } else {
+      encoding = length;
+      length = void 0;
+    }
+  } else {
+    throw new Error(
+      "Buffer.write(string, encoding, offset[, length]) is no longer supported"
+    );
+  }
+  var remaining = this.length - offset;
+  if (length === void 0 || length > remaining) length = remaining;
+  if (string.length > 0 && (length < 0 || offset < 0) || offset > this.length) {
+    throw new RangeError("Attempt to write outside buffer bounds");
+  }
+  if (!encoding) encoding = "utf8";
+  var loweredCase = false;
+  for (; ; ) {
+    switch (encoding) {
+      case "hex":
+        return hexWrite(this, string, offset, length);
+      case "utf8":
+      case "utf-8":
+        return utf8Write(this, string, offset, length);
+      case "ascii":
+        return asciiWrite(this, string, offset, length);
+      case "latin1":
+      case "binary":
+        return latin1Write(this, string, offset, length);
+      case "base64":
+        return base64Write(this, string, offset, length);
+      case "ucs2":
+      case "ucs-2":
+      case "utf16le":
+      case "utf-16le":
+        return ucs2Write(this, string, offset, length);
+      default:
+        if (loweredCase) throw new TypeError("Unknown encoding: " + encoding);
+        encoding = ("" + encoding).toLowerCase();
+        loweredCase = true;
+    }
+  }
+};
+Buffer.prototype.toJSON = function toJSON() {
+  return {
+    type: "Buffer",
+    data: Array.prototype.slice.call(this._arr || this, 0)
+  };
+};
+function base64Slice(buf, start, end) {
+  if (start === 0 && end === buf.length) {
+    return fromByteArray(buf);
+  } else {
+    return fromByteArray(buf.slice(start, end));
+  }
+}
+function utf8Slice(buf, start, end) {
+  end = Math.min(buf.length, end);
+  var res = [];
+  var i = start;
+  while (i < end) {
+    var firstByte = buf[i];
+    var codePoint = null;
+    var bytesPerSequence = firstByte > 239 ? 4 : firstByte > 223 ? 3 : firstByte > 191 ? 2 : 1;
+    if (i + bytesPerSequence <= end) {
+      var secondByte, thirdByte, fourthByte, tempCodePoint;
+      switch (bytesPerSequence) {
+        case 1:
+          if (firstByte < 128) {
+            codePoint = firstByte;
+          }
+          break;
+        case 2:
+          secondByte = buf[i + 1];
+          if ((secondByte & 192) === 128) {
+            tempCodePoint = (firstByte & 31) << 6 | secondByte & 63;
+            if (tempCodePoint > 127) {
+              codePoint = tempCodePoint;
+            }
+          }
+          break;
+        case 3:
+          secondByte = buf[i + 1];
+          thirdByte = buf[i + 2];
+          if ((secondByte & 192) === 128 && (thirdByte & 192) === 128) {
+            tempCodePoint = (firstByte & 15) << 12 | (secondByte & 63) << 6 | thirdByte & 63;
+            if (tempCodePoint > 2047 && (tempCodePoint < 55296 || tempCodePoint > 57343)) {
+              codePoint = tempCodePoint;
+            }
+          }
+          break;
+        case 4:
+          secondByte = buf[i + 1];
+          thirdByte = buf[i + 2];
+          fourthByte = buf[i + 3];
+          if ((secondByte & 192) === 128 && (thirdByte & 192) === 128 && (fourthByte & 192) === 128) {
+            tempCodePoint = (firstByte & 15) << 18 | (secondByte & 63) << 12 | (thirdByte & 63) << 6 | fourthByte & 63;
+            if (tempCodePoint > 65535 && tempCodePoint < 1114112) {
+              codePoint = tempCodePoint;
+            }
+          }
+      }
+    }
+    if (codePoint === null) {
+      codePoint = 65533;
+      bytesPerSequence = 1;
+    } else if (codePoint > 65535) {
+      codePoint -= 65536;
+      res.push(codePoint >>> 10 & 1023 | 55296);
+      codePoint = 56320 | codePoint & 1023;
+    }
+    res.push(codePoint);
+    i += bytesPerSequence;
+  }
+  return decodeCodePointsArray(res);
+}
+var MAX_ARGUMENTS_LENGTH = 4096;
+function decodeCodePointsArray(codePoints) {
+  var len = codePoints.length;
+  if (len <= MAX_ARGUMENTS_LENGTH) {
+    return String.fromCharCode.apply(String, codePoints);
+  }
+  var res = "";
+  var i = 0;
+  while (i < len) {
+    res += String.fromCharCode.apply(
+      String,
+      codePoints.slice(i, i += MAX_ARGUMENTS_LENGTH)
+    );
+  }
+  return res;
+}
+function asciiSlice(buf, start, end) {
+  var ret = "";
+  end = Math.min(buf.length, end);
+  for (var i = start; i < end; ++i) {
+    ret += String.fromCharCode(buf[i] & 127);
+  }
+  return ret;
+}
+function latin1Slice(buf, start, end) {
+  var ret = "";
+  end = Math.min(buf.length, end);
+  for (var i = start; i < end; ++i) {
+    ret += String.fromCharCode(buf[i]);
+  }
+  return ret;
+}
+function hexSlice(buf, start, end) {
+  var len = buf.length;
+  if (!start || start < 0) start = 0;
+  if (!end || end < 0 || end > len) end = len;
+  var out = "";
+  for (var i = start; i < end; ++i) {
+    out += toHex(buf[i]);
+  }
+  return out;
+}
+function utf16leSlice(buf, start, end) {
+  var bytes = buf.slice(start, end);
+  var res = "";
+  for (var i = 0; i < bytes.length; i += 2) {
+    res += String.fromCharCode(bytes[i] + bytes[i + 1] * 256);
+  }
+  return res;
+}
+Buffer.prototype.slice = function slice(start, end) {
+  var len = this.length;
+  start = ~~start;
+  end = end === void 0 ? len : ~~end;
+  if (start < 0) {
+    start += len;
+    if (start < 0) start = 0;
+  } else if (start > len) {
+    start = len;
+  }
+  if (end < 0) {
+    end += len;
+    if (end < 0) end = 0;
+  } else if (end > len) {
+    end = len;
+  }
+  if (end < start) end = start;
+  var newBuf;
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    newBuf = this.subarray(start, end);
+    newBuf.__proto__ = Buffer.prototype;
+  } else {
+    var sliceLen = end - start;
+    newBuf = new Buffer(sliceLen, void 0);
+    for (var i = 0; i < sliceLen; ++i) {
+      newBuf[i] = this[i + start];
+    }
+  }
+  return newBuf;
+};
+function checkOffset(offset, ext, length) {
+  if (offset % 1 !== 0 || offset < 0) throw new RangeError("offset is not uint");
+  if (offset + ext > length) throw new RangeError("Trying to access beyond buffer length");
+}
+Buffer.prototype.readUIntLE = function readUIntLE(offset, byteLength2, noAssert) {
+  offset = offset | 0;
+  byteLength2 = byteLength2 | 0;
+  if (!noAssert) checkOffset(offset, byteLength2, this.length);
+  var val = this[offset];
+  var mul = 1;
+  var i = 0;
+  while (++i < byteLength2 && (mul *= 256)) {
+    val += this[offset + i] * mul;
+  }
+  return val;
+};
+Buffer.prototype.readUIntBE = function readUIntBE(offset, byteLength2, noAssert) {
+  offset = offset | 0;
+  byteLength2 = byteLength2 | 0;
+  if (!noAssert) {
+    checkOffset(offset, byteLength2, this.length);
+  }
+  var val = this[offset + --byteLength2];
+  var mul = 1;
+  while (byteLength2 > 0 && (mul *= 256)) {
+    val += this[offset + --byteLength2] * mul;
+  }
+  return val;
+};
+Buffer.prototype.readUInt8 = function readUInt8(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 1, this.length);
+  return this[offset];
+};
+Buffer.prototype.readUInt16LE = function readUInt16LE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 2, this.length);
+  return this[offset] | this[offset + 1] << 8;
+};
+Buffer.prototype.readUInt16BE = function readUInt16BE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 2, this.length);
+  return this[offset] << 8 | this[offset + 1];
+};
+Buffer.prototype.readUInt32LE = function readUInt32LE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 4, this.length);
+  return (this[offset] | this[offset + 1] << 8 | this[offset + 2] << 16) + this[offset + 3] * 16777216;
+};
+Buffer.prototype.readUInt32BE = function readUInt32BE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 4, this.length);
+  return this[offset] * 16777216 + (this[offset + 1] << 16 | this[offset + 2] << 8 | this[offset + 3]);
+};
+Buffer.prototype.readIntLE = function readIntLE(offset, byteLength2, noAssert) {
+  offset = offset | 0;
+  byteLength2 = byteLength2 | 0;
+  if (!noAssert) checkOffset(offset, byteLength2, this.length);
+  var val = this[offset];
+  var mul = 1;
+  var i = 0;
+  while (++i < byteLength2 && (mul *= 256)) {
+    val += this[offset + i] * mul;
+  }
+  mul *= 128;
+  if (val >= mul) val -= Math.pow(2, 8 * byteLength2);
+  return val;
+};
+Buffer.prototype.readIntBE = function readIntBE(offset, byteLength2, noAssert) {
+  offset = offset | 0;
+  byteLength2 = byteLength2 | 0;
+  if (!noAssert) checkOffset(offset, byteLength2, this.length);
+  var i = byteLength2;
+  var mul = 1;
+  var val = this[offset + --i];
+  while (i > 0 && (mul *= 256)) {
+    val += this[offset + --i] * mul;
+  }
+  mul *= 128;
+  if (val >= mul) val -= Math.pow(2, 8 * byteLength2);
+  return val;
+};
+Buffer.prototype.readInt8 = function readInt8(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 1, this.length);
+  if (!(this[offset] & 128)) return this[offset];
+  return (255 - this[offset] + 1) * -1;
+};
+Buffer.prototype.readInt16LE = function readInt16LE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 2, this.length);
+  var val = this[offset] | this[offset + 1] << 8;
+  return val & 32768 ? val | 4294901760 : val;
+};
+Buffer.prototype.readInt16BE = function readInt16BE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 2, this.length);
+  var val = this[offset + 1] | this[offset] << 8;
+  return val & 32768 ? val | 4294901760 : val;
+};
+Buffer.prototype.readInt32LE = function readInt32LE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 4, this.length);
+  return this[offset] | this[offset + 1] << 8 | this[offset + 2] << 16 | this[offset + 3] << 24;
+};
+Buffer.prototype.readInt32BE = function readInt32BE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 4, this.length);
+  return this[offset] << 24 | this[offset + 1] << 16 | this[offset + 2] << 8 | this[offset + 3];
+};
+Buffer.prototype.readFloatLE = function readFloatLE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 4, this.length);
+  return read(this, offset, true, 23, 4);
+};
+Buffer.prototype.readFloatBE = function readFloatBE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 4, this.length);
+  return read(this, offset, false, 23, 4);
+};
+Buffer.prototype.readDoubleLE = function readDoubleLE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 8, this.length);
+  return read(this, offset, true, 52, 8);
+};
+Buffer.prototype.readDoubleBE = function readDoubleBE(offset, noAssert) {
+  if (!noAssert) checkOffset(offset, 8, this.length);
+  return read(this, offset, false, 52, 8);
+};
+function checkInt(buf, value, offset, ext, max, min) {
+  if (!internalIsBuffer(buf)) throw new TypeError('"buffer" argument must be a Buffer instance');
+  if (value > max || value < min) throw new RangeError('"value" argument is out of bounds');
+  if (offset + ext > buf.length) throw new RangeError("Index out of range");
+}
+Buffer.prototype.writeUIntLE = function writeUIntLE(value, offset, byteLength2, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  byteLength2 = byteLength2 | 0;
+  if (!noAssert) {
+    var maxBytes = Math.pow(2, 8 * byteLength2) - 1;
+    checkInt(this, value, offset, byteLength2, maxBytes, 0);
+  }
+  var mul = 1;
+  var i = 0;
+  this[offset] = value & 255;
+  while (++i < byteLength2 && (mul *= 256)) {
+    this[offset + i] = value / mul & 255;
+  }
+  return offset + byteLength2;
+};
+Buffer.prototype.writeUIntBE = function writeUIntBE(value, offset, byteLength2, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  byteLength2 = byteLength2 | 0;
+  if (!noAssert) {
+    var maxBytes = Math.pow(2, 8 * byteLength2) - 1;
+    checkInt(this, value, offset, byteLength2, maxBytes, 0);
+  }
+  var i = byteLength2 - 1;
+  var mul = 1;
+  this[offset + i] = value & 255;
+  while (--i >= 0 && (mul *= 256)) {
+    this[offset + i] = value / mul & 255;
+  }
+  return offset + byteLength2;
+};
+Buffer.prototype.writeUInt8 = function writeUInt8(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 1, 255, 0);
+  if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value);
+  this[offset] = value & 255;
+  return offset + 1;
+};
+function objectWriteUInt16(buf, value, offset, littleEndian) {
+  if (value < 0) value = 65535 + value + 1;
+  for (var i = 0, j = Math.min(buf.length - offset, 2); i < j; ++i) {
+    buf[offset + i] = (value & 255 << 8 * (littleEndian ? i : 1 - i)) >>> (littleEndian ? i : 1 - i) * 8;
+  }
+}
+Buffer.prototype.writeUInt16LE = function writeUInt16LE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 2, 65535, 0);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value & 255;
+    this[offset + 1] = value >>> 8;
+  } else {
+    objectWriteUInt16(this, value, offset, true);
+  }
+  return offset + 2;
+};
+Buffer.prototype.writeUInt16BE = function writeUInt16BE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 2, 65535, 0);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value >>> 8;
+    this[offset + 1] = value & 255;
+  } else {
+    objectWriteUInt16(this, value, offset, false);
+  }
+  return offset + 2;
+};
+function objectWriteUInt32(buf, value, offset, littleEndian) {
+  if (value < 0) value = 4294967295 + value + 1;
+  for (var i = 0, j = Math.min(buf.length - offset, 4); i < j; ++i) {
+    buf[offset + i] = value >>> (littleEndian ? i : 3 - i) * 8 & 255;
+  }
+}
+Buffer.prototype.writeUInt32LE = function writeUInt32LE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 4, 4294967295, 0);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset + 3] = value >>> 24;
+    this[offset + 2] = value >>> 16;
+    this[offset + 1] = value >>> 8;
+    this[offset] = value & 255;
+  } else {
+    objectWriteUInt32(this, value, offset, true);
+  }
+  return offset + 4;
+};
+Buffer.prototype.writeUInt32BE = function writeUInt32BE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 4, 4294967295, 0);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value >>> 24;
+    this[offset + 1] = value >>> 16;
+    this[offset + 2] = value >>> 8;
+    this[offset + 3] = value & 255;
+  } else {
+    objectWriteUInt32(this, value, offset, false);
+  }
+  return offset + 4;
+};
+Buffer.prototype.writeIntLE = function writeIntLE(value, offset, byteLength2, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) {
+    var limit = Math.pow(2, 8 * byteLength2 - 1);
+    checkInt(this, value, offset, byteLength2, limit - 1, -limit);
+  }
+  var i = 0;
+  var mul = 1;
+  var sub = 0;
+  this[offset] = value & 255;
+  while (++i < byteLength2 && (mul *= 256)) {
+    if (value < 0 && sub === 0 && this[offset + i - 1] !== 0) {
+      sub = 1;
+    }
+    this[offset + i] = (value / mul >> 0) - sub & 255;
+  }
+  return offset + byteLength2;
+};
+Buffer.prototype.writeIntBE = function writeIntBE(value, offset, byteLength2, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) {
+    var limit = Math.pow(2, 8 * byteLength2 - 1);
+    checkInt(this, value, offset, byteLength2, limit - 1, -limit);
+  }
+  var i = byteLength2 - 1;
+  var mul = 1;
+  var sub = 0;
+  this[offset + i] = value & 255;
+  while (--i >= 0 && (mul *= 256)) {
+    if (value < 0 && sub === 0 && this[offset + i + 1] !== 0) {
+      sub = 1;
+    }
+    this[offset + i] = (value / mul >> 0) - sub & 255;
+  }
+  return offset + byteLength2;
+};
+Buffer.prototype.writeInt8 = function writeInt8(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 1, 127, -128);
+  if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value);
+  if (value < 0) value = 255 + value + 1;
+  this[offset] = value & 255;
+  return offset + 1;
+};
+Buffer.prototype.writeInt16LE = function writeInt16LE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 2, 32767, -32768);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value & 255;
+    this[offset + 1] = value >>> 8;
+  } else {
+    objectWriteUInt16(this, value, offset, true);
+  }
+  return offset + 2;
+};
+Buffer.prototype.writeInt16BE = function writeInt16BE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 2, 32767, -32768);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value >>> 8;
+    this[offset + 1] = value & 255;
+  } else {
+    objectWriteUInt16(this, value, offset, false);
+  }
+  return offset + 2;
+};
+Buffer.prototype.writeInt32LE = function writeInt32LE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 4, 2147483647, -2147483648);
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value & 255;
+    this[offset + 1] = value >>> 8;
+    this[offset + 2] = value >>> 16;
+    this[offset + 3] = value >>> 24;
+  } else {
+    objectWriteUInt32(this, value, offset, true);
+  }
+  return offset + 4;
+};
+Buffer.prototype.writeInt32BE = function writeInt32BE(value, offset, noAssert) {
+  value = +value;
+  offset = offset | 0;
+  if (!noAssert) checkInt(this, value, offset, 4, 2147483647, -2147483648);
+  if (value < 0) value = 4294967295 + value + 1;
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value >>> 24;
+    this[offset + 1] = value >>> 16;
+    this[offset + 2] = value >>> 8;
+    this[offset + 3] = value & 255;
+  } else {
+    objectWriteUInt32(this, value, offset, false);
+  }
+  return offset + 4;
+};
+function checkIEEE754(buf, value, offset, ext, max, min) {
+  if (offset + ext > buf.length) throw new RangeError("Index out of range");
+  if (offset < 0) throw new RangeError("Index out of range");
+}
+function writeFloat(buf, value, offset, littleEndian, noAssert) {
+  if (!noAssert) {
+    checkIEEE754(buf, value, offset, 4);
+  }
+  write(buf, value, offset, littleEndian, 23, 4);
+  return offset + 4;
+}
+Buffer.prototype.writeFloatLE = function writeFloatLE(value, offset, noAssert) {
+  return writeFloat(this, value, offset, true, noAssert);
+};
+Buffer.prototype.writeFloatBE = function writeFloatBE(value, offset, noAssert) {
+  return writeFloat(this, value, offset, false, noAssert);
+};
+function writeDouble(buf, value, offset, littleEndian, noAssert) {
+  if (!noAssert) {
+    checkIEEE754(buf, value, offset, 8);
+  }
+  write(buf, value, offset, littleEndian, 52, 8);
+  return offset + 8;
+}
+Buffer.prototype.writeDoubleLE = function writeDoubleLE(value, offset, noAssert) {
+  return writeDouble(this, value, offset, true, noAssert);
+};
+Buffer.prototype.writeDoubleBE = function writeDoubleBE(value, offset, noAssert) {
+  return writeDouble(this, value, offset, false, noAssert);
+};
+Buffer.prototype.copy = function copy(target, targetStart, start, end) {
+  if (!start) start = 0;
+  if (!end && end !== 0) end = this.length;
+  if (targetStart >= target.length) targetStart = target.length;
+  if (!targetStart) targetStart = 0;
+  if (end > 0 && end < start) end = start;
+  if (end === start) return 0;
+  if (target.length === 0 || this.length === 0) return 0;
+  if (targetStart < 0) {
+    throw new RangeError("targetStart out of bounds");
+  }
+  if (start < 0 || start >= this.length) throw new RangeError("sourceStart out of bounds");
+  if (end < 0) throw new RangeError("sourceEnd out of bounds");
+  if (end > this.length) end = this.length;
+  if (target.length - targetStart < end - start) {
+    end = target.length - targetStart + start;
+  }
+  var len = end - start;
+  var i;
+  if (this === target && start < targetStart && targetStart < end) {
+    for (i = len - 1; i >= 0; --i) {
+      target[i + targetStart] = this[i + start];
+    }
+  } else if (len < 1e3 || !Buffer.TYPED_ARRAY_SUPPORT) {
+    for (i = 0; i < len; ++i) {
+      target[i + targetStart] = this[i + start];
+    }
+  } else {
+    Uint8Array.prototype.set.call(
+      target,
+      this.subarray(start, start + len),
+      targetStart
+    );
+  }
+  return len;
+};
+Buffer.prototype.fill = function fill(val, start, end, encoding) {
+  if (typeof val === "string") {
+    if (typeof start === "string") {
+      encoding = start;
+      start = 0;
+      end = this.length;
+    } else if (typeof end === "string") {
+      encoding = end;
+      end = this.length;
+    }
+    if (val.length === 1) {
+      var code = val.charCodeAt(0);
+      if (code < 256) {
+        val = code;
+      }
+    }
+    if (encoding !== void 0 && typeof encoding !== "string") {
+      throw new TypeError("encoding must be a string");
+    }
+    if (typeof encoding === "string" && !Buffer.isEncoding(encoding)) {
+      throw new TypeError("Unknown encoding: " + encoding);
+    }
+  } else if (typeof val === "number") {
+    val = val & 255;
+  }
+  if (start < 0 || this.length < start || this.length < end) {
+    throw new RangeError("Out of range index");
+  }
+  if (end <= start) {
+    return this;
+  }
+  start = start >>> 0;
+  end = end === void 0 ? this.length : end >>> 0;
+  if (!val) val = 0;
+  var i;
+  if (typeof val === "number") {
+    for (i = start; i < end; ++i) {
+      this[i] = val;
+    }
+  } else {
+    var bytes = internalIsBuffer(val) ? val : utf8ToBytes(new Buffer(val, encoding).toString());
+    var len = bytes.length;
+    for (i = 0; i < end - start; ++i) {
+      this[i + start] = bytes[i % len];
+    }
+  }
+  return this;
+};
+var INVALID_BASE64_RE = /[^+\/0-9A-Za-z-_]/g;
+function base64clean(str) {
+  str = stringtrim(str).replace(INVALID_BASE64_RE, "");
+  if (str.length < 2) return "";
+  while (str.length % 4 !== 0) {
+    str = str + "=";
+  }
+  return str;
+}
+function stringtrim(str) {
+  if (str.trim) return str.trim();
+  return str.replace(/^\s+|\s+$/g, "");
+}
+function toHex(n) {
+  if (n < 16) return "0" + n.toString(16);
+  return n.toString(16);
+}
+function utf8ToBytes(string, units) {
+  units = units || Infinity;
+  var codePoint;
+  var length = string.length;
+  var leadSurrogate = null;
+  var bytes = [];
+  for (var i = 0; i < length; ++i) {
+    codePoint = string.charCodeAt(i);
+    if (codePoint > 55295 && codePoint < 57344) {
+      if (!leadSurrogate) {
+        if (codePoint > 56319) {
+          if ((units -= 3) > -1) bytes.push(239, 191, 189);
+          continue;
+        } else if (i + 1 === length) {
+          if ((units -= 3) > -1) bytes.push(239, 191, 189);
+          continue;
+        }
+        leadSurrogate = codePoint;
+        continue;
+      }
+      if (codePoint < 56320) {
+        if ((units -= 3) > -1) bytes.push(239, 191, 189);
+        leadSurrogate = codePoint;
+        continue;
+      }
+      codePoint = (leadSurrogate - 55296 << 10 | codePoint - 56320) + 65536;
+    } else if (leadSurrogate) {
+      if ((units -= 3) > -1) bytes.push(239, 191, 189);
+    }
+    leadSurrogate = null;
+    if (codePoint < 128) {
+      if ((units -= 1) < 0) break;
+      bytes.push(codePoint);
+    } else if (codePoint < 2048) {
+      if ((units -= 2) < 0) break;
+      bytes.push(
+        codePoint >> 6 | 192,
+        codePoint & 63 | 128
+      );
+    } else if (codePoint < 65536) {
+      if ((units -= 3) < 0) break;
+      bytes.push(
+        codePoint >> 12 | 224,
+        codePoint >> 6 & 63 | 128,
+        codePoint & 63 | 128
+      );
+    } else if (codePoint < 1114112) {
+      if ((units -= 4) < 0) break;
+      bytes.push(
+        codePoint >> 18 | 240,
+        codePoint >> 12 & 63 | 128,
+        codePoint >> 6 & 63 | 128,
+        codePoint & 63 | 128
+      );
+    } else {
+      throw new Error("Invalid code point");
+    }
+  }
+  return bytes;
+}
+function asciiToBytes(str) {
+  var byteArray = [];
+  for (var i = 0; i < str.length; ++i) {
+    byteArray.push(str.charCodeAt(i) & 255);
+  }
+  return byteArray;
+}
+function utf16leToBytes(str, units) {
+  var c, hi, lo;
+  var byteArray = [];
+  for (var i = 0; i < str.length; ++i) {
+    if ((units -= 2) < 0) break;
+    c = str.charCodeAt(i);
+    hi = c >> 8;
+    lo = c % 256;
+    byteArray.push(lo);
+    byteArray.push(hi);
+  }
+  return byteArray;
+}
+function base64ToBytes(str) {
+  return toByteArray(base64clean(str));
+}
+function blitBuffer(src, dst, offset, length) {
+  for (var i = 0; i < length; ++i) {
+    if (i + offset >= dst.length || i >= src.length) break;
+    dst[i + offset] = src[i];
+  }
+  return i;
+}
+function isnan(val) {
+  return val !== val;
+}
+function isBuffer(obj) {
+  return obj != null && (!!obj._isBuffer || isFastBuffer(obj) || isSlowBuffer(obj));
+}
+function isFastBuffer(obj) {
+  return !!obj.constructor && typeof obj.constructor.isBuffer === "function" && obj.constructor.isBuffer(obj);
+}
+function isSlowBuffer(obj) {
+  return typeof obj.readFloatLE === "function" && typeof obj.slice === "function" && isFastBuffer(obj.slice(0, 0));
+}
+var domain;
+function EventHandlers() {
+}
+EventHandlers.prototype = /* @__PURE__ */ Object.create(null);
+function EventEmitter() {
+  EventEmitter.init.call(this);
+}
+EventEmitter.EventEmitter = EventEmitter;
+EventEmitter.usingDomains = false;
+EventEmitter.prototype.domain = void 0;
+EventEmitter.prototype._events = void 0;
+EventEmitter.prototype._maxListeners = void 0;
+EventEmitter.defaultMaxListeners = 10;
+EventEmitter.init = function() {
+  this.domain = null;
+  if (EventEmitter.usingDomains) {
+    if (domain.active) ;
+  }
+  if (!this._events || this._events === Object.getPrototypeOf(this)._events) {
+    this._events = new EventHandlers();
+    this._eventsCount = 0;
+  }
+  this._maxListeners = this._maxListeners || void 0;
+};
+EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) {
+  if (typeof n !== "number" || n < 0 || isNaN(n))
+    throw new TypeError('"n" argument must be a positive number');
+  this._maxListeners = n;
+  return this;
+};
+function $getMaxListeners(that) {
+  if (that._maxListeners === void 0)
+    return EventEmitter.defaultMaxListeners;
+  return that._maxListeners;
+}
+EventEmitter.prototype.getMaxListeners = function getMaxListeners() {
+  return $getMaxListeners(this);
+};
+function emitNone(handler, isFn, self2) {
+  if (isFn)
+    handler.call(self2);
+  else {
+    var len = handler.length;
+    var listeners2 = arrayClone(handler, len);
+    for (var i = 0; i < len; ++i)
+      listeners2[i].call(self2);
+  }
+}
+function emitOne(handler, isFn, self2, arg1) {
+  if (isFn)
+    handler.call(self2, arg1);
+  else {
+    var len = handler.length;
+    var listeners2 = arrayClone(handler, len);
+    for (var i = 0; i < len; ++i)
+      listeners2[i].call(self2, arg1);
+  }
+}
+function emitTwo(handler, isFn, self2, arg1, arg2) {
+  if (isFn)
+    handler.call(self2, arg1, arg2);
+  else {
+    var len = handler.length;
+    var listeners2 = arrayClone(handler, len);
+    for (var i = 0; i < len; ++i)
+      listeners2[i].call(self2, arg1, arg2);
+  }
+}
+function emitThree(handler, isFn, self2, arg1, arg2, arg3) {
+  if (isFn)
+    handler.call(self2, arg1, arg2, arg3);
+  else {
+    var len = handler.length;
+    var listeners2 = arrayClone(handler, len);
+    for (var i = 0; i < len; ++i)
+      listeners2[i].call(self2, arg1, arg2, arg3);
+  }
+}
+function emitMany(handler, isFn, self2, args) {
+  if (isFn)
+    handler.apply(self2, args);
+  else {
+    var len = handler.length;
+    var listeners2 = arrayClone(handler, len);
+    for (var i = 0; i < len; ++i)
+      listeners2[i].apply(self2, args);
+  }
+}
+EventEmitter.prototype.emit = function emit(type) {
+  var er, handler, len, args, i, events, domain2;
+  var doError = type === "error";
+  events = this._events;
+  if (events)
+    doError = doError && events.error == null;
+  else if (!doError)
+    return false;
+  domain2 = this.domain;
+  if (doError) {
+    er = arguments[1];
+    if (domain2) {
+      if (!er)
+        er = new Error('Uncaught, unspecified "error" event');
+      er.domainEmitter = this;
+      er.domain = domain2;
+      er.domainThrown = false;
+      domain2.emit("error", er);
+    } else if (er instanceof Error) {
+      throw er;
+    } else {
+      var err = new Error('Uncaught, unspecified "error" event. (' + er + ")");
+      err.context = er;
+      throw err;
+    }
+    return false;
+  }
+  handler = events[type];
+  if (!handler)
+    return false;
+  var isFn = typeof handler === "function";
+  len = arguments.length;
+  switch (len) {
+    // fast cases
+    case 1:
+      emitNone(handler, isFn, this);
+      break;
+    case 2:
+      emitOne(handler, isFn, this, arguments[1]);
+      break;
+    case 3:
+      emitTwo(handler, isFn, this, arguments[1], arguments[2]);
+      break;
+    case 4:
+      emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]);
+      break;
+    // slower
+    default:
+      args = new Array(len - 1);
+      for (i = 1; i < len; i++)
+        args[i - 1] = arguments[i];
+      emitMany(handler, isFn, this, args);
+  }
+  return true;
+};
+function _addListener(target, type, listener, prepend) {
+  var m;
+  var events;
+  var existing;
+  if (typeof listener !== "function")
+    throw new TypeError('"listener" argument must be a function');
+  events = target._events;
+  if (!events) {
+    events = target._events = new EventHandlers();
+    target._eventsCount = 0;
+  } else {
+    if (events.newListener) {
+      target.emit(
+        "newListener",
+        type,
+        listener.listener ? listener.listener : listener
+      );
+      events = target._events;
+    }
+    existing = events[type];
+  }
+  if (!existing) {
+    existing = events[type] = listener;
+    ++target._eventsCount;
+  } else {
+    if (typeof existing === "function") {
+      existing = events[type] = prepend ? [listener, existing] : [existing, listener];
+    } else {
+      if (prepend) {
+        existing.unshift(listener);
+      } else {
+        existing.push(listener);
+      }
+    }
+    if (!existing.warned) {
+      m = $getMaxListeners(target);
+      if (m && m > 0 && existing.length > m) {
+        existing.warned = true;
+        var w = new Error("Possible EventEmitter memory leak detected. " + existing.length + " " + type + " listeners added. Use emitter.setMaxListeners() to increase limit");
+        w.name = "MaxListenersExceededWarning";
+        w.emitter = target;
+        w.type = type;
+        w.count = existing.length;
+        emitWarning(w);
+      }
+    }
+  }
+  return target;
+}
+function emitWarning(e) {
+  typeof console.warn === "function" ? console.warn(e) : console.log(e);
+}
+EventEmitter.prototype.addListener = function addListener(type, listener) {
+  return _addListener(this, type, listener, false);
+};
+EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+EventEmitter.prototype.prependListener = function prependListener(type, listener) {
+  return _addListener(this, type, listener, true);
+};
+function _onceWrap(target, type, listener) {
+  var fired = false;
+  function g() {
+    target.removeListener(type, g);
+    if (!fired) {
+      fired = true;
+      listener.apply(target, arguments);
+    }
+  }
+  g.listener = listener;
+  return g;
+}
+EventEmitter.prototype.once = function once(type, listener) {
+  if (typeof listener !== "function")
+    throw new TypeError('"listener" argument must be a function');
+  this.on(type, _onceWrap(this, type, listener));
+  return this;
+};
+EventEmitter.prototype.prependOnceListener = function prependOnceListener(type, listener) {
+  if (typeof listener !== "function")
+    throw new TypeError('"listener" argument must be a function');
+  this.prependListener(type, _onceWrap(this, type, listener));
+  return this;
+};
+EventEmitter.prototype.removeListener = function removeListener(type, listener) {
+  var list, events, position, i, originalListener;
+  if (typeof listener !== "function")
+    throw new TypeError('"listener" argument must be a function');
+  events = this._events;
+  if (!events)
+    return this;
+  list = events[type];
+  if (!list)
+    return this;
+  if (list === listener || list.listener && list.listener === listener) {
+    if (--this._eventsCount === 0)
+      this._events = new EventHandlers();
+    else {
+      delete events[type];
+      if (events.removeListener)
+        this.emit("removeListener", type, list.listener || listener);
+    }
+  } else if (typeof list !== "function") {
+    position = -1;
+    for (i = list.length; i-- > 0; ) {
+      if (list[i] === listener || list[i].listener && list[i].listener === listener) {
+        originalListener = list[i].listener;
+        position = i;
+        break;
+      }
+    }
+    if (position < 0)
+      return this;
+    if (list.length === 1) {
+      list[0] = void 0;
+      if (--this._eventsCount === 0) {
+        this._events = new EventHandlers();
+        return this;
+      } else {
+        delete events[type];
+      }
+    } else {
+      spliceOne(list, position);
+    }
+    if (events.removeListener)
+      this.emit("removeListener", type, originalListener || listener);
+  }
+  return this;
+};
+EventEmitter.prototype.removeAllListeners = function removeAllListeners(type) {
+  var listeners2, events;
+  events = this._events;
+  if (!events)
+    return this;
+  if (!events.removeListener) {
+    if (arguments.length === 0) {
+      this._events = new EventHandlers();
+      this._eventsCount = 0;
+    } else if (events[type]) {
+      if (--this._eventsCount === 0)
+        this._events = new EventHandlers();
+      else
+        delete events[type];
+    }
+    return this;
+  }
+  if (arguments.length === 0) {
+    var keys2 = Object.keys(events);
+    for (var i = 0, key; i < keys2.length; ++i) {
+      key = keys2[i];
+      if (key === "removeListener") continue;
+      this.removeAllListeners(key);
+    }
+    this.removeAllListeners("removeListener");
+    this._events = new EventHandlers();
+    this._eventsCount = 0;
+    return this;
+  }
+  listeners2 = events[type];
+  if (typeof listeners2 === "function") {
+    this.removeListener(type, listeners2);
+  } else if (listeners2) {
+    do {
+      this.removeListener(type, listeners2[listeners2.length - 1]);
+    } while (listeners2[0]);
+  }
+  return this;
+};
+EventEmitter.prototype.listeners = function listeners(type) {
+  var evlistener;
+  var ret;
+  var events = this._events;
+  if (!events)
+    ret = [];
+  else {
+    evlistener = events[type];
+    if (!evlistener)
+      ret = [];
+    else if (typeof evlistener === "function")
+      ret = [evlistener.listener || evlistener];
+    else
+      ret = unwrapListeners(evlistener);
+  }
+  return ret;
+};
+EventEmitter.listenerCount = function(emitter, type) {
+  if (typeof emitter.listenerCount === "function") {
+    return emitter.listenerCount(type);
+  } else {
+    return listenerCount$1.call(emitter, type);
+  }
+};
+EventEmitter.prototype.listenerCount = listenerCount$1;
+function listenerCount$1(type) {
+  var events = this._events;
+  if (events) {
+    var evlistener = events[type];
+    if (typeof evlistener === "function") {
+      return 1;
+    } else if (evlistener) {
+      return evlistener.length;
+    }
+  }
+  return 0;
+}
+EventEmitter.prototype.eventNames = function eventNames() {
+  return this._eventsCount > 0 ? Reflect.ownKeys(this._events) : [];
+};
+function spliceOne(list, index) {
+  for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1)
+    list[i] = list[k];
+  list.pop();
+}
+function arrayClone(arr, i) {
+  var copy2 = new Array(i);
+  while (i--)
+    copy2[i] = arr[i];
+  return copy2;
+}
+function unwrapListeners(arr) {
+  var ret = new Array(arr.length);
+  for (var i = 0; i < ret.length; ++i) {
+    ret[i] = arr[i].listener || arr[i];
+  }
+  return ret;
+}
+function defaultSetTimout() {
+  throw new Error("setTimeout has not been defined");
+}
+function defaultClearTimeout() {
+  throw new Error("clearTimeout has not been defined");
+}
+var cachedSetTimeout = defaultSetTimout;
+var cachedClearTimeout = defaultClearTimeout;
+if (typeof global$1.setTimeout === "function") {
+  cachedSetTimeout = setTimeout;
+}
+if (typeof global$1.clearTimeout === "function") {
+  cachedClearTimeout = clearTimeout;
+}
+function runTimeout(fun) {
+  if (cachedSetTimeout === setTimeout) {
+    return setTimeout(fun, 0);
+  }
+  if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) {
+    cachedSetTimeout = setTimeout;
+    return setTimeout(fun, 0);
+  }
+  try {
+    return cachedSetTimeout(fun, 0);
+  } catch (e) {
+    try {
+      return cachedSetTimeout.call(null, fun, 0);
+    } catch (e2) {
+      return cachedSetTimeout.call(this, fun, 0);
+    }
+  }
+}
+function runClearTimeout(marker) {
+  if (cachedClearTimeout === clearTimeout) {
+    return clearTimeout(marker);
+  }
+  if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) {
+    cachedClearTimeout = clearTimeout;
+    return clearTimeout(marker);
+  }
+  try {
+    return cachedClearTimeout(marker);
+  } catch (e) {
+    try {
+      return cachedClearTimeout.call(null, marker);
+    } catch (e2) {
+      return cachedClearTimeout.call(this, marker);
+    }
+  }
+}
+var queue = [];
+var draining = false;
+var currentQueue;
+var queueIndex = -1;
+function cleanUpNextTick() {
+  if (!draining || !currentQueue) {
+    return;
+  }
+  draining = false;
+  if (currentQueue.length) {
+    queue = currentQueue.concat(queue);
+  } else {
+    queueIndex = -1;
+  }
+  if (queue.length) {
+    drainQueue();
+  }
+}
+function drainQueue() {
+  if (draining) {
+    return;
+  }
+  var timeout = runTimeout(cleanUpNextTick);
+  draining = true;
+  var len = queue.length;
+  while (len) {
+    currentQueue = queue;
+    queue = [];
+    while (++queueIndex < len) {
+      if (currentQueue) {
+        currentQueue[queueIndex].run();
+      }
+    }
+    queueIndex = -1;
+    len = queue.length;
+  }
+  currentQueue = null;
+  draining = false;
+  runClearTimeout(timeout);
+}
+function nextTick(fun) {
+  var args = new Array(arguments.length - 1);
+  if (arguments.length > 1) {
+    for (var i = 1; i < arguments.length; i++) {
+      args[i - 1] = arguments[i];
+    }
+  }
+  queue.push(new Item(fun, args));
+  if (queue.length === 1 && !draining) {
+    runTimeout(drainQueue);
+  }
+}
+function Item(fun, array) {
+  this.fun = fun;
+  this.array = array;
+}
+Item.prototype.run = function() {
+  this.fun.apply(null, this.array);
+};
+var title = "browser";
+var platform = "browser";
+var browser = true;
+var env = {};
+var argv = [];
+var version = "";
+var versions = {};
+var release = {};
+var config = {};
+function noop() {
+}
+var on = noop;
+var addListener2 = noop;
+var once2 = noop;
+var off = noop;
+var removeListener2 = noop;
+var removeAllListeners2 = noop;
+var emit2 = noop;
+function binding(name) {
+  throw new Error("process.binding is not supported");
+}
+function cwd() {
+  return "/";
+}
+function chdir(dir) {
+  throw new Error("process.chdir is not supported");
+}
+function umask() {
+  return 0;
+}
+var performance = global$1.performance || {};
+var performanceNow = performance.now || performance.mozNow || performance.msNow || performance.oNow || performance.webkitNow || function() {
+  return (/* @__PURE__ */ new Date()).getTime();
+};
+function hrtime(previousTimestamp) {
+  var clocktime = performanceNow.call(performance) * 1e-3;
+  var seconds = Math.floor(clocktime);
+  var nanoseconds = Math.floor(clocktime % 1 * 1e9);
+  if (previousTimestamp) {
+    seconds = seconds - previousTimestamp[0];
+    nanoseconds = nanoseconds - previousTimestamp[1];
+    if (nanoseconds < 0) {
+      seconds--;
+      nanoseconds += 1e9;
+    }
+  }
+  return [seconds, nanoseconds];
+}
+var startTime = /* @__PURE__ */ new Date();
+function uptime() {
+  var currentTime = /* @__PURE__ */ new Date();
+  var dif = currentTime - startTime;
+  return dif / 1e3;
+}
+var process = {
+  nextTick,
+  title,
+  browser,
+  env,
+  argv,
+  version,
+  versions,
+  on,
+  addListener: addListener2,
+  once: once2,
+  off,
+  removeListener: removeListener2,
+  removeAllListeners: removeAllListeners2,
+  emit: emit2,
+  binding,
+  cwd,
+  chdir,
+  umask,
+  hrtime,
+  platform,
+  release,
+  config,
+  uptime
+};
+var inherits;
+if (typeof Object.create === "function") {
+  inherits = function inherits2(ctor, superCtor) {
+    ctor.super_ = superCtor;
+    ctor.prototype = Object.create(superCtor.prototype, {
+      constructor: {
+        value: ctor,
+        enumerable: false,
+        writable: true,
+        configurable: true
+      }
+    });
+  };
+} else {
+  inherits = function inherits2(ctor, superCtor) {
+    ctor.super_ = superCtor;
+    var TempCtor = function() {
+    };
+    TempCtor.prototype = superCtor.prototype;
+    ctor.prototype = new TempCtor();
+    ctor.prototype.constructor = ctor;
+  };
+}
+var inherits$1 = inherits;
+var formatRegExp = /%[sdj%]/g;
+function format(f) {
+  if (!isString(f)) {
+    var objects = [];
+    for (var i = 0; i < arguments.length; i++) {
+      objects.push(inspect2(arguments[i]));
+    }
+    return objects.join(" ");
+  }
+  var i = 1;
+  var args = arguments;
+  var len = args.length;
+  var str = String(f).replace(formatRegExp, function(x2) {
+    if (x2 === "%%") return "%";
+    if (i >= len) return x2;
+    switch (x2) {
+      case "%s":
+        return String(args[i++]);
+      case "%d":
+        return Number(args[i++]);
+      case "%j":
+        try {
+          return JSON.stringify(args[i++]);
+        } catch (_) {
+          return "[Circular]";
+        }
+      default:
+        return x2;
+    }
+  });
+  for (var x = args[i]; i < len; x = args[++i]) {
+    if (isNull(x) || !isObject(x)) {
+      str += " " + x;
+    } else {
+      str += " " + inspect2(x);
+    }
+  }
+  return str;
+}
+function deprecate(fn, msg) {
+  if (isUndefined(global$1.process)) {
+    return function() {
+      return deprecate(fn, msg).apply(this, arguments);
+    };
+  }
+  if (process.noDeprecation === true) {
+    return fn;
+  }
+  var warned = false;
+  function deprecated() {
+    if (!warned) {
+      if (process.throwDeprecation) {
+        throw new Error(msg);
+      } else if (process.traceDeprecation) {
+        console.trace(msg);
+      } else {
+        console.error(msg);
+      }
+      warned = true;
+    }
+    return fn.apply(this, arguments);
+  }
+  return deprecated;
+}
+var debugs = {};
+var debugEnviron;
+function debuglog(set) {
+  if (isUndefined(debugEnviron))
+    debugEnviron = process.env.NODE_DEBUG || "";
+  set = set.toUpperCase();
+  if (!debugs[set]) {
+    if (new RegExp("\\b" + set + "\\b", "i").test(debugEnviron)) {
+      var pid = 0;
+      debugs[set] = function() {
+        var msg = format.apply(null, arguments);
+        console.error("%s %d: %s", set, pid, msg);
+      };
+    } else {
+      debugs[set] = function() {
+      };
+    }
+  }
+  return debugs[set];
+}
+function inspect2(obj, opts) {
+  var ctx = {
+    seen: [],
+    stylize: stylizeNoColor
+  };
+  if (arguments.length >= 3) ctx.depth = arguments[2];
+  if (arguments.length >= 4) ctx.colors = arguments[3];
+  if (isBoolean(opts)) {
+    ctx.showHidden = opts;
+  } else if (opts) {
+    _extend(ctx, opts);
+  }
+  if (isUndefined(ctx.showHidden)) ctx.showHidden = false;
+  if (isUndefined(ctx.depth)) ctx.depth = 2;
+  if (isUndefined(ctx.colors)) ctx.colors = false;
+  if (isUndefined(ctx.customInspect)) ctx.customInspect = true;
+  if (ctx.colors) ctx.stylize = stylizeWithColor;
+  return formatValue(ctx, obj, ctx.depth);
+}
+inspect2.colors = {
+  "bold": [1, 22],
+  "italic": [3, 23],
+  "underline": [4, 24],
+  "inverse": [7, 27],
+  "white": [37, 39],
+  "grey": [90, 39],
+  "black": [30, 39],
+  "blue": [34, 39],
+  "cyan": [36, 39],
+  "green": [32, 39],
+  "magenta": [35, 39],
+  "red": [31, 39],
+  "yellow": [33, 39]
+};
+inspect2.styles = {
+  "special": "cyan",
+  "number": "yellow",
+  "boolean": "yellow",
+  "undefined": "grey",
+  "null": "bold",
+  "string": "green",
+  "date": "magenta",
+  // "name": intentionally not styling
+  "regexp": "red"
+};
+function stylizeWithColor(str, styleType) {
+  var style = inspect2.styles[styleType];
+  if (style) {
+    return "\x1B[" + inspect2.colors[style][0] + "m" + str + "\x1B[" + inspect2.colors[style][1] + "m";
+  } else {
+    return str;
+  }
+}
+function stylizeNoColor(str, styleType) {
+  return str;
+}
+function arrayToHash(array) {
+  var hash = {};
+  array.forEach(function(val, idx) {
+    hash[val] = true;
+  });
+  return hash;
+}
+function formatValue(ctx, value, recurseTimes) {
+  if (ctx.customInspect && value && isFunction(value.inspect) && // Filter out the util module, it's inspect function is special
+  value.inspect !== inspect2 && // Also filter out any prototype objects using the circular check.
+  !(value.constructor && value.constructor.prototype === value)) {
+    var ret = value.inspect(recurseTimes, ctx);
+    if (!isString(ret)) {
+      ret = formatValue(ctx, ret, recurseTimes);
+    }
+    return ret;
+  }
+  var primitive = formatPrimitive(ctx, value);
+  if (primitive) {
+    return primitive;
+  }
+  var keys2 = Object.keys(value);
+  var visibleKeys = arrayToHash(keys2);
+  if (ctx.showHidden) {
+    keys2 = Object.getOwnPropertyNames(value);
+  }
+  if (isError(value) && (keys2.indexOf("message") >= 0 || keys2.indexOf("description") >= 0)) {
+    return formatError(value);
+  }
+  if (keys2.length === 0) {
+    if (isFunction(value)) {
+      var name = value.name ? ": " + value.name : "";
+      return ctx.stylize("[Function" + name + "]", "special");
+    }
+    if (isRegExp(value)) {
+      return ctx.stylize(RegExp.prototype.toString.call(value), "regexp");
+    }
+    if (isDate(value)) {
+      return ctx.stylize(Date.prototype.toString.call(value), "date");
+    }
+    if (isError(value)) {
+      return formatError(value);
+    }
+  }
+  var base = "", array = false, braces = ["{", "}"];
+  if (isArray(value)) {
+    array = true;
+    braces = ["[", "]"];
+  }
+  if (isFunction(value)) {
+    var n = value.name ? ": " + value.name : "";
+    base = " [Function" + n + "]";
+  }
+  if (isRegExp(value)) {
+    base = " " + RegExp.prototype.toString.call(value);
+  }
+  if (isDate(value)) {
+    base = " " + Date.prototype.toUTCString.call(value);
+  }
+  if (isError(value)) {
+    base = " " + formatError(value);
+  }
+  if (keys2.length === 0 && (!array || value.length == 0)) {
+    return braces[0] + base + braces[1];
+  }
+  if (recurseTimes < 0) {
+    if (isRegExp(value)) {
+      return ctx.stylize(RegExp.prototype.toString.call(value), "regexp");
+    } else {
+      return ctx.stylize("[Object]", "special");
+    }
+  }
+  ctx.seen.push(value);
+  var output;
+  if (array) {
+    output = formatArray(ctx, value, recurseTimes, visibleKeys, keys2);
+  } else {
+    output = keys2.map(function(key) {
+      return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array);
+    });
+  }
+  ctx.seen.pop();
+  return reduceToSingleString(output, base, braces);
+}
+function formatPrimitive(ctx, value) {
+  if (isUndefined(value))
+    return ctx.stylize("undefined", "undefined");
+  if (isString(value)) {
+    var simple = "'" + JSON.stringify(value).replace(/^"|"$/g, "").replace(/'/g, "\\'").replace(/\\"/g, '"') + "'";
+    return ctx.stylize(simple, "string");
+  }
+  if (isNumber(value))
+    return ctx.stylize("" + value, "number");
+  if (isBoolean(value))
+    return ctx.stylize("" + value, "boolean");
+  if (isNull(value))
+    return ctx.stylize("null", "null");
+}
+function formatError(value) {
+  return "[" + Error.prototype.toString.call(value) + "]";
+}
+function formatArray(ctx, value, recurseTimes, visibleKeys, keys2) {
+  var output = [];
+  for (var i = 0, l = value.length; i < l; ++i) {
+    if (hasOwnProperty(value, String(i))) {
+      output.push(formatProperty(
+        ctx,
+        value,
+        recurseTimes,
+        visibleKeys,
+        String(i),
+        true
+      ));
+    } else {
+      output.push("");
+    }
+  }
+  keys2.forEach(function(key) {
+    if (!key.match(/^\d+$/)) {
+      output.push(formatProperty(
+        ctx,
+        value,
+        recurseTimes,
+        visibleKeys,
+        key,
+        true
+      ));
+    }
+  });
+  return output;
+}
+function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) {
+  var name, str, desc;
+  desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] };
+  if (desc.get) {
+    if (desc.set) {
+      str = ctx.stylize("[Getter/Setter]", "special");
+    } else {
+      str = ctx.stylize("[Getter]", "special");
+    }
+  } else {
+    if (desc.set) {
+      str = ctx.stylize("[Setter]", "special");
+    }
+  }
+  if (!hasOwnProperty(visibleKeys, key)) {
+    name = "[" + key + "]";
+  }
+  if (!str) {
+    if (ctx.seen.indexOf(desc.value) < 0) {
+      if (isNull(recurseTimes)) {
+        str = formatValue(ctx, desc.value, null);
+      } else {
+        str = formatValue(ctx, desc.value, recurseTimes - 1);
+      }
+      if (str.indexOf("\n") > -1) {
+        if (array) {
+          str = str.split("\n").map(function(line) {
+            return "  " + line;
+          }).join("\n").substr(2);
+        } else {
+          str = "\n" + str.split("\n").map(function(line) {
+            return "   " + line;
+          }).join("\n");
+        }
+      }
+    } else {
+      str = ctx.stylize("[Circular]", "special");
+    }
+  }
+  if (isUndefined(name)) {
+    if (array && key.match(/^\d+$/)) {
+      return str;
+    }
+    name = JSON.stringify("" + key);
+    if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) {
+      name = name.substr(1, name.length - 2);
+      name = ctx.stylize(name, "name");
+    } else {
+      name = name.replace(/'/g, "\\'").replace(/\\"/g, '"').replace(/(^"|"$)/g, "'");
+      name = ctx.stylize(name, "string");
+    }
+  }
+  return name + ": " + str;
+}
+function reduceToSingleString(output, base, braces) {
+  var length = output.reduce(function(prev, cur) {
+    if (cur.indexOf("\n") >= 0) ;
+    return prev + cur.replace(/\u001b\[\d\d?m/g, "").length + 1;
+  }, 0);
+  if (length > 60) {
+    return braces[0] + (base === "" ? "" : base + "\n ") + " " + output.join(",\n  ") + " " + braces[1];
+  }
+  return braces[0] + base + " " + output.join(", ") + " " + braces[1];
+}
+function isArray(ar) {
+  return Array.isArray(ar);
+}
+function isBoolean(arg) {
+  return typeof arg === "boolean";
+}
+function isNull(arg) {
+  return arg === null;
+}
+function isNumber(arg) {
+  return typeof arg === "number";
+}
+function isString(arg) {
+  return typeof arg === "string";
+}
+function isUndefined(arg) {
+  return arg === void 0;
+}
+function isRegExp(re) {
+  return isObject(re) && objectToString(re) === "[object RegExp]";
+}
+function isObject(arg) {
+  return typeof arg === "object" && arg !== null;
+}
+function isDate(d) {
+  return isObject(d) && objectToString(d) === "[object Date]";
+}
+function isError(e) {
+  return isObject(e) && (objectToString(e) === "[object Error]" || e instanceof Error);
+}
+function isFunction(arg) {
+  return typeof arg === "function";
+}
+function objectToString(o) {
+  return Object.prototype.toString.call(o);
+}
+function _extend(origin, add) {
+  if (!add || !isObject(add)) return origin;
+  var keys2 = Object.keys(add);
+  var i = keys2.length;
+  while (i--) {
+    origin[keys2[i]] = add[keys2[i]];
+  }
+  return origin;
+}
+function hasOwnProperty(obj, prop) {
+  return Object.prototype.hasOwnProperty.call(obj, prop);
+}
+function BufferList() {
+  this.head = null;
+  this.tail = null;
+  this.length = 0;
+}
+BufferList.prototype.push = function(v) {
+  var entry = { data: v, next: null };
+  if (this.length > 0) this.tail.next = entry;
+  else this.head = entry;
+  this.tail = entry;
+  ++this.length;
+};
+BufferList.prototype.unshift = function(v) {
+  var entry = { data: v, next: this.head };
+  if (this.length === 0) this.tail = entry;
+  this.head = entry;
+  ++this.length;
+};
+BufferList.prototype.shift = function() {
+  if (this.length === 0) return;
+  var ret = this.head.data;
+  if (this.length === 1) this.head = this.tail = null;
+  else this.head = this.head.next;
+  --this.length;
+  return ret;
+};
+BufferList.prototype.clear = function() {
+  this.head = this.tail = null;
+  this.length = 0;
+};
+BufferList.prototype.join = function(s) {
+  if (this.length === 0) return "";
+  var p = this.head;
+  var ret = "" + p.data;
+  while (p = p.next) {
+    ret += s + p.data;
+  }
+  return ret;
+};
+BufferList.prototype.concat = function(n) {
+  if (this.length === 0) return Buffer.alloc(0);
+  if (this.length === 1) return this.head.data;
+  var ret = Buffer.allocUnsafe(n >>> 0);
+  var p = this.head;
+  var i = 0;
+  while (p) {
+    p.data.copy(ret, i);
+    i += p.data.length;
+    p = p.next;
+  }
+  return ret;
+};
+var isBufferEncoding = Buffer.isEncoding || function(encoding) {
+  switch (encoding && encoding.toLowerCase()) {
+    case "hex":
+    case "utf8":
+    case "utf-8":
+    case "ascii":
+    case "binary":
+    case "base64":
+    case "ucs2":
+    case "ucs-2":
+    case "utf16le":
+    case "utf-16le":
+    case "raw":
+      return true;
+    default:
+      return false;
+  }
+};
+function assertEncoding(encoding) {
+  if (encoding && !isBufferEncoding(encoding)) {
+    throw new Error("Unknown encoding: " + encoding);
+  }
+}
+function StringDecoder(encoding) {
+  this.encoding = (encoding || "utf8").toLowerCase().replace(/[-_]/, "");
+  assertEncoding(encoding);
+  switch (this.encoding) {
+    case "utf8":
+      this.surrogateSize = 3;
+      break;
+    case "ucs2":
+    case "utf16le":
+      this.surrogateSize = 2;
+      this.detectIncompleteChar = utf16DetectIncompleteChar;
+      break;
+    case "base64":
+      this.surrogateSize = 3;
+      this.detectIncompleteChar = base64DetectIncompleteChar;
+      break;
+    default:
+      this.write = passThroughWrite;
+      return;
+  }
+  this.charBuffer = new Buffer(6);
+  this.charReceived = 0;
+  this.charLength = 0;
+}
+StringDecoder.prototype.write = function(buffer) {
+  var charStr = "";
+  while (this.charLength) {
+    var available = buffer.length >= this.charLength - this.charReceived ? this.charLength - this.charReceived : buffer.length;
+    buffer.copy(this.charBuffer, this.charReceived, 0, available);
+    this.charReceived += available;
+    if (this.charReceived < this.charLength) {
+      return "";
+    }
+    buffer = buffer.slice(available, buffer.length);
+    charStr = this.charBuffer.slice(0, this.charLength).toString(this.encoding);
+    var charCode = charStr.charCodeAt(charStr.length - 1);
+    if (charCode >= 55296 && charCode <= 56319) {
+      this.charLength += this.surrogateSize;
+      charStr = "";
+      continue;
+    }
+    this.charReceived = this.charLength = 0;
+    if (buffer.length === 0) {
+      return charStr;
+    }
+    break;
+  }
+  this.detectIncompleteChar(buffer);
+  var end = buffer.length;
+  if (this.charLength) {
+    buffer.copy(this.charBuffer, 0, buffer.length - this.charReceived, end);
+    end -= this.charReceived;
+  }
+  charStr += buffer.toString(this.encoding, 0, end);
+  var end = charStr.length - 1;
+  var charCode = charStr.charCodeAt(end);
+  if (charCode >= 55296 && charCode <= 56319) {
+    var size = this.surrogateSize;
+    this.charLength += size;
+    this.charReceived += size;
+    this.charBuffer.copy(this.charBuffer, size, 0, size);
+    buffer.copy(this.charBuffer, 0, 0, size);
+    return charStr.substring(0, end);
+  }
+  return charStr;
+};
+StringDecoder.prototype.detectIncompleteChar = function(buffer) {
+  var i = buffer.length >= 3 ? 3 : buffer.length;
+  for (; i > 0; i--) {
+    var c = buffer[buffer.length - i];
+    if (i == 1 && c >> 5 == 6) {
+      this.charLength = 2;
+      break;
+    }
+    if (i <= 2 && c >> 4 == 14) {
+      this.charLength = 3;
+      break;
+    }
+    if (i <= 3 && c >> 3 == 30) {
+      this.charLength = 4;
+      break;
+    }
+  }
+  this.charReceived = i;
+};
+StringDecoder.prototype.end = function(buffer) {
+  var res = "";
+  if (buffer && buffer.length)
+    res = this.write(buffer);
+  if (this.charReceived) {
+    var cr2 = this.charReceived;
+    var buf = this.charBuffer;
+    var enc = this.encoding;
+    res += buf.slice(0, cr2).toString(enc);
+  }
+  return res;
+};
+function passThroughWrite(buffer) {
+  return buffer.toString(this.encoding);
+}
+function utf16DetectIncompleteChar(buffer) {
+  this.charReceived = buffer.length % 2;
+  this.charLength = this.charReceived ? 2 : 0;
+}
+function base64DetectIncompleteChar(buffer) {
+  this.charReceived = buffer.length % 3;
+  this.charLength = this.charReceived ? 3 : 0;
+}
+Readable.ReadableState = ReadableState;
+var debug = debuglog("stream");
+inherits$1(Readable, EventEmitter);
+function prependListener2(emitter, event, fn) {
+  if (typeof emitter.prependListener === "function") {
+    return emitter.prependListener(event, fn);
+  } else {
+    if (!emitter._events || !emitter._events[event])
+      emitter.on(event, fn);
+    else if (Array.isArray(emitter._events[event]))
+      emitter._events[event].unshift(fn);
+    else
+      emitter._events[event] = [fn, emitter._events[event]];
+  }
+}
+function listenerCount(emitter, type) {
+  return emitter.listeners(type).length;
+}
+function ReadableState(options, stream) {
+  options = options || {};
+  this.objectMode = !!options.objectMode;
+  if (stream instanceof Duplex) this.objectMode = this.objectMode || !!options.readableObjectMode;
+  var hwm = options.highWaterMark;
+  var defaultHwm = this.objectMode ? 16 : 16 * 1024;
+  this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm;
+  this.highWaterMark = ~~this.highWaterMark;
+  this.buffer = new BufferList();
+  this.length = 0;
+  this.pipes = null;
+  this.pipesCount = 0;
+  this.flowing = null;
+  this.ended = false;
+  this.endEmitted = false;
+  this.reading = false;
+  this.sync = true;
+  this.needReadable = false;
+  this.emittedReadable = false;
+  this.readableListening = false;
+  this.resumeScheduled = false;
+  this.defaultEncoding = options.defaultEncoding || "utf8";
+  this.ranOut = false;
+  this.awaitDrain = 0;
+  this.readingMore = false;
+  this.decoder = null;
+  this.encoding = null;
+  if (options.encoding) {
+    this.decoder = new StringDecoder(options.encoding);
+    this.encoding = options.encoding;
+  }
+}
+function Readable(options) {
+  if (!(this instanceof Readable)) return new Readable(options);
+  this._readableState = new ReadableState(options, this);
+  this.readable = true;
+  if (options && typeof options.read === "function") this._read = options.read;
+  EventEmitter.call(this);
+}
+Readable.prototype.push = function(chunk, encoding) {
+  var state = this._readableState;
+  if (!state.objectMode && typeof chunk === "string") {
+    encoding = encoding || state.defaultEncoding;
+    if (encoding !== state.encoding) {
+      chunk = Buffer.from(chunk, encoding);
+      encoding = "";
+    }
+  }
+  return readableAddChunk(this, state, chunk, encoding, false);
+};
+Readable.prototype.unshift = function(chunk) {
+  var state = this._readableState;
+  return readableAddChunk(this, state, chunk, "", true);
+};
+Readable.prototype.isPaused = function() {
+  return this._readableState.flowing === false;
+};
+function readableAddChunk(stream, state, chunk, encoding, addToFront) {
+  var er = chunkInvalid(state, chunk);
+  if (er) {
+    stream.emit("error", er);
+  } else if (chunk === null) {
+    state.reading = false;
+    onEofChunk(stream, state);
+  } else if (state.objectMode || chunk && chunk.length > 0) {
+    if (state.ended && !addToFront) {
+      var e = new Error("stream.push() after EOF");
+      stream.emit("error", e);
+    } else if (state.endEmitted && addToFront) {
+      var _e = new Error("stream.unshift() after end event");
+      stream.emit("error", _e);
+    } else {
+      var skipAdd;
+      if (state.decoder && !addToFront && !encoding) {
+        chunk = state.decoder.write(chunk);
+        skipAdd = !state.objectMode && chunk.length === 0;
+      }
+      if (!addToFront) state.reading = false;
+      if (!skipAdd) {
+        if (state.flowing && state.length === 0 && !state.sync) {
+          stream.emit("data", chunk);
+          stream.read(0);
+        } else {
+          state.length += state.objectMode ? 1 : chunk.length;
+          if (addToFront) state.buffer.unshift(chunk);
+          else state.buffer.push(chunk);
+          if (state.needReadable) emitReadable(stream);
+        }
+      }
+      maybeReadMore(stream, state);
+    }
+  } else if (!addToFront) {
+    state.reading = false;
+  }
+  return needMoreData(state);
+}
+function needMoreData(state) {
+  return !state.ended && (state.needReadable || state.length < state.highWaterMark || state.length === 0);
+}
+Readable.prototype.setEncoding = function(enc) {
+  this._readableState.decoder = new StringDecoder(enc);
+  this._readableState.encoding = enc;
+  return this;
+};
+var MAX_HWM = 8388608;
+function computeNewHighWaterMark(n) {
+  if (n >= MAX_HWM) {
+    n = MAX_HWM;
+  } else {
+    n--;
+    n |= n >>> 1;
+    n |= n >>> 2;
+    n |= n >>> 4;
+    n |= n >>> 8;
+    n |= n >>> 16;
+    n++;
+  }
+  return n;
+}
+function howMuchToRead(n, state) {
+  if (n <= 0 || state.length === 0 && state.ended) return 0;
+  if (state.objectMode) return 1;
+  if (n !== n) {
+    if (state.flowing && state.length) return state.buffer.head.data.length;
+    else return state.length;
+  }
+  if (n > state.highWaterMark) state.highWaterMark = computeNewHighWaterMark(n);
+  if (n <= state.length) return n;
+  if (!state.ended) {
+    state.needReadable = true;
+    return 0;
+  }
+  return state.length;
+}
+Readable.prototype.read = function(n) {
+  debug("read", n);
+  n = parseInt(n, 10);
+  var state = this._readableState;
+  var nOrig = n;
+  if (n !== 0) state.emittedReadable = false;
+  if (n === 0 && state.needReadable && (state.length >= state.highWaterMark || state.ended)) {
+    debug("read: emitReadable", state.length, state.ended);
+    if (state.length === 0 && state.ended) endReadable(this);
+    else emitReadable(this);
+    return null;
+  }
+  n = howMuchToRead(n, state);
+  if (n === 0 && state.ended) {
+    if (state.length === 0) endReadable(this);
+    return null;
+  }
+  var doRead = state.needReadable;
+  debug("need readable", doRead);
+  if (state.length === 0 || state.length - n < state.highWaterMark) {
+    doRead = true;
+    debug("length less than watermark", doRead);
+  }
+  if (state.ended || state.reading) {
+    doRead = false;
+    debug("reading or ended", doRead);
+  } else if (doRead) {
+    debug("do read");
+    state.reading = true;
+    state.sync = true;
+    if (state.length === 0) state.needReadable = true;
+    this._read(state.highWaterMark);
+    state.sync = false;
+    if (!state.reading) n = howMuchToRead(nOrig, state);
+  }
+  var ret;
+  if (n > 0) ret = fromList(n, state);
+  else ret = null;
+  if (ret === null) {
+    state.needReadable = true;
+    n = 0;
+  } else {
+    state.length -= n;
+  }
+  if (state.length === 0) {
+    if (!state.ended) state.needReadable = true;
+    if (nOrig !== n && state.ended) endReadable(this);
+  }
+  if (ret !== null) this.emit("data", ret);
+  return ret;
+};
+function chunkInvalid(state, chunk) {
+  var er = null;
+  if (!isBuffer(chunk) && typeof chunk !== "string" && chunk !== null && chunk !== void 0 && !state.objectMode) {
+    er = new TypeError("Invalid non-string/buffer chunk");
+  }
+  return er;
+}
+function onEofChunk(stream, state) {
+  if (state.ended) return;
+  if (state.decoder) {
+    var chunk = state.decoder.end();
+    if (chunk && chunk.length) {
+      state.buffer.push(chunk);
+      state.length += state.objectMode ? 1 : chunk.length;
+    }
+  }
+  state.ended = true;
+  emitReadable(stream);
+}
+function emitReadable(stream) {
+  var state = stream._readableState;
+  state.needReadable = false;
+  if (!state.emittedReadable) {
+    debug("emitReadable", state.flowing);
+    state.emittedReadable = true;
+    if (state.sync) nextTick(emitReadable_, stream);
+    else emitReadable_(stream);
+  }
+}
+function emitReadable_(stream) {
+  debug("emit readable");
+  stream.emit("readable");
+  flow(stream);
+}
+function maybeReadMore(stream, state) {
+  if (!state.readingMore) {
+    state.readingMore = true;
+    nextTick(maybeReadMore_, stream, state);
+  }
+}
+function maybeReadMore_(stream, state) {
+  var len = state.length;
+  while (!state.reading && !state.flowing && !state.ended && state.length < state.highWaterMark) {
+    debug("maybeReadMore read 0");
+    stream.read(0);
+    if (len === state.length)
+      break;
+    else len = state.length;
+  }
+  state.readingMore = false;
+}
+Readable.prototype._read = function(n) {
+  this.emit("error", new Error("not implemented"));
+};
+Readable.prototype.pipe = function(dest, pipeOpts) {
+  var src = this;
+  var state = this._readableState;
+  switch (state.pipesCount) {
+    case 0:
+      state.pipes = dest;
+      break;
+    case 1:
+      state.pipes = [state.pipes, dest];
+      break;
+    default:
+      state.pipes.push(dest);
+      break;
+  }
+  state.pipesCount += 1;
+  debug("pipe count=%d opts=%j", state.pipesCount, pipeOpts);
+  var doEnd = !pipeOpts || pipeOpts.end !== false;
+  var endFn = doEnd ? onend2 : cleanup;
+  if (state.endEmitted) nextTick(endFn);
+  else src.once("end", endFn);
+  dest.on("unpipe", onunpipe);
+  function onunpipe(readable) {
+    debug("onunpipe");
+    if (readable === src) {
+      cleanup();
+    }
+  }
+  function onend2() {
+    debug("onend");
+    dest.end();
+  }
+  var ondrain = pipeOnDrain(src);
+  dest.on("drain", ondrain);
+  var cleanedUp = false;
+  function cleanup() {
+    debug("cleanup");
+    dest.removeListener("close", onclose);
+    dest.removeListener("finish", onfinish);
+    dest.removeListener("drain", ondrain);
+    dest.removeListener("error", onerror);
+    dest.removeListener("unpipe", onunpipe);
+    src.removeListener("end", onend2);
+    src.removeListener("end", cleanup);
+    src.removeListener("data", ondata);
+    cleanedUp = true;
+    if (state.awaitDrain && (!dest._writableState || dest._writableState.needDrain)) ondrain();
+  }
+  var increasedAwaitDrain = false;
+  src.on("data", ondata);
+  function ondata(chunk) {
+    debug("ondata");
+    increasedAwaitDrain = false;
+    var ret = dest.write(chunk);
+    if (false === ret && !increasedAwaitDrain) {
+      if ((state.pipesCount === 1 && state.pipes === dest || state.pipesCount > 1 && indexOf2(state.pipes, dest) !== -1) && !cleanedUp) {
+        debug("false write response, pause", src._readableState.awaitDrain);
+        src._readableState.awaitDrain++;
+        increasedAwaitDrain = true;
+      }
+      src.pause();
+    }
+  }
+  function onerror(er) {
+    debug("onerror", er);
+    unpipe();
+    dest.removeListener("error", onerror);
+    if (listenerCount(dest, "error") === 0) dest.emit("error", er);
+  }
+  prependListener2(dest, "error", onerror);
+  function onclose() {
+    dest.removeListener("finish", onfinish);
+    unpipe();
+  }
+  dest.once("close", onclose);
+  function onfinish() {
+    debug("onfinish");
+    dest.removeListener("close", onclose);
+    unpipe();
+  }
+  dest.once("finish", onfinish);
+  function unpipe() {
+    debug("unpipe");
+    src.unpipe(dest);
+  }
+  dest.emit("pipe", src);
+  if (!state.flowing) {
+    debug("pipe resume");
+    src.resume();
+  }
+  return dest;
+};
+function pipeOnDrain(src) {
+  return function() {
+    var state = src._readableState;
+    debug("pipeOnDrain", state.awaitDrain);
+    if (state.awaitDrain) state.awaitDrain--;
+    if (state.awaitDrain === 0 && src.listeners("data").length) {
+      state.flowing = true;
+      flow(src);
+    }
+  };
+}
+Readable.prototype.unpipe = function(dest) {
+  var state = this._readableState;
+  if (state.pipesCount === 0) return this;
+  if (state.pipesCount === 1) {
+    if (dest && dest !== state.pipes) return this;
+    if (!dest) dest = state.pipes;
+    state.pipes = null;
+    state.pipesCount = 0;
+    state.flowing = false;
+    if (dest) dest.emit("unpipe", this);
+    return this;
+  }
+  if (!dest) {
+    var dests = state.pipes;
+    var len = state.pipesCount;
+    state.pipes = null;
+    state.pipesCount = 0;
+    state.flowing = false;
+    for (var _i = 0; _i < len; _i++) {
+      dests[_i].emit("unpipe", this);
+    }
+    return this;
+  }
+  var i = indexOf2(state.pipes, dest);
+  if (i === -1) return this;
+  state.pipes.splice(i, 1);
+  state.pipesCount -= 1;
+  if (state.pipesCount === 1) state.pipes = state.pipes[0];
+  dest.emit("unpipe", this);
+  return this;
+};
+Readable.prototype.on = function(ev, fn) {
+  var res = EventEmitter.prototype.on.call(this, ev, fn);
+  if (ev === "data") {
+    if (this._readableState.flowing !== false) this.resume();
+  } else if (ev === "readable") {
+    var state = this._readableState;
+    if (!state.endEmitted && !state.readableListening) {
+      state.readableListening = state.needReadable = true;
+      state.emittedReadable = false;
+      if (!state.reading) {
+        nextTick(nReadingNextTick, this);
+      } else if (state.length) {
+        emitReadable(this);
+      }
+    }
+  }
+  return res;
+};
+Readable.prototype.addListener = Readable.prototype.on;
+function nReadingNextTick(self2) {
+  debug("readable nexttick read 0");
+  self2.read(0);
+}
+Readable.prototype.resume = function() {
+  var state = this._readableState;
+  if (!state.flowing) {
+    debug("resume");
+    state.flowing = true;
+    resume(this, state);
+  }
+  return this;
+};
+function resume(stream, state) {
+  if (!state.resumeScheduled) {
+    state.resumeScheduled = true;
+    nextTick(resume_, stream, state);
+  }
+}
+function resume_(stream, state) {
+  if (!state.reading) {
+    debug("resume read 0");
+    stream.read(0);
+  }
+  state.resumeScheduled = false;
+  state.awaitDrain = 0;
+  stream.emit("resume");
+  flow(stream);
+  if (state.flowing && !state.reading) stream.read(0);
+}
+Readable.prototype.pause = function() {
+  debug("call pause flowing=%j", this._readableState.flowing);
+  if (false !== this._readableState.flowing) {
+    debug("pause");
+    this._readableState.flowing = false;
+    this.emit("pause");
+  }
+  return this;
+};
+function flow(stream) {
+  var state = stream._readableState;
+  debug("flow", state.flowing);
+  while (state.flowing && stream.read() !== null) {
+  }
+}
+Readable.prototype.wrap = function(stream) {
+  var state = this._readableState;
+  var paused = false;
+  var self2 = this;
+  stream.on("end", function() {
+    debug("wrapped end");
+    if (state.decoder && !state.ended) {
+      var chunk = state.decoder.end();
+      if (chunk && chunk.length) self2.push(chunk);
+    }
+    self2.push(null);
+  });
+  stream.on("data", function(chunk) {
+    debug("wrapped data");
+    if (state.decoder) chunk = state.decoder.write(chunk);
+    if (state.objectMode && (chunk === null || chunk === void 0)) return;
+    else if (!state.objectMode && (!chunk || !chunk.length)) return;
+    var ret = self2.push(chunk);
+    if (!ret) {
+      paused = true;
+      stream.pause();
+    }
+  });
+  for (var i in stream) {
+    if (this[i] === void 0 && typeof stream[i] === "function") {
+      this[i] = /* @__PURE__ */ (function(method) {
+        return function() {
+          return stream[method].apply(stream, arguments);
+        };
+      })(i);
+    }
+  }
+  var events = ["error", "close", "destroy", "pause", "resume"];
+  forEach(events, function(ev) {
+    stream.on(ev, self2.emit.bind(self2, ev));
+  });
+  self2._read = function(n) {
+    debug("wrapped _read", n);
+    if (paused) {
+      paused = false;
+      stream.resume();
+    }
+  };
+  return self2;
+};
+Readable._fromList = fromList;
+function fromList(n, state) {
+  if (state.length === 0) return null;
+  var ret;
+  if (state.objectMode) ret = state.buffer.shift();
+  else if (!n || n >= state.length) {
+    if (state.decoder) ret = state.buffer.join("");
+    else if (state.buffer.length === 1) ret = state.buffer.head.data;
+    else ret = state.buffer.concat(state.length);
+    state.buffer.clear();
+  } else {
+    ret = fromListPartial(n, state.buffer, state.decoder);
+  }
+  return ret;
+}
+function fromListPartial(n, list, hasStrings) {
+  var ret;
+  if (n < list.head.data.length) {
+    ret = list.head.data.slice(0, n);
+    list.head.data = list.head.data.slice(n);
+  } else if (n === list.head.data.length) {
+    ret = list.shift();
+  } else {
+    ret = hasStrings ? copyFromBufferString(n, list) : copyFromBuffer(n, list);
+  }
+  return ret;
+}
+function copyFromBufferString(n, list) {
+  var p = list.head;
+  var c = 1;
+  var ret = p.data;
+  n -= ret.length;
+  while (p = p.next) {
+    var str = p.data;
+    var nb = n > str.length ? str.length : n;
+    if (nb === str.length) ret += str;
+    else ret += str.slice(0, n);
+    n -= nb;
+    if (n === 0) {
+      if (nb === str.length) {
+        ++c;
+        if (p.next) list.head = p.next;
+        else list.head = list.tail = null;
+      } else {
+        list.head = p;
+        p.data = str.slice(nb);
+      }
+      break;
+    }
+    ++c;
+  }
+  list.length -= c;
+  return ret;
+}
+function copyFromBuffer(n, list) {
+  var ret = Buffer.allocUnsafe(n);
+  var p = list.head;
+  var c = 1;
+  p.data.copy(ret);
+  n -= p.data.length;
+  while (p = p.next) {
+    var buf = p.data;
+    var nb = n > buf.length ? buf.length : n;
+    buf.copy(ret, ret.length - n, 0, nb);
+    n -= nb;
+    if (n === 0) {
+      if (nb === buf.length) {
+        ++c;
+        if (p.next) list.head = p.next;
+        else list.head = list.tail = null;
+      } else {
+        list.head = p;
+        p.data = buf.slice(nb);
+      }
+      break;
+    }
+    ++c;
+  }
+  list.length -= c;
+  return ret;
+}
+function endReadable(stream) {
+  var state = stream._readableState;
+  if (state.length > 0) throw new Error('"endReadable()" called on non-empty stream');
+  if (!state.endEmitted) {
+    state.ended = true;
+    nextTick(endReadableNT, state, stream);
+  }
+}
+function endReadableNT(state, stream) {
+  if (!state.endEmitted && state.length === 0) {
+    state.endEmitted = true;
+    stream.readable = false;
+    stream.emit("end");
+  }
+}
+function forEach(xs, f) {
+  for (var i = 0, l = xs.length; i < l; i++) {
+    f(xs[i], i);
+  }
+}
+function indexOf2(xs, x) {
+  for (var i = 0, l = xs.length; i < l; i++) {
+    if (xs[i] === x) return i;
+  }
+  return -1;
+}
+Writable.WritableState = WritableState;
+inherits$1(Writable, EventEmitter);
+function nop() {
+}
+function WriteReq(chunk, encoding, cb) {
+  this.chunk = chunk;
+  this.encoding = encoding;
+  this.callback = cb;
+  this.next = null;
+}
+function WritableState(options, stream) {
+  Object.defineProperty(this, "buffer", {
+    get: deprecate(function() {
+      return this.getBuffer();
+    }, "_writableState.buffer is deprecated. Use _writableState.getBuffer instead.")
+  });
+  options = options || {};
+  this.objectMode = !!options.objectMode;
+  if (stream instanceof Duplex) this.objectMode = this.objectMode || !!options.writableObjectMode;
+  var hwm = options.highWaterMark;
+  var defaultHwm = this.objectMode ? 16 : 16 * 1024;
+  this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm;
+  this.highWaterMark = ~~this.highWaterMark;
+  this.needDrain = false;
+  this.ending = false;
+  this.ended = false;
+  this.finished = false;
+  var noDecode = options.decodeStrings === false;
+  this.decodeStrings = !noDecode;
+  this.defaultEncoding = options.defaultEncoding || "utf8";
+  this.length = 0;
+  this.writing = false;
+  this.corked = 0;
+  this.sync = true;
+  this.bufferProcessing = false;
+  this.onwrite = function(er) {
+    onwrite(stream, er);
+  };
+  this.writecb = null;
+  this.writelen = 0;
+  this.bufferedRequest = null;
+  this.lastBufferedRequest = null;
+  this.pendingcb = 0;
+  this.prefinished = false;
+  this.errorEmitted = false;
+  this.bufferedRequestCount = 0;
+  this.corkedRequestsFree = new CorkedRequest(this);
+}
+WritableState.prototype.getBuffer = function writableStateGetBuffer() {
+  var current = this.bufferedRequest;
+  var out = [];
+  while (current) {
+    out.push(current);
+    current = current.next;
+  }
+  return out;
+};
+function Writable(options) {
+  if (!(this instanceof Writable) && !(this instanceof Duplex)) return new Writable(options);
+  this._writableState = new WritableState(options, this);
+  this.writable = true;
+  if (options) {
+    if (typeof options.write === "function") this._write = options.write;
+    if (typeof options.writev === "function") this._writev = options.writev;
+  }
+  EventEmitter.call(this);
+}
+Writable.prototype.pipe = function() {
+  this.emit("error", new Error("Cannot pipe, not readable"));
+};
+function writeAfterEnd(stream, cb) {
+  var er = new Error("write after end");
+  stream.emit("error", er);
+  nextTick(cb, er);
+}
+function validChunk(stream, state, chunk, cb) {
+  var valid = true;
+  var er = false;
+  if (chunk === null) {
+    er = new TypeError("May not write null values to stream");
+  } else if (!Buffer.isBuffer(chunk) && typeof chunk !== "string" && chunk !== void 0 && !state.objectMode) {
+    er = new TypeError("Invalid non-string/buffer chunk");
+  }
+  if (er) {
+    stream.emit("error", er);
+    nextTick(cb, er);
+    valid = false;
+  }
+  return valid;
+}
+Writable.prototype.write = function(chunk, encoding, cb) {
+  var state = this._writableState;
+  var ret = false;
+  if (typeof encoding === "function") {
+    cb = encoding;
+    encoding = null;
+  }
+  if (Buffer.isBuffer(chunk)) encoding = "buffer";
+  else if (!encoding) encoding = state.defaultEncoding;
+  if (typeof cb !== "function") cb = nop;
+  if (state.ended) writeAfterEnd(this, cb);
+  else if (validChunk(this, state, chunk, cb)) {
+    state.pendingcb++;
+    ret = writeOrBuffer(this, state, chunk, encoding, cb);
+  }
+  return ret;
+};
+Writable.prototype.cork = function() {
+  var state = this._writableState;
+  state.corked++;
+};
+Writable.prototype.uncork = function() {
+  var state = this._writableState;
+  if (state.corked) {
+    state.corked--;
+    if (!state.writing && !state.corked && !state.finished && !state.bufferProcessing && state.bufferedRequest) clearBuffer(this, state);
+  }
+};
+Writable.prototype.setDefaultEncoding = function setDefaultEncoding(encoding) {
+  if (typeof encoding === "string") encoding = encoding.toLowerCase();
+  if (!(["hex", "utf8", "utf-8", "ascii", "binary", "base64", "ucs2", "ucs-2", "utf16le", "utf-16le", "raw"].indexOf((encoding + "").toLowerCase()) > -1)) throw new TypeError("Unknown encoding: " + encoding);
+  this._writableState.defaultEncoding = encoding;
+  return this;
+};
+function decodeChunk(state, chunk, encoding) {
+  if (!state.objectMode && state.decodeStrings !== false && typeof chunk === "string") {
+    chunk = Buffer.from(chunk, encoding);
+  }
+  return chunk;
+}
+function writeOrBuffer(stream, state, chunk, encoding, cb) {
+  chunk = decodeChunk(state, chunk, encoding);
+  if (Buffer.isBuffer(chunk)) encoding = "buffer";
+  var len = state.objectMode ? 1 : chunk.length;
+  state.length += len;
+  var ret = state.length < state.highWaterMark;
+  if (!ret) state.needDrain = true;
+  if (state.writing || state.corked) {
+    var last = state.lastBufferedRequest;
+    state.lastBufferedRequest = new WriteReq(chunk, encoding, cb);
+    if (last) {
+      last.next = state.lastBufferedRequest;
+    } else {
+      state.bufferedRequest = state.lastBufferedRequest;
+    }
+    state.bufferedRequestCount += 1;
+  } else {
+    doWrite(stream, state, false, len, chunk, encoding, cb);
+  }
+  return ret;
+}
+function doWrite(stream, state, writev, len, chunk, encoding, cb) {
+  state.writelen = len;
+  state.writecb = cb;
+  state.writing = true;
+  state.sync = true;
+  if (writev) stream._writev(chunk, state.onwrite);
+  else stream._write(chunk, encoding, state.onwrite);
+  state.sync = false;
+}
+function onwriteError(stream, state, sync, er, cb) {
+  --state.pendingcb;
+  if (sync) nextTick(cb, er);
+  else cb(er);
+  stream._writableState.errorEmitted = true;
+  stream.emit("error", er);
+}
+function onwriteStateUpdate(state) {
+  state.writing = false;
+  state.writecb = null;
+  state.length -= state.writelen;
+  state.writelen = 0;
+}
+function onwrite(stream, er) {
+  var state = stream._writableState;
+  var sync = state.sync;
+  var cb = state.writecb;
+  onwriteStateUpdate(state);
+  if (er) onwriteError(stream, state, sync, er, cb);
+  else {
+    var finished = needFinish(state);
+    if (!finished && !state.corked && !state.bufferProcessing && state.bufferedRequest) {
+      clearBuffer(stream, state);
+    }
+    if (sync) {
+      nextTick(afterWrite, stream, state, finished, cb);
+    } else {
+      afterWrite(stream, state, finished, cb);
+    }
+  }
+}
+function afterWrite(stream, state, finished, cb) {
+  if (!finished) onwriteDrain(stream, state);
+  state.pendingcb--;
+  cb();
+  finishMaybe(stream, state);
+}
+function onwriteDrain(stream, state) {
+  if (state.length === 0 && state.needDrain) {
+    state.needDrain = false;
+    stream.emit("drain");
+  }
+}
+function clearBuffer(stream, state) {
+  state.bufferProcessing = true;
+  var entry = state.bufferedRequest;
+  if (stream._writev && entry && entry.next) {
+    var l = state.bufferedRequestCount;
+    var buffer = new Array(l);
+    var holder = state.corkedRequestsFree;
+    holder.entry = entry;
+    var count = 0;
+    while (entry) {
+      buffer[count] = entry;
+      entry = entry.next;
+      count += 1;
+    }
+    doWrite(stream, state, true, state.length, buffer, "", holder.finish);
+    state.pendingcb++;
+    state.lastBufferedRequest = null;
+    if (holder.next) {
+      state.corkedRequestsFree = holder.next;
+      holder.next = null;
+    } else {
+      state.corkedRequestsFree = new CorkedRequest(state);
+    }
+  } else {
+    while (entry) {
+      var chunk = entry.chunk;
+      var encoding = entry.encoding;
+      var cb = entry.callback;
+      var len = state.objectMode ? 1 : chunk.length;
+      doWrite(stream, state, false, len, chunk, encoding, cb);
+      entry = entry.next;
+      if (state.writing) {
+        break;
+      }
+    }
+    if (entry === null) state.lastBufferedRequest = null;
+  }
+  state.bufferedRequestCount = 0;
+  state.bufferedRequest = entry;
+  state.bufferProcessing = false;
+}
+Writable.prototype._write = function(chunk, encoding, cb) {
+  cb(new Error("not implemented"));
+};
+Writable.prototype._writev = null;
+Writable.prototype.end = function(chunk, encoding, cb) {
+  var state = this._writableState;
+  if (typeof chunk === "function") {
+    cb = chunk;
+    chunk = null;
+    encoding = null;
+  } else if (typeof encoding === "function") {
+    cb = encoding;
+    encoding = null;
+  }
+  if (chunk !== null && chunk !== void 0) this.write(chunk, encoding);
+  if (state.corked) {
+    state.corked = 1;
+    this.uncork();
+  }
+  if (!state.ending && !state.finished) endWritable(this, state, cb);
+};
+function needFinish(state) {
+  return state.ending && state.length === 0 && state.bufferedRequest === null && !state.finished && !state.writing;
+}
+function prefinish(stream, state) {
+  if (!state.prefinished) {
+    state.prefinished = true;
+    stream.emit("prefinish");
+  }
+}
+function finishMaybe(stream, state) {
+  var need = needFinish(state);
+  if (need) {
+    if (state.pendingcb === 0) {
+      prefinish(stream, state);
+      state.finished = true;
+      stream.emit("finish");
+    } else {
+      prefinish(stream, state);
+    }
+  }
+  return need;
+}
+function endWritable(stream, state, cb) {
+  state.ending = true;
+  finishMaybe(stream, state);
+  if (cb) {
+    if (state.finished) nextTick(cb);
+    else stream.once("finish", cb);
+  }
+  state.ended = true;
+  stream.writable = false;
+}
+function CorkedRequest(state) {
+  var _this = this;
+  this.next = null;
+  this.entry = null;
+  this.finish = function(err) {
+    var entry = _this.entry;
+    _this.entry = null;
+    while (entry) {
+      var cb = entry.callback;
+      state.pendingcb--;
+      cb(err);
+      entry = entry.next;
+    }
+    if (state.corkedRequestsFree) {
+      state.corkedRequestsFree.next = _this;
+    } else {
+      state.corkedRequestsFree = _this;
+    }
+  };
+}
+inherits$1(Duplex, Readable);
+var keys = Object.keys(Writable.prototype);
+for (v = 0; v < keys.length; v++) {
+  method = keys[v];
+  if (!Duplex.prototype[method]) Duplex.prototype[method] = Writable.prototype[method];
+}
+var method;
+var v;
+function Duplex(options) {
+  if (!(this instanceof Duplex)) return new Duplex(options);
+  Readable.call(this, options);
+  Writable.call(this, options);
+  if (options && options.readable === false) this.readable = false;
+  if (options && options.writable === false) this.writable = false;
+  this.allowHalfOpen = true;
+  if (options && options.allowHalfOpen === false) this.allowHalfOpen = false;
+  this.once("end", onend);
+}
+function onend() {
+  if (this.allowHalfOpen || this._writableState.ended) return;
+  nextTick(onEndNT, this);
+}
+function onEndNT(self2) {
+  self2.end();
+}
+inherits$1(Transform, Duplex);
+function TransformState(stream) {
+  this.afterTransform = function(er, data) {
+    return afterTransform(stream, er, data);
+  };
+  this.needTransform = false;
+  this.transforming = false;
+  this.writecb = null;
+  this.writechunk = null;
+  this.writeencoding = null;
+}
+function afterTransform(stream, er, data) {
+  var ts = stream._transformState;
+  ts.transforming = false;
+  var cb = ts.writecb;
+  if (!cb) return stream.emit("error", new Error("no writecb in Transform class"));
+  ts.writechunk = null;
+  ts.writecb = null;
+  if (data !== null && data !== void 0) stream.push(data);
+  cb(er);
+  var rs = stream._readableState;
+  rs.reading = false;
+  if (rs.needReadable || rs.length < rs.highWaterMark) {
+    stream._read(rs.highWaterMark);
+  }
+}
+function Transform(options) {
+  if (!(this instanceof Transform)) return new Transform(options);
+  Duplex.call(this, options);
+  this._transformState = new TransformState(this);
+  var stream = this;
+  this._readableState.needReadable = true;
+  this._readableState.sync = false;
+  if (options) {
+    if (typeof options.transform === "function") this._transform = options.transform;
+    if (typeof options.flush === "function") this._flush = options.flush;
+  }
+  this.once("prefinish", function() {
+    if (typeof this._flush === "function") this._flush(function(er) {
+      done(stream, er);
+    });
+    else done(stream);
+  });
+}
+Transform.prototype.push = function(chunk, encoding) {
+  this._transformState.needTransform = false;
+  return Duplex.prototype.push.call(this, chunk, encoding);
+};
+Transform.prototype._transform = function(chunk, encoding, cb) {
+  throw new Error("Not implemented");
+};
+Transform.prototype._write = function(chunk, encoding, cb) {
+  var ts = this._transformState;
+  ts.writecb = cb;
+  ts.writechunk = chunk;
+  ts.writeencoding = encoding;
+  if (!ts.transforming) {
+    var rs = this._readableState;
+    if (ts.needTransform || rs.needReadable || rs.length < rs.highWaterMark) this._read(rs.highWaterMark);
+  }
+};
+Transform.prototype._read = function(n) {
+  var ts = this._transformState;
+  if (ts.writechunk !== null && ts.writecb && !ts.transforming) {
+    ts.transforming = true;
+    this._transform(ts.writechunk, ts.writeencoding, ts.afterTransform);
+  } else {
+    ts.needTransform = true;
+  }
+};
+function done(stream, er) {
+  if (er) return stream.emit("error", er);
+  var ws = stream._writableState;
+  var ts = stream._transformState;
+  if (ws.length) throw new Error("Calling transform done when ws.length != 0");
+  if (ts.transforming) throw new Error("Calling transform done when still transforming");
+  return stream.push(null);
+}
+inherits$1(PassThrough, Transform);
+function PassThrough(options) {
+  if (!(this instanceof PassThrough)) return new PassThrough(options);
+  Transform.call(this, options);
+}
+PassThrough.prototype._transform = function(chunk, encoding, cb) {
+  cb(null, chunk);
+};
+inherits$1(Stream, EventEmitter);
+Stream.Readable = Readable;
+Stream.Writable = Writable;
+Stream.Duplex = Duplex;
+Stream.Transform = Transform;
+Stream.PassThrough = PassThrough;
+Stream.Stream = Stream;
+function Stream() {
+  EventEmitter.call(this);
+}
+Stream.prototype.pipe = function(dest, options) {
+  var source = this;
+  function ondata(chunk) {
+    if (dest.writable) {
+      if (false === dest.write(chunk) && source.pause) {
+        source.pause();
+      }
+    }
+  }
+  source.on("data", ondata);
+  function ondrain() {
+    if (source.readable && source.resume) {
+      source.resume();
+    }
+  }
+  dest.on("drain", ondrain);
+  if (!dest._isStdio && (!options || options.end !== false)) {
+    source.on("end", onend2);
+    source.on("close", onclose);
+  }
+  var didOnEnd = false;
+  function onend2() {
+    if (didOnEnd) return;
+    didOnEnd = true;
+    dest.end();
+  }
+  function onclose() {
+    if (didOnEnd) return;
+    didOnEnd = true;
+    if (typeof dest.destroy === "function") dest.destroy();
+  }
+  function onerror(er) {
+    cleanup();
+    if (EventEmitter.listenerCount(this, "error") === 0) {
+      throw er;
+    }
+  }
+  source.on("error", onerror);
+  dest.on("error", onerror);
+  function cleanup() {
+    source.removeListener("data", ondata);
+    dest.removeListener("drain", ondrain);
+    source.removeListener("end", onend2);
+    source.removeListener("close", onclose);
+    source.removeListener("error", onerror);
+    dest.removeListener("error", onerror);
+    source.removeListener("end", cleanup);
+    source.removeListener("close", cleanup);
+    dest.removeListener("close", cleanup);
+  }
+  source.on("end", cleanup);
+  source.on("close", cleanup);
+  dest.on("close", cleanup);
+  dest.emit("pipe", source);
+  return dest;
+};
+var is_object = function(obj) {
+  return typeof obj === "object" && obj !== null && !Array.isArray(obj);
+};
+var CsvError = class _CsvError extends Error {
+  constructor(code, message, options, ...contexts) {
+    if (Array.isArray(message)) message = message.join(" ").trim();
+    super(message);
+    if (Error.captureStackTrace !== void 0) {
+      Error.captureStackTrace(this, _CsvError);
+    }
+    this.code = code;
+    for (const context of contexts) {
+      for (const key in context) {
+        const value = context[key];
+        this[key] = isBuffer(value) ? value.toString(options.encoding) : value == null ? value : JSON.parse(JSON.stringify(value));
+      }
+    }
+  }
+};
+var normalize_columns_array = function(columns) {
+  const normalizedColumns = [];
+  for (let i = 0, l = columns.length; i < l; i++) {
+    const column = columns[i];
+    if (column === void 0 || column === null || column === false) {
+      normalizedColumns[i] = { disabled: true };
+    } else if (typeof column === "string") {
+      normalizedColumns[i] = { name: column };
+    } else if (is_object(column)) {
+      if (typeof column.name !== "string") {
+        throw new CsvError("CSV_OPTION_COLUMNS_MISSING_NAME", [
+          "Option columns missing name:",
+          `property "name" is required at position ${i}`,
+          "when column is an object literal"
+        ]);
+      }
+      normalizedColumns[i] = column;
+    } else {
+      throw new CsvError("CSV_INVALID_COLUMN_DEFINITION", [
+        "Invalid column definition:",
+        "expect a string or a literal object,",
+        `got ${JSON.stringify(column)} at position ${i}`
+      ]);
+    }
+  }
+  return normalizedColumns;
+};
+var ResizeableBuffer = class {
+  constructor(size = 100) {
+    this.size = size;
+    this.length = 0;
+    this.buf = Buffer.allocUnsafe(size);
+  }
+  prepend(val) {
+    if (isBuffer(val)) {
+      const length = this.length + val.length;
+      if (length >= this.size) {
+        this.resize();
+        if (length >= this.size) {
+          throw Error("INVALID_BUFFER_STATE");
+        }
+      }
+      const buf = this.buf;
+      this.buf = Buffer.allocUnsafe(this.size);
+      val.copy(this.buf, 0);
+      buf.copy(this.buf, val.length);
+      this.length += val.length;
+    } else {
+      const length = this.length++;
+      if (length === this.size) {
+        this.resize();
+      }
+      const buf = this.clone();
+      this.buf[0] = val;
+      buf.copy(this.buf, 1, 0, length);
+    }
+  }
+  append(val) {
+    const length = this.length++;
+    if (length === this.size) {
+      this.resize();
+    }
+    this.buf[length] = val;
+  }
+  clone() {
+    return Buffer.from(this.buf.slice(0, this.length));
+  }
+  resize() {
+    const length = this.length;
+    this.size = this.size * 2;
+    const buf = Buffer.allocUnsafe(this.size);
+    this.buf.copy(buf, 0, 0, length);
+    this.buf = buf;
+  }
+  toString(encoding) {
+    if (encoding) {
+      return this.buf.slice(0, this.length).toString(encoding);
+    } else {
+      return Uint8Array.prototype.slice.call(this.buf.slice(0, this.length));
+    }
+  }
+  toJSON() {
+    return this.toString("utf8");
+  }
+  reset() {
+    this.length = 0;
+  }
+};
+var np = 12;
+var cr$1 = 13;
+var nl$1 = 10;
+var space = 32;
+var tab = 9;
+var init_state = function(options) {
+  return {
+    bomSkipped: false,
+    bufBytesStart: 0,
+    castField: options.cast_function,
+    commenting: false,
+    // Current error encountered by a record
+    error: void 0,
+    enabled: options.from_line === 1,
+    escaping: false,
+    escapeIsQuote: isBuffer(options.escape) && isBuffer(options.quote) && Buffer.compare(options.escape, options.quote) === 0,
+    // columns can be `false`, `true`, `Array`
+    expectedRecordLength: Array.isArray(options.columns) ? options.columns.length : void 0,
+    field: new ResizeableBuffer(20),
+    firstLineToHeaders: options.cast_first_line_to_header,
+    needMoreDataSize: Math.max(
+      // Skip if the remaining buffer smaller than comment
+      options.comment !== null ? options.comment.length : 0,
+      ...options.delimiter.map((delimiter) => delimiter.length),
+      // Skip if the remaining buffer can be escape sequence
+      options.quote !== null ? options.quote.length : 0
+    ),
+    previousBuf: void 0,
+    quoting: false,
+    stop: false,
+    rawBuffer: new ResizeableBuffer(100),
+    record: [],
+    recordHasError: false,
+    record_length: 0,
+    recordDelimiterMaxLength: options.record_delimiter.length === 0 ? 0 : Math.max(...options.record_delimiter.map((v) => v.length)),
+    trimChars: [Buffer.from(" ", options.encoding)[0], Buffer.from("	", options.encoding)[0]],
+    wasQuoting: false,
+    wasRowDelimiter: false,
+    timchars: [
+      Buffer.from(Buffer.from([cr$1], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([nl$1], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([np], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([space], "utf8").toString(), options.encoding),
+      Buffer.from(Buffer.from([tab], "utf8").toString(), options.encoding)
+    ]
+  };
+};
+var underscore = function(str) {
+  return str.replace(/([A-Z])/g, function(_, match) {
+    return "_" + match.toLowerCase();
+  });
+};
+var normalize_options = function(opts) {
+  const options = {};
+  for (const opt in opts) {
+    options[underscore(opt)] = opts[opt];
+  }
+  if (options.encoding === void 0 || options.encoding === true) {
+    options.encoding = "utf8";
+  } else if (options.encoding === null || options.encoding === false) {
+    options.encoding = null;
+  } else if (typeof options.encoding !== "string" && options.encoding !== null) {
+    throw new CsvError("CSV_INVALID_OPTION_ENCODING", [
+      "Invalid option encoding:",
+      "encoding must be a string or null to return a buffer,",
+      `got ${JSON.stringify(options.encoding)}`
+    ], options);
+  }
+  if (options.bom === void 0 || options.bom === null || options.bom === false) {
+    options.bom = false;
+  } else if (options.bom !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_BOM", [
+      "Invalid option bom:",
+      "bom must be true,",
+      `got ${JSON.stringify(options.bom)}`
+    ], options);
+  }
+  options.cast_function = null;
+  if (options.cast === void 0 || options.cast === null || options.cast === false || options.cast === "") {
+    options.cast = void 0;
+  } else if (typeof options.cast === "function") {
+    options.cast_function = options.cast;
+    options.cast = true;
+  } else if (options.cast !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_CAST", [
+      "Invalid option cast:",
+      "cast must be true or a function,",
+      `got ${JSON.stringify(options.cast)}`
+    ], options);
+  }
+  if (options.cast_date === void 0 || options.cast_date === null || options.cast_date === false || options.cast_date === "") {
+    options.cast_date = false;
+  } else if (options.cast_date === true) {
+    options.cast_date = function(value) {
+      const date = Date.parse(value);
+      return !isNaN(date) ? new Date(date) : value;
+    };
+  } else if (typeof options.cast_date !== "function") {
+    throw new CsvError("CSV_INVALID_OPTION_CAST_DATE", [
+      "Invalid option cast_date:",
+      "cast_date must be true or a function,",
+      `got ${JSON.stringify(options.cast_date)}`
+    ], options);
+  }
+  options.cast_first_line_to_header = null;
+  if (options.columns === true) {
+    options.cast_first_line_to_header = void 0;
+  } else if (typeof options.columns === "function") {
+    options.cast_first_line_to_header = options.columns;
+    options.columns = true;
+  } else if (Array.isArray(options.columns)) {
+    options.columns = normalize_columns_array(options.columns);
+  } else if (options.columns === void 0 || options.columns === null || options.columns === false) {
+    options.columns = false;
+  } else {
+    throw new CsvError("CSV_INVALID_OPTION_COLUMNS", [
+      "Invalid option columns:",
+      "expect an array, a function or true,",
+      `got ${JSON.stringify(options.columns)}`
+    ], options);
+  }
+  if (options.group_columns_by_name === void 0 || options.group_columns_by_name === null || options.group_columns_by_name === false) {
+    options.group_columns_by_name = false;
+  } else if (options.group_columns_by_name !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [
+      "Invalid option group_columns_by_name:",
+      "expect an boolean,",
+      `got ${JSON.stringify(options.group_columns_by_name)}`
+    ], options);
+  } else if (options.columns === false) {
+    throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [
+      "Invalid option group_columns_by_name:",
+      "the `columns` mode must be activated."
+    ], options);
+  }
+  if (options.comment === void 0 || options.comment === null || options.comment === false || options.comment === "") {
+    options.comment = null;
+  } else {
+    if (typeof options.comment === "string") {
+      options.comment = Buffer.from(options.comment, options.encoding);
+    }
+    if (!isBuffer(options.comment)) {
+      throw new CsvError("CSV_INVALID_OPTION_COMMENT", [
+        "Invalid option comment:",
+        "comment must be a buffer or a string,",
+        `got ${JSON.stringify(options.comment)}`
+      ], options);
+    }
+  }
+  if (options.comment_no_infix === void 0 || options.comment_no_infix === null || options.comment_no_infix === false) {
+    options.comment_no_infix = false;
+  } else if (options.comment_no_infix !== true) {
+    throw new CsvError("CSV_INVALID_OPTION_COMMENT", [
+      "Invalid option comment_no_infix:",
+      "value must be a boolean,",
+      `got ${JSON.stringify(options.comment_no_infix)}`
+    ], options);
+  }
+  const delimiter_json = JSON.stringify(options.delimiter);
+  if (!Array.isArray(options.delimiter)) options.delimiter = [options.delimiter];
+  if (options.delimiter.length === 0) {
+    throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [
+      "Invalid option delimiter:",
+      "delimiter must be a non empty string or buffer or array of string|buffer,",
+      `got ${delimiter_json}`
+    ], options);
+  }
+  options.delimiter = options.delimiter.map(function(delimiter) {
+    if (delimiter === void 0 || delimiter === null || delimiter === false) {
+      return Buffer.from(",", options.encoding);
+    }
+    if (typeof delimiter === "string") {
+      delimiter = Buffer.from(delimiter, options.encoding);
+    }
+    if (!isBuffer(delimiter) || delimiter.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [
+        "Invalid option delimiter:",
+        "delimiter must be a non empty string or buffer or array of string|buffer,",
+        `got ${delimiter_json}`
+      ], options);
+    }
+    return delimiter;
+  });
+  if (options.escape === void 0 || options.escape === true) {
+    options.escape = Buffer.from('"', options.encoding);
+  } else if (typeof options.escape === "string") {
+    options.escape = Buffer.from(options.escape, options.encoding);
+  } else if (options.escape === null || options.escape === false) {
+    options.escape = null;
+  }
+  if (options.escape !== null) {
+    if (!isBuffer(options.escape)) {
+      throw new Error(`Invalid Option: escape must be a buffer, a string or a boolean, got ${JSON.stringify(options.escape)}`);
+    }
+  }
+  if (options.from === void 0 || options.from === null) {
+    options.from = 1;
+  } else {
+    if (typeof options.from === "string" && /\d+/.test(options.from)) {
+      options.from = parseInt(options.from);
+    }
+    if (Number.isInteger(options.from)) {
+      if (options.from < 0) {
+        throw new Error(`Invalid Option: from must be a positive integer, got ${JSON.stringify(opts.from)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: from must be an integer, got ${JSON.stringify(options.from)}`);
+    }
+  }
+  if (options.from_line === void 0 || options.from_line === null) {
+    options.from_line = 1;
+  } else {
+    if (typeof options.from_line === "string" && /\d+/.test(options.from_line)) {
+      options.from_line = parseInt(options.from_line);
+    }
+    if (Number.isInteger(options.from_line)) {
+      if (options.from_line <= 0) {
+        throw new Error(`Invalid Option: from_line must be a positive integer greater than 0, got ${JSON.stringify(opts.from_line)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: from_line must be an integer, got ${JSON.stringify(opts.from_line)}`);
+    }
+  }
+  if (options.ignore_last_delimiters === void 0 || options.ignore_last_delimiters === null) {
+    options.ignore_last_delimiters = false;
+  } else if (typeof options.ignore_last_delimiters === "number") {
+    options.ignore_last_delimiters = Math.floor(options.ignore_last_delimiters);
+    if (options.ignore_last_delimiters === 0) {
+      options.ignore_last_delimiters = false;
+    }
+  } else if (typeof options.ignore_last_delimiters !== "boolean") {
+    throw new CsvError("CSV_INVALID_OPTION_IGNORE_LAST_DELIMITERS", [
+      "Invalid option `ignore_last_delimiters`:",
+      "the value must be a boolean value or an integer,",
+      `got ${JSON.stringify(options.ignore_last_delimiters)}`
+    ], options);
+  }
+  if (options.ignore_last_delimiters === true && options.columns === false) {
+    throw new CsvError("CSV_IGNORE_LAST_DELIMITERS_REQUIRES_COLUMNS", [
+      "The option `ignore_last_delimiters`",
+      "requires the activation of the `columns` option"
+    ], options);
+  }
+  if (options.info === void 0 || options.info === null || options.info === false) {
+    options.info = false;
+  } else if (options.info !== true) {
+    throw new Error(`Invalid Option: info must be true, got ${JSON.stringify(options.info)}`);
+  }
+  if (options.max_record_size === void 0 || options.max_record_size === null || options.max_record_size === false) {
+    options.max_record_size = 0;
+  } else if (Number.isInteger(options.max_record_size) && options.max_record_size >= 0) ;
+  else if (typeof options.max_record_size === "string" && /\d+/.test(options.max_record_size)) {
+    options.max_record_size = parseInt(options.max_record_size);
+  } else {
+    throw new Error(`Invalid Option: max_record_size must be a positive integer, got ${JSON.stringify(options.max_record_size)}`);
+  }
+  if (options.objname === void 0 || options.objname === null || options.objname === false) {
+    options.objname = void 0;
+  } else if (isBuffer(options.objname)) {
+    if (options.objname.length === 0) {
+      throw new Error(`Invalid Option: objname must be a non empty buffer`);
+    }
+    if (options.encoding === null) ;
+    else {
+      options.objname = options.objname.toString(options.encoding);
+    }
+  } else if (typeof options.objname === "string") {
+    if (options.objname.length === 0) {
+      throw new Error(`Invalid Option: objname must be a non empty string`);
+    }
+  } else if (typeof options.objname === "number") ;
+  else {
+    throw new Error(`Invalid Option: objname must be a string or a buffer, got ${options.objname}`);
+  }
+  if (options.objname !== void 0) {
+    if (typeof options.objname === "number") {
+      if (options.columns !== false) {
+        throw Error("Invalid Option: objname index cannot be combined with columns or be defined as a field");
+      }
+    } else {
+      if (options.columns === false) {
+        throw Error("Invalid Option: objname field must be combined with columns or be defined as an index");
+      }
+    }
+  }
+  if (options.on_record === void 0 || options.on_record === null) {
+    options.on_record = void 0;
+  } else if (typeof options.on_record !== "function") {
+    throw new CsvError("CSV_INVALID_OPTION_ON_RECORD", [
+      "Invalid option `on_record`:",
+      "expect a function,",
+      `got ${JSON.stringify(options.on_record)}`
+    ], options);
+  }
+  if (options.on_skip !== void 0 && options.on_skip !== null && typeof options.on_skip !== "function") {
+    throw new Error(`Invalid Option: on_skip must be a function, got ${JSON.stringify(options.on_skip)}`);
+  }
+  if (options.quote === null || options.quote === false || options.quote === "") {
+    options.quote = null;
+  } else {
+    if (options.quote === void 0 || options.quote === true) {
+      options.quote = Buffer.from('"', options.encoding);
+    } else if (typeof options.quote === "string") {
+      options.quote = Buffer.from(options.quote, options.encoding);
+    }
+    if (!isBuffer(options.quote)) {
+      throw new Error(`Invalid Option: quote must be a buffer or a string, got ${JSON.stringify(options.quote)}`);
+    }
+  }
+  if (options.raw === void 0 || options.raw === null || options.raw === false) {
+    options.raw = false;
+  } else if (options.raw !== true) {
+    throw new Error(`Invalid Option: raw must be true, got ${JSON.stringify(options.raw)}`);
+  }
+  if (options.record_delimiter === void 0) {
+    options.record_delimiter = [];
+  } else if (typeof options.record_delimiter === "string" || isBuffer(options.record_delimiter)) {
+    if (options.record_delimiter.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a non empty string or buffer,",
+        `got ${JSON.stringify(options.record_delimiter)}`
+      ], options);
+    }
+    options.record_delimiter = [options.record_delimiter];
+  } else if (!Array.isArray(options.record_delimiter)) {
+    throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+      "Invalid option `record_delimiter`:",
+      "value must be a string, a buffer or array of string|buffer,",
+      `got ${JSON.stringify(options.record_delimiter)}`
+    ], options);
+  }
+  options.record_delimiter = options.record_delimiter.map(function(rd, i) {
+    if (typeof rd !== "string" && !isBuffer(rd)) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a string, a buffer or array of string|buffer",
+        `at index ${i},`,
+        `got ${JSON.stringify(rd)}`
+      ], options);
+    } else if (rd.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a non empty string or buffer",
+        `at index ${i},`,
+        `got ${JSON.stringify(rd)}`
+      ], options);
+    }
+    if (typeof rd === "string") {
+      rd = Buffer.from(rd, options.encoding);
+    }
+    return rd;
+  });
+  if (typeof options.relax_column_count === "boolean") ;
+  else if (options.relax_column_count === void 0 || options.relax_column_count === null) {
+    options.relax_column_count = false;
+  } else {
+    throw new Error(`Invalid Option: relax_column_count must be a boolean, got ${JSON.stringify(options.relax_column_count)}`);
+  }
+  if (typeof options.relax_column_count_less === "boolean") ;
+  else if (options.relax_column_count_less === void 0 || options.relax_column_count_less === null) {
+    options.relax_column_count_less = false;
+  } else {
+    throw new Error(`Invalid Option: relax_column_count_less must be a boolean, got ${JSON.stringify(options.relax_column_count_less)}`);
+  }
+  if (typeof options.relax_column_count_more === "boolean") ;
+  else if (options.relax_column_count_more === void 0 || options.relax_column_count_more === null) {
+    options.relax_column_count_more = false;
+  } else {
+    throw new Error(`Invalid Option: relax_column_count_more must be a boolean, got ${JSON.stringify(options.relax_column_count_more)}`);
+  }
+  if (typeof options.relax_quotes === "boolean") ;
+  else if (options.relax_quotes === void 0 || options.relax_quotes === null) {
+    options.relax_quotes = false;
+  } else {
+    throw new Error(`Invalid Option: relax_quotes must be a boolean, got ${JSON.stringify(options.relax_quotes)}`);
+  }
+  if (typeof options.skip_empty_lines === "boolean") ;
+  else if (options.skip_empty_lines === void 0 || options.skip_empty_lines === null) {
+    options.skip_empty_lines = false;
+  } else {
+    throw new Error(`Invalid Option: skip_empty_lines must be a boolean, got ${JSON.stringify(options.skip_empty_lines)}`);
+  }
+  if (typeof options.skip_records_with_empty_values === "boolean") ;
+  else if (options.skip_records_with_empty_values === void 0 || options.skip_records_with_empty_values === null) {
+    options.skip_records_with_empty_values = false;
+  } else {
+    throw new Error(`Invalid Option: skip_records_with_empty_values must be a boolean, got ${JSON.stringify(options.skip_records_with_empty_values)}`);
+  }
+  if (typeof options.skip_records_with_error === "boolean") ;
+  else if (options.skip_records_with_error === void 0 || options.skip_records_with_error === null) {
+    options.skip_records_with_error = false;
+  } else {
+    throw new Error(`Invalid Option: skip_records_with_error must be a boolean, got ${JSON.stringify(options.skip_records_with_error)}`);
+  }
+  if (options.rtrim === void 0 || options.rtrim === null || options.rtrim === false) {
+    options.rtrim = false;
+  } else if (options.rtrim !== true) {
+    throw new Error(`Invalid Option: rtrim must be a boolean, got ${JSON.stringify(options.rtrim)}`);
+  }
+  if (options.ltrim === void 0 || options.ltrim === null || options.ltrim === false) {
+    options.ltrim = false;
+  } else if (options.ltrim !== true) {
+    throw new Error(`Invalid Option: ltrim must be a boolean, got ${JSON.stringify(options.ltrim)}`);
+  }
+  if (options.trim === void 0 || options.trim === null || options.trim === false) {
+    options.trim = false;
+  } else if (options.trim !== true) {
+    throw new Error(`Invalid Option: trim must be a boolean, got ${JSON.stringify(options.trim)}`);
+  }
+  if (options.trim === true && opts.ltrim !== false) {
+    options.ltrim = true;
+  } else if (options.ltrim !== true) {
+    options.ltrim = false;
+  }
+  if (options.trim === true && opts.rtrim !== false) {
+    options.rtrim = true;
+  } else if (options.rtrim !== true) {
+    options.rtrim = false;
+  }
+  if (options.to === void 0 || options.to === null) {
+    options.to = -1;
+  } else {
+    if (typeof options.to === "string" && /\d+/.test(options.to)) {
+      options.to = parseInt(options.to);
+    }
+    if (Number.isInteger(options.to)) {
+      if (options.to <= 0) {
+        throw new Error(`Invalid Option: to must be a positive integer greater than 0, got ${JSON.stringify(opts.to)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: to must be an integer, got ${JSON.stringify(opts.to)}`);
+    }
+  }
+  if (options.to_line === void 0 || options.to_line === null) {
+    options.to_line = -1;
+  } else {
+    if (typeof options.to_line === "string" && /\d+/.test(options.to_line)) {
+      options.to_line = parseInt(options.to_line);
+    }
+    if (Number.isInteger(options.to_line)) {
+      if (options.to_line <= 0) {
+        throw new Error(`Invalid Option: to_line must be a positive integer greater than 0, got ${JSON.stringify(opts.to_line)}`);
+      }
+    } else {
+      throw new Error(`Invalid Option: to_line must be an integer, got ${JSON.stringify(opts.to_line)}`);
+    }
+  }
+  return options;
+};
+var isRecordEmpty = function(record) {
+  return record.every((field) => field == null || field.toString && field.toString().trim() === "");
+};
+var cr = 13;
+var nl = 10;
+var boms = {
+  // Note, the following are equals:
+  // Buffer.from("\ufeff")
+  // Buffer.from([239, 187, 191])
+  // Buffer.from('EFBBBF', 'hex')
+  "utf8": Buffer.from([239, 187, 191]),
+  // Note, the following are equals:
+  // Buffer.from "\ufeff", 'utf16le
+  // Buffer.from([255, 254])
+  "utf16le": Buffer.from([255, 254])
+};
+var transform = function(original_options = {}) {
+  const info = {
+    bytes: 0,
+    comment_lines: 0,
+    empty_lines: 0,
+    invalid_field_length: 0,
+    lines: 1,
+    records: 0
+  };
+  const options = normalize_options(original_options);
+  return {
+    info,
+    original_options,
+    options,
+    state: init_state(options),
+    __needMoreData: function(i, bufLen, end) {
+      if (end) return false;
+      const { encoding, escape, quote } = this.options;
+      const { quoting, needMoreDataSize, recordDelimiterMaxLength } = this.state;
+      const numOfCharLeft = bufLen - i - 1;
+      const requiredLength = Math.max(
+        needMoreDataSize,
+        // Skip if the remaining buffer smaller than record delimiter
+        // If "record_delimiter" is yet to be discovered:
+        // 1. It is equals to `[]` and "recordDelimiterMaxLength" equals `0`
+        // 2. We set the length to windows line ending in the current encoding
+        // Note, that encoding is known from user or bom discovery at that point
+        // recordDelimiterMaxLength,
+        recordDelimiterMaxLength === 0 ? Buffer.from("\r\n", encoding).length : recordDelimiterMaxLength,
+        // Skip if remaining buffer can be an escaped quote
+        quoting ? (escape === null ? 0 : escape.length) + quote.length : 0,
+        // Skip if remaining buffer can be record delimiter following the closing quote
+        quoting ? quote.length + recordDelimiterMaxLength : 0
+      );
+      return numOfCharLeft < requiredLength;
+    },
+    // Central parser implementation
+    parse: function(nextBuf, end, push, close) {
+      const { bom, comment_no_infix, encoding, from_line, ltrim, max_record_size, raw, relax_quotes, rtrim, skip_empty_lines, to, to_line } = this.options;
+      let { comment, escape, quote, record_delimiter } = this.options;
+      const { bomSkipped, previousBuf, rawBuffer, escapeIsQuote } = this.state;
+      let buf;
+      if (previousBuf === void 0) {
+        if (nextBuf === void 0) {
+          close();
+          return;
+        } else {
+          buf = nextBuf;
+        }
+      } else if (previousBuf !== void 0 && nextBuf === void 0) {
+        buf = previousBuf;
+      } else {
+        buf = Buffer.concat([previousBuf, nextBuf]);
+      }
+      if (bomSkipped === false) {
+        if (bom === false) {
+          this.state.bomSkipped = true;
+        } else if (buf.length < 3) {
+          if (end === false) {
+            this.state.previousBuf = buf;
+            return;
+          }
+        } else {
+          for (const encoding2 in boms) {
+            if (boms[encoding2].compare(buf, 0, boms[encoding2].length) === 0) {
+              const bomLength = boms[encoding2].length;
+              this.state.bufBytesStart += bomLength;
+              buf = buf.slice(bomLength);
+              this.options = normalize_options({ ...this.original_options, encoding: encoding2 });
+              ({ comment, escape, quote } = this.options);
+              break;
+            }
+          }
+          this.state.bomSkipped = true;
+        }
+      }
+      const bufLen = buf.length;
+      let pos;
+      for (pos = 0; pos < bufLen; pos++) {
+        if (this.__needMoreData(pos, bufLen, end)) {
+          break;
+        }
+        if (this.state.wasRowDelimiter === true) {
+          this.info.lines++;
+          this.state.wasRowDelimiter = false;
+        }
+        if (to_line !== -1 && this.info.lines > to_line) {
+          this.state.stop = true;
+          close();
+          return;
+        }
+        if (this.state.quoting === false && record_delimiter.length === 0) {
+          const record_delimiterCount = this.__autoDiscoverRecordDelimiter(buf, pos);
+          if (record_delimiterCount) {
+            record_delimiter = this.options.record_delimiter;
+          }
+        }
+        const chr = buf[pos];
+        if (raw === true) {
+          rawBuffer.append(chr);
+        }
+        if ((chr === cr || chr === nl) && this.state.wasRowDelimiter === false) {
+          this.state.wasRowDelimiter = true;
+        }
+        if (this.state.escaping === true) {
+          this.state.escaping = false;
+        } else {
+          if (escape !== null && this.state.quoting === true && this.__isEscape(buf, pos, chr) && pos + escape.length < bufLen) {
+            if (escapeIsQuote) {
+              if (this.__isQuote(buf, pos + escape.length)) {
+                this.state.escaping = true;
+                pos += escape.length - 1;
+                continue;
+              }
+            } else {
+              this.state.escaping = true;
+              pos += escape.length - 1;
+              continue;
+            }
+          }
+          if (this.state.commenting === false && this.__isQuote(buf, pos)) {
+            if (this.state.quoting === true) {
+              const nextChr = buf[pos + quote.length];
+              const isNextChrTrimable = rtrim && this.__isCharTrimable(buf, pos + quote.length);
+              const isNextChrComment = comment !== null && this.__compareBytes(comment, buf, pos + quote.length, nextChr);
+              const isNextChrDelimiter = this.__isDelimiter(buf, pos + quote.length, nextChr);
+              const isNextChrRecordDelimiter = record_delimiter.length === 0 ? this.__autoDiscoverRecordDelimiter(buf, pos + quote.length) : this.__isRecordDelimiter(nextChr, buf, pos + quote.length);
+              if (escape !== null && this.__isEscape(buf, pos, chr) && this.__isQuote(buf, pos + escape.length)) {
+                pos += escape.length - 1;
+              } else if (!nextChr || isNextChrDelimiter || isNextChrRecordDelimiter || isNextChrComment || isNextChrTrimable) {
+                this.state.quoting = false;
+                this.state.wasQuoting = true;
+                pos += quote.length - 1;
+                continue;
+              } else if (relax_quotes === false) {
+                const err = this.__error(
+                  new CsvError("CSV_INVALID_CLOSING_QUOTE", [
+                    "Invalid Closing Quote:",
+                    `got "${String.fromCharCode(nextChr)}"`,
+                    `at line ${this.info.lines}`,
+                    "instead of delimiter, record delimiter, trimable character",
+                    "(if activated) or comment"
+                  ], this.options, this.__infoField())
+                );
+                if (err !== void 0) return err;
+              } else {
+                this.state.quoting = false;
+                this.state.wasQuoting = true;
+                this.state.field.prepend(quote);
+                pos += quote.length - 1;
+              }
+            } else {
+              if (this.state.field.length !== 0) {
+                if (relax_quotes === false) {
+                  const info2 = this.__infoField();
+                  const bom2 = Object.keys(boms).map((b) => boms[b].equals(this.state.field.toString()) ? b : false).filter(Boolean)[0];
+                  const err = this.__error(
+                    new CsvError("INVALID_OPENING_QUOTE", [
+                      "Invalid Opening Quote:",
+                      `a quote is found on field ${JSON.stringify(info2.column)} at line ${info2.lines}, value is ${JSON.stringify(this.state.field.toString(encoding))}`,
+                      bom2 ? `(${bom2} bom)` : void 0
+                    ], this.options, info2, {
+                      field: this.state.field
+                    })
+                  );
+                  if (err !== void 0) return err;
+                }
+              } else {
+                this.state.quoting = true;
+                pos += quote.length - 1;
+                continue;
+              }
+            }
+          }
+          if (this.state.quoting === false) {
+            const recordDelimiterLength = this.__isRecordDelimiter(chr, buf, pos);
+            if (recordDelimiterLength !== 0) {
+              const skipCommentLine = this.state.commenting && (this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0);
+              if (skipCommentLine) {
+                this.info.comment_lines++;
+              } else {
+                if (this.state.enabled === false && this.info.lines + (this.state.wasRowDelimiter === true ? 1 : 0) >= from_line) {
+                  this.state.enabled = true;
+                  this.__resetField();
+                  this.__resetRecord();
+                  pos += recordDelimiterLength - 1;
+                  continue;
+                }
+                if (skip_empty_lines === true && this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0) {
+                  this.info.empty_lines++;
+                  pos += recordDelimiterLength - 1;
+                  continue;
+                }
+                this.info.bytes = this.state.bufBytesStart + pos;
+                const errField = this.__onField();
+                if (errField !== void 0) return errField;
+                this.info.bytes = this.state.bufBytesStart + pos + recordDelimiterLength;
+                const errRecord = this.__onRecord(push);
+                if (errRecord !== void 0) return errRecord;
+                if (to !== -1 && this.info.records >= to) {
+                  this.state.stop = true;
+                  close();
+                  return;
+                }
+              }
+              this.state.commenting = false;
+              pos += recordDelimiterLength - 1;
+              continue;
+            }
+            if (this.state.commenting) {
+              continue;
+            }
+            if (comment !== null && (comment_no_infix === false || this.state.record.length === 0 && this.state.field.length === 0)) {
+              const commentCount = this.__compareBytes(comment, buf, pos, chr);
+              if (commentCount !== 0) {
+                this.state.commenting = true;
+                continue;
+              }
+            }
+            const delimiterLength = this.__isDelimiter(buf, pos, chr);
+            if (delimiterLength !== 0) {
+              this.info.bytes = this.state.bufBytesStart + pos;
+              const errField = this.__onField();
+              if (errField !== void 0) return errField;
+              pos += delimiterLength - 1;
+              continue;
+            }
+          }
+        }
+        if (this.state.commenting === false) {
+          if (max_record_size !== 0 && this.state.record_length + this.state.field.length > max_record_size) {
+            return this.__error(
+              new CsvError("CSV_MAX_RECORD_SIZE", [
+                "Max Record Size:",
+                "record exceed the maximum number of tolerated bytes",
+                `of ${max_record_size}`,
+                `at line ${this.info.lines}`
+              ], this.options, this.__infoField())
+            );
+          }
+        }
+        const lappend = ltrim === false || this.state.quoting === true || this.state.field.length !== 0 || !this.__isCharTrimable(buf, pos);
+        const rappend = rtrim === false || this.state.wasQuoting === false;
+        if (lappend === true && rappend === true) {
+          this.state.field.append(chr);
+        } else if (rtrim === true && !this.__isCharTrimable(buf, pos)) {
+          return this.__error(
+            new CsvError("CSV_NON_TRIMABLE_CHAR_AFTER_CLOSING_QUOTE", [
+              "Invalid Closing Quote:",
+              "found non trimable byte after quote",
+              `at line ${this.info.lines}`
+            ], this.options, this.__infoField())
+          );
+        } else {
+          if (lappend === false) {
+            pos += this.__isCharTrimable(buf, pos) - 1;
+          }
+          continue;
+        }
+      }
+      if (end === true) {
+        if (this.state.quoting === true) {
+          const err = this.__error(
+            new CsvError("CSV_QUOTE_NOT_CLOSED", [
+              "Quote Not Closed:",
+              `the parsing is finished with an opening quote at line ${this.info.lines}`
+            ], this.options, this.__infoField())
+          );
+          if (err !== void 0) return err;
+        } else {
+          if (this.state.wasQuoting === true || this.state.record.length !== 0 || this.state.field.length !== 0) {
+            this.info.bytes = this.state.bufBytesStart + pos;
+            const errField = this.__onField();
+            if (errField !== void 0) return errField;
+            const errRecord = this.__onRecord(push);
+            if (errRecord !== void 0) return errRecord;
+          } else if (this.state.wasRowDelimiter === true) {
+            this.info.empty_lines++;
+          } else if (this.state.commenting === true) {
+            this.info.comment_lines++;
+          }
+        }
+      } else {
+        this.state.bufBytesStart += pos;
+        this.state.previousBuf = buf.slice(pos);
+      }
+      if (this.state.wasRowDelimiter === true) {
+        this.info.lines++;
+        this.state.wasRowDelimiter = false;
+      }
+    },
+    __onRecord: function(push) {
+      const { columns, group_columns_by_name, encoding, info: info2, from: from2, relax_column_count, relax_column_count_less, relax_column_count_more, raw, skip_records_with_empty_values } = this.options;
+      const { enabled, record } = this.state;
+      if (enabled === false) {
+        return this.__resetRecord();
+      }
+      const recordLength = record.length;
+      if (columns === true) {
+        if (skip_records_with_empty_values === true && isRecordEmpty(record)) {
+          this.__resetRecord();
+          return;
+        }
+        return this.__firstLineToColumns(record);
+      }
+      if (columns === false && this.info.records === 0) {
+        this.state.expectedRecordLength = recordLength;
+      }
+      if (recordLength !== this.state.expectedRecordLength) {
+        const err = columns === false ? new CsvError("CSV_RECORD_INCONSISTENT_FIELDS_LENGTH", [
+          "Invalid Record Length:",
+          `expect ${this.state.expectedRecordLength},`,
+          `got ${recordLength} on line ${this.info.lines}`
+        ], this.options, this.__infoField(), {
+          record
+        }) : new CsvError("CSV_RECORD_INCONSISTENT_COLUMNS", [
+          "Invalid Record Length:",
+          `columns length is ${columns.length},`,
+          // rename columns
+          `got ${recordLength} on line ${this.info.lines}`
+        ], this.options, this.__infoField(), {
+          record
+        });
+        if (relax_column_count === true || relax_column_count_less === true && recordLength < this.state.expectedRecordLength || relax_column_count_more === true && recordLength > this.state.expectedRecordLength) {
+          this.info.invalid_field_length++;
+          this.state.error = err;
+        } else {
+          const finalErr = this.__error(err);
+          if (finalErr) return finalErr;
+        }
+      }
+      if (skip_records_with_empty_values === true && isRecordEmpty(record)) {
+        this.__resetRecord();
+        return;
+      }
+      if (this.state.recordHasError === true) {
+        this.__resetRecord();
+        this.state.recordHasError = false;
+        return;
+      }
+      this.info.records++;
+      if (from2 === 1 || this.info.records >= from2) {
+        const { objname } = this.options;
+        if (columns !== false) {
+          const obj = {};
+          for (let i = 0, l = record.length; i < l; i++) {
+            if (columns[i] === void 0 || columns[i].disabled) continue;
+            if (group_columns_by_name === true && obj[columns[i].name] !== void 0) {
+              if (Array.isArray(obj[columns[i].name])) {
+                obj[columns[i].name] = obj[columns[i].name].concat(record[i]);
+              } else {
+                obj[columns[i].name] = [obj[columns[i].name], record[i]];
+              }
+            } else {
+              obj[columns[i].name] = record[i];
+            }
+          }
+          if (raw === true || info2 === true) {
+            const extRecord = Object.assign(
+              { record: obj },
+              raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {},
+              info2 === true ? { info: this.__infoRecord() } : {}
+            );
+            const err = this.__push(
+              objname === void 0 ? extRecord : [obj[objname], extRecord],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          } else {
+            const err = this.__push(
+              objname === void 0 ? obj : [obj[objname], obj],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          }
+        } else {
+          if (raw === true || info2 === true) {
+            const extRecord = Object.assign(
+              { record },
+              raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {},
+              info2 === true ? { info: this.__infoRecord() } : {}
+            );
+            const err = this.__push(
+              objname === void 0 ? extRecord : [record[objname], extRecord],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          } else {
+            const err = this.__push(
+              objname === void 0 ? record : [record[objname], record],
+              push
+            );
+            if (err) {
+              return err;
+            }
+          }
+        }
+      }
+      this.__resetRecord();
+    },
+    __firstLineToColumns: function(record) {
+      const { firstLineToHeaders } = this.state;
+      try {
+        const headers = firstLineToHeaders === void 0 ? record : firstLineToHeaders.call(null, record);
+        if (!Array.isArray(headers)) {
+          return this.__error(
+            new CsvError("CSV_INVALID_COLUMN_MAPPING", [
+              "Invalid Column Mapping:",
+              "expect an array from column function,",
+              `got ${JSON.stringify(headers)}`
+            ], this.options, this.__infoField(), {
+              headers
+            })
+          );
+        }
+        const normalizedHeaders = normalize_columns_array(headers);
+        this.state.expectedRecordLength = normalizedHeaders.length;
+        this.options.columns = normalizedHeaders;
+        this.__resetRecord();
+        return;
+      } catch (err) {
+        return err;
+      }
+    },
+    __resetRecord: function() {
+      if (this.options.raw === true) {
+        this.state.rawBuffer.reset();
+      }
+      this.state.error = void 0;
+      this.state.record = [];
+      this.state.record_length = 0;
+    },
+    __onField: function() {
+      const { cast, encoding, rtrim, max_record_size } = this.options;
+      const { enabled, wasQuoting } = this.state;
+      if (enabled === false) {
+        return this.__resetField();
+      }
+      let field = this.state.field.toString(encoding);
+      if (rtrim === true && wasQuoting === false) {
+        field = field.trimRight();
+      }
+      if (cast === true) {
+        const [err, f] = this.__cast(field);
+        if (err !== void 0) return err;
+        field = f;
+      }
+      this.state.record.push(field);
+      if (max_record_size !== 0 && typeof field === "string") {
+        this.state.record_length += field.length;
+      }
+      this.__resetField();
+    },
+    __resetField: function() {
+      this.state.field.reset();
+      this.state.wasQuoting = false;
+    },
+    __push: function(record, push) {
+      const { on_record } = this.options;
+      if (on_record !== void 0) {
+        const info2 = this.__infoRecord();
+        try {
+          record = on_record.call(null, record, info2);
+        } catch (err) {
+          return err;
+        }
+        if (record === void 0 || record === null) {
+          return;
+        }
+      }
+      push(record);
+    },
+    // Return a tuple with the error and the casted value
+    __cast: function(field) {
+      const { columns, relax_column_count } = this.options;
+      const isColumns = Array.isArray(columns);
+      if (isColumns === true && relax_column_count && this.options.columns.length <= this.state.record.length) {
+        return [void 0, void 0];
+      }
+      if (this.state.castField !== null) {
+        try {
+          const info2 = this.__infoField();
+          return [void 0, this.state.castField.call(null, field, info2)];
+        } catch (err) {
+          return [err];
+        }
+      }
+      if (this.__isFloat(field)) {
+        return [void 0, parseFloat(field)];
+      } else if (this.options.cast_date !== false) {
+        const info2 = this.__infoField();
+        return [void 0, this.options.cast_date.call(null, field, info2)];
+      }
+      return [void 0, field];
+    },
+    // Helper to test if a character is a space or a line delimiter
+    __isCharTrimable: function(buf, pos) {
+      const isTrim = (buf2, pos2) => {
+        const { timchars } = this.state;
+        loop1: for (let i = 0; i < timchars.length; i++) {
+          const timchar = timchars[i];
+          for (let j = 0; j < timchar.length; j++) {
+            if (timchar[j] !== buf2[pos2 + j]) continue loop1;
+          }
+          return timchar.length;
+        }
+        return 0;
+      };
+      return isTrim(buf, pos);
+    },
+    // Keep it in case we implement the `cast_int` option
+    // __isInt(value){
+    //   // return Number.isInteger(parseInt(value))
+    //   // return !isNaN( parseInt( obj ) );
+    //   return /^(\-|\+)?[1-9][0-9]*$/.test(value)
+    // }
+    __isFloat: function(value) {
+      return value - parseFloat(value) + 1 >= 0;
+    },
+    __compareBytes: function(sourceBuf, targetBuf, targetPos, firstByte) {
+      if (sourceBuf[0] !== firstByte) return 0;
+      const sourceLength = sourceBuf.length;
+      for (let i = 1; i < sourceLength; i++) {
+        if (sourceBuf[i] !== targetBuf[targetPos + i]) return 0;
+      }
+      return sourceLength;
+    },
+    __isDelimiter: function(buf, pos, chr) {
+      const { delimiter, ignore_last_delimiters } = this.options;
+      if (ignore_last_delimiters === true && this.state.record.length === this.options.columns.length - 1) {
+        return 0;
+      } else if (ignore_last_delimiters !== false && typeof ignore_last_delimiters === "number" && this.state.record.length === ignore_last_delimiters - 1) {
+        return 0;
+      }
+      loop1: for (let i = 0; i < delimiter.length; i++) {
+        const del = delimiter[i];
+        if (del[0] === chr) {
+          for (let j = 1; j < del.length; j++) {
+            if (del[j] !== buf[pos + j]) continue loop1;
+          }
+          return del.length;
+        }
+      }
+      return 0;
+    },
+    __isRecordDelimiter: function(chr, buf, pos) {
+      const { record_delimiter } = this.options;
+      const recordDelimiterLength = record_delimiter.length;
+      loop1: for (let i = 0; i < recordDelimiterLength; i++) {
+        const rd = record_delimiter[i];
+        const rdLength = rd.length;
+        if (rd[0] !== chr) {
+          continue;
+        }
+        for (let j = 1; j < rdLength; j++) {
+          if (rd[j] !== buf[pos + j]) {
+            continue loop1;
+          }
+        }
+        return rd.length;
+      }
+      return 0;
+    },
+    __isEscape: function(buf, pos, chr) {
+      const { escape } = this.options;
+      if (escape === null) return false;
+      const l = escape.length;
+      if (escape[0] === chr) {
+        for (let i = 0; i < l; i++) {
+          if (escape[i] !== buf[pos + i]) {
+            return false;
+          }
+        }
+        return true;
+      }
+      return false;
+    },
+    __isQuote: function(buf, pos) {
+      const { quote } = this.options;
+      if (quote === null) return false;
+      const l = quote.length;
+      for (let i = 0; i < l; i++) {
+        if (quote[i] !== buf[pos + i]) {
+          return false;
+        }
+      }
+      return true;
+    },
+    __autoDiscoverRecordDelimiter: function(buf, pos) {
+      const { encoding } = this.options;
+      const rds = [
+        // Important, the windows line ending must be before mac os 9
+        Buffer.from("\r\n", encoding),
+        Buffer.from("\n", encoding),
+        Buffer.from("\r", encoding)
+      ];
+      loop: for (let i = 0; i < rds.length; i++) {
+        const l = rds[i].length;
+        for (let j = 0; j < l; j++) {
+          if (rds[i][j] !== buf[pos + j]) {
+            continue loop;
+          }
+        }
+        this.options.record_delimiter.push(rds[i]);
+        this.state.recordDelimiterMaxLength = rds[i].length;
+        return rds[i].length;
+      }
+      return 0;
+    },
+    __error: function(msg) {
+      const { encoding, raw, skip_records_with_error } = this.options;
+      const err = typeof msg === "string" ? new Error(msg) : msg;
+      if (skip_records_with_error) {
+        this.state.recordHasError = true;
+        if (this.options.on_skip !== void 0) {
+          this.options.on_skip(err, raw ? this.state.rawBuffer.toString(encoding) : void 0);
+        }
+        return void 0;
+      } else {
+        return err;
+      }
+    },
+    __infoDataSet: function() {
+      return {
+        ...this.info,
+        columns: this.options.columns
+      };
+    },
+    __infoRecord: function() {
+      const { columns, raw, encoding } = this.options;
+      return {
+        ...this.__infoDataSet(),
+        error: this.state.error,
+        header: columns === true,
+        index: this.state.record.length,
+        raw: raw ? this.state.rawBuffer.toString(encoding) : void 0
+      };
+    },
+    __infoField: function() {
+      const { columns } = this.options;
+      const isColumns = Array.isArray(columns);
+      return {
+        ...this.__infoRecord(),
+        column: isColumns === true ? columns.length > this.state.record.length ? columns[this.state.record.length].name : null : this.state.record.length,
+        quoting: this.state.wasQuoting
+      };
+    }
+  };
+};
+var Parser = class extends Transform {
+  constructor(opts = {}) {
+    super({ ...{ readableObjectMode: true }, ...opts, encoding: null });
+    this.api = transform({ on_skip: (err, chunk) => {
+      this.emit("skip", err, chunk);
+    }, ...opts });
+    this.state = this.api.state;
+    this.options = this.api.options;
+    this.info = this.api.info;
+  }
+  // Implementation of `Transform._transform`
+  _transform(buf, _, callback) {
+    if (this.state.stop === true) {
+      return;
+    }
+    const err = this.api.parse(buf, false, (record) => {
+      this.push(record);
+    }, () => {
+      this.push(null);
+      this.end();
+      this.on("end", this.destroy);
+    });
+    if (err !== void 0) {
+      this.state.stop = true;
+    }
+    callback(err);
+  }
+  // Implementation of `Transform._flush`
+  _flush(callback) {
+    if (this.state.stop === true) {
+      return;
+    }
+    const err = this.api.parse(void 0, true, (record) => {
+      this.push(record);
+    }, () => {
+      this.push(null);
+      this.on("end", this.destroy);
+    });
+    callback(err);
+  }
+};
+var parse = function() {
+  let data, options, callback;
+  for (const i in arguments) {
+    const argument = arguments[i];
+    const type = typeof argument;
+    if (data === void 0 && (typeof argument === "string" || isBuffer(argument))) {
+      data = argument;
+    } else if (options === void 0 && is_object(argument)) {
+      options = argument;
+    } else if (callback === void 0 && type === "function") {
+      callback = argument;
+    } else {
+      throw new CsvError("CSV_INVALID_ARGUMENT", [
+        "Invalid argument:",
+        `got ${JSON.stringify(argument)} at index ${i}`
+      ], options || {});
+    }
+  }
+  const parser = new Parser(options);
+  if (callback) {
+    const records = options === void 0 || options.objname === void 0 ? [] : {};
+    parser.on("readable", function() {
+      let record;
+      while ((record = this.read()) !== null) {
+        if (options === void 0 || options.objname === void 0) {
+          records.push(record);
+        } else {
+          records[record[0]] = record[1];
+        }
+      }
+    });
+    parser.on("error", function(err) {
+      callback(err, void 0, parser.api.__infoDataSet());
+    });
+    parser.on("end", function() {
+      callback(void 0, records, parser.api.__infoDataSet());
+    });
+  }
+  if (data !== void 0) {
+    const writer = function() {
+      parser.write(data);
+      parser.end();
+    };
+    if (typeof setImmediate === "function") {
+      setImmediate(writer);
+    } else {
+      setTimeout(writer, 0);
+    }
+  }
+  return parser;
+};
+
+// src/utils.ts
+var PSYCHDS_IGNORE_FILENAME = ".psychds-ignore";
+var PSYCHDS_IGNORE_CONTENT = "**/raw/\n.psychds-ignore\n";
+function saveTextToFile(textstr, filename) {
+  const blobToSave = new Blob([textstr], {
+    type: "text/plain"
+  });
+  let blobURL = "";
+  if (typeof window.webkitURL !== "undefined") {
+    blobURL = window.webkitURL.createObjectURL(blobToSave);
+  } else {
+    blobURL = window.URL.createObjectURL(blobToSave);
+  }
+  const link = document.createElement("a");
+  link.id = "jspsych-download-as-text-link";
+  link.style.display = "none";
+  link.download = filename;
+  link.href = blobURL;
+  link.click();
+}
+function tryParseJSON(value) {
+  try {
+    return JSON.parse(value);
+  } catch {
+    return null;
+  }
+}
+function unwrapTrials(data) {
+  const parsed = typeof data === "string" ? JSON.parse(data) : data;
+  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
+    const keys2 = Object.keys(parsed);
+    if (keys2.length === 1 && keys2[0] === "trials" && Array.isArray(parsed.trials)) {
+      return parsed.trials;
+    }
+  }
+  return parsed;
+}
+function parseJsonData(content, options = {}, stats) {
+  if (content.charCodeAt(0) === 65279) content = content.slice(1);
+  const whole = tryParseJSON(content);
+  if (whole !== null) return unwrapTrials(whole);
+  const lines = content.split(/\r?\n/);
+  const out = [];
+  let parsedAny = false;
+  let recordIndex = 0;
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim();
+    if (!line) continue;
+    let value;
+    try {
+      value = JSON.parse(line);
+    } catch {
+      throw new Error(
+        `Could not parse data as JSON or JSON-Lines: line ${i + 1} is not valid JSON.`
+      );
+    }
+    parsedAny = true;
+    const observations = Array.isArray(value) ? value : [value];
+    if (options.tagSourceRecordId) {
+      for (const obs of observations) {
+        if (obs !== null && typeof obs === "object" && !Array.isArray(obs) && !("source_record_id" in obs) && !("participant_id" in obs)) {
+          obs.source_record_id = recordIndex;
+          if (stats) stats.synthesizedSourceRecordId = true;
+        }
+      }
+    }
+    out.push(...observations);
+    recordIndex++;
+  }
+  if (!parsedAny) {
+    throw new Error("Could not parse data: input is empty or not valid JSON/JSON-Lines.");
+  }
+  return out;
+}
+var SYSTEM_COLUMNS = /* @__PURE__ */ new Set([
+  "trial_type",
+  "trial_index",
+  "time_elapsed",
+  "extension_type",
+  "extension_version"
+]);
+function analyzeJoinKeys(parsedData, keys2) {
+  if (parsedData.length === 0) {
+    return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+  }
+  const compositeKeys = parsedData.map(
+    (row) => keys2.map((k) => String(row[k] ?? "")).join("\0")
+  );
+  const keyCount = /* @__PURE__ */ new Map();
+  for (const ck of compositeKeys) keyCount.set(ck, (keyCount.get(ck) ?? 0) + 1);
+  const duplicateCount = [...keyCount.values()].reduce((n, c) => n + (c > 1 ? c - 1 : 0), 0);
+  const isUnique = duplicateCount === 0;
+  const duplicateValues = [];
+  for (let i = 0; i < parsedData.length && duplicateValues.length < 5; i++) {
+    if ((keyCount.get(compositeKeys[i]) ?? 0) > 1) {
+      const vals = keys2.reduce((acc, k) => {
+        acc[k] = parsedData[i][k];
+        return acc;
+      }, {});
+      if (!duplicateValues.some((v) => JSON.stringify(v) === JSON.stringify(vals))) {
+        duplicateValues.push(vals);
+      }
+    }
+  }
+  if (isUnique) {
+    return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+  }
+  const keySet = new Set(keys2);
+  const allColumns = /* @__PURE__ */ new Set();
+  for (const row of parsedData) for (const col of Object.keys(row)) allColumns.add(col);
+  const candidateColumns = [...allColumns].filter(
+    (col) => !isUnnamedHeader(col) && !keySet.has(col) && !SYSTEM_COLUMNS.has(col)
+  );
+  const candidates = candidateColumns.map((col) => {
+    const extended = parsedData.map(
+      (row) => [...keys2, col].map((k) => String(row[k] ?? "")).join("\0")
+    );
+    return { column: col, makesUnique: new Set(extended).size === parsedData.length };
+  });
+  if (candidates.some((c) => c.makesUnique)) {
+    return { isUnique, duplicateCount, duplicateValues, candidates, suggestedAdditionalKeys: [] };
+  }
+  const workingKeys = [...keys2];
+  const available = [...candidateColumns];
+  while (available.length > 0) {
+    const current = parsedData.map(
+      (row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0")
+    );
+    if (new Set(current).size === parsedData.length) break;
+    let bestCol = null;
+    let bestCount = new Set(current).size;
+    for (const col of available) {
+      const test = parsedData.map(
+        (row) => [...workingKeys, col].map((k) => String(row[k] ?? "")).join("\0")
+      );
+      const count = new Set(test).size;
+      if (count > bestCount) {
+        bestCount = count;
+        bestCol = col;
+      }
+    }
+    if (bestCol === null) break;
+    workingKeys.push(bestCol);
+    available.splice(available.indexOf(bestCol), 1);
+  }
+  const added = workingKeys.slice(keys2.length);
+  const greedyIsUnique = new Set(
+    parsedData.map((row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0"))
+  ).size === parsedData.length;
+  return {
+    isUnique,
+    duplicateCount,
+    duplicateValues,
+    candidates,
+    suggestedAdditionalKeys: added.length > 0 && greedyIsUnique ? added : null
+  };
+}
+var PSYCH_DS_FILENAME_RE = /^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\.(csv|tsv)$/;
+function isValidPsychDSDataFilename(name) {
+  return PSYCH_DS_FILENAME_RE.test(name);
+}
+function toPsychDSValue(name, fallback = "value") {
+  const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
+  if (parts.length === 0) return fallback;
+  return parts[0] + parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
+}
+function deriveFallbackBase(stem) {
+  return `subject-${toPsychDSValue(stem, "file")}`;
+}
+function deriveArrayFilename(parentBase, columnName) {
+  return `${parentBase}_measure-${toPsychDSValue(columnName, "col")}_data.csv`;
+}
+function objectsToCSV(rows, priorityCols = ["trial_index", "element_index"]) {
+  if (rows.length === 0) return "";
+  const allKeys = /* @__PURE__ */ new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row)) allKeys.add(key);
+  }
+  const otherCols = [...allKeys].filter((k) => !priorityCols.includes(k));
+  const headers = [...priorityCols.filter((c) => allKeys.has(c)), ...otherCols];
+  const escape = (val) => {
+    if (val === null || val === void 0) return "";
+    const str = typeof val === "object" ? JSON.stringify(val) : String(val);
+    return str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r") ? `"${str.replace(/"/g, '""')}"` : str;
+  };
+  const lines = [headers.join(",")];
+  for (const row of rows) {
+    lines.push(headers.map((h) => escape(row[h])).join(","));
+  }
+  return lines.join("\r\n");
+}
+function disambiguateArrayFilename(base, used) {
+  if (!used.has(base)) return base;
+  const suffix = "_data.csv";
+  const root = base.endsWith(suffix) ? base.slice(0, -suffix.length) : base.replace(/\.csv$/i, "");
+  let n = 2;
+  let candidate = `${root}${n}${suffix}`;
+  while (used.has(candidate)) {
+    n += 1;
+    candidate = `${root}${n}${suffix}`;
+  }
+  return candidate;
+}
+var isUnnamedHeader = (key) => key.trim() === "";
+function hasUnnamedColumns(rows) {
+  return rows.some((row) => Object.keys(row).some(isUnnamedHeader));
+}
+function stripUnnamedColumns(rows) {
+  const unnamed = /* @__PURE__ */ new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row)) {
+      if (isUnnamedHeader(key)) unnamed.add(key);
+    }
+  }
+  if (unnamed.size > 0) {
+    for (const row of rows) {
+      for (const key of unnamed) delete row[key];
+    }
+  }
+  return { rows, dropped: [...unnamed] };
+}
+function buildPsychDSDataFiles(args) {
+  const {
+    base,
+    mainRows,
+    mainContent,
+    extractedArrays = /* @__PURE__ */ new Map(),
+    extractedObjects = /* @__PURE__ */ new Map(),
+    joinKeys = ["trial_index"],
+    usedArrayFilenames = /* @__PURE__ */ new Set()
+  } = args;
+  const out = [];
+  const reserve = (name) => {
+    if (!isValidPsychDSDataFilename(name)) {
+      throw new Error(`Refusing to write non-Psych-DS-compliant data filename "${name}".`);
+    }
+    usedArrayFilenames.add(name);
+    return name;
+  };
+  const mainName = reserve(disambiguateArrayFilename(`${base}_data.csv`, usedArrayFilenames));
+  const { rows: cleanedMainRows, dropped: droppedMain } = stripUnnamedColumns(mainRows);
+  out.push({
+    filename: mainName,
+    content: mainContent !== void 0 && droppedMain.length === 0 ? mainContent : objectsToCSV(cleanedMainRows, ["trial_index"]),
+    kind: "main"
+  });
+  const arrayPriority = [...joinKeys, "element_index"];
+  for (const [colName, rows] of extractedArrays) {
+    const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+    out.push({ filename: name, content: objectsToCSV(rows, arrayPriority), kind: "array" });
+  }
+  for (const [colName, rows] of extractedObjects) {
+    const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+    out.push({ filename: name, content: objectsToCSV(rows, joinKeys), kind: "object" });
+  }
+  return out;
+}
+async function parseCSV(input) {
+  if (!parse) {
+    throw new Error("Parser module not loaded");
+  }
+  return new Promise((resolve, reject) => {
+    parse(input, {
+      columns: true,
+      // Treat the first row as headers
+      delimiter: ",",
+      // Specify the delimiter (e.g., comma)
+      bom: true
+      // Strip a leading UTF-8 BOM so the first header name isn't corrupted (e.g. "Participant_ID")
+    }, (err, records) => {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(records);
+      }
+    });
+  });
+}
+
+// src/VariablesMap.ts
+var VariablesMap = class _VariablesMap {
+  /**
+   *  Creates the VariablesMap by initialising an empty variable map. The jsPsych system
+   * variables (trial_type, trial_index, time_elapsed, extension_*) are NOT seeded here — they
+   * are registered lazily when their column is actually observed in the data (see
+   * {@link registerSystemVariable}). Seeding them unconditionally produced orphan
+   * variableMeasured entries (e.g. time_elapsed) for datasets that omit those columns, which
+   * fails Psych-DS validation (VARIABLE_MISSING_FROM_CSV_COLUMNS).
+   *
+   * @constructor
+   */
+  constructor() {
+    this.generateDefaultVariables();
+  }
+  /**
+   * The fixed jsPsych definition for a system column, or null if `name` is not a known system
+   * variable. Returns a fresh object on each call so callers never share/mutate one template.
+   */
+  static systemVariableTemplate(name) {
+    switch (name) {
+      case "trial_type":
+        return {
+          "@type": "PropertyValue",
+          name: "trial_type",
+          description: { default: "unknown", jsPsych: "The name of the plugin used to run the trial." },
+          value: "string"
+        };
+      case "trial_index":
+        return {
+          "@type": "PropertyValue",
+          name: "trial_index",
+          description: { default: "unknown", jsPsych: "The index of the current trial across the whole experiment." },
+          value: "number"
+        };
+      case "time_elapsed":
+        return {
+          "@type": "PropertyValue",
+          name: "time_elapsed",
+          description: {
+            default: "unknown",
+            jsPsych: "The number of milliseconds between the start of the experiment and when the trial ended."
+          },
+          value: "number"
+        };
+      case "extension_type":
+        return {
+          "@type": "PropertyValue",
+          name: "extension_type",
+          description: { default: "unknown", jsPsych: "The name(s) of the extension(s) used in the trial." },
+          value: "string"
+        };
+      case "extension_version":
+        return {
+          "@type": "PropertyValue",
+          name: "extension_version",
+          description: { default: "unknown", jsPsych: "The version(s) of the extension(s) used in the trial." },
+          value: "number"
+        };
+      default:
+        return null;
+    }
+  }
+  /**
+   * Lazily registers the default jsPsych definition for a system column the first time it is
+   * observed in the data. No-op (returns false) when `name` is not a known system variable or
+   * is already present; returns true when a new variable was registered. This is what keeps a
+   * system variable out of variableMeasured unless the data actually contains that column.
+   *
+   * @param {string} name - The column / system-variable name.
+   * @returns {boolean} - True if a variable was registered, false otherwise.
+   */
+  registerSystemVariable(name) {
+    if (this.containsVariable(name)) return false;
+    const template = _VariablesMap.systemVariableTemplate(name);
+    if (!template) return false;
+    this.setVariable(template);
+    return true;
+  }
+  /**
+   * Initialises the variable map. System variables are registered lazily (see the constructor
+   * and {@link registerSystemVariable}), so this just resets the map to empty.
+   */
+  generateDefaultVariables() {
+    this.variables = {};
+  }
+  /**
+   * Returns a list of the variables instead of an object according to the Psych-DS format.
+   *
+   * @returns {{}[]} - The list of variables represented as objects.
+   */
+  getList() {
+    var var_list = [];
+    for (const key of Object.keys(this.variables)) {
+      const variable = this.variables[key];
+      variable["description"] = this.collapseDescription(variable["description"]);
+      var_list.push(variable);
+    }
+    return var_list;
+  }
+  /**
+   * Collapses an internal { pluginType: description } map into a single schema.org-valid
+   * Text value. Descriptions are stored per-plugin and only ever hold multiple keys when the
+   * texts genuinely differ (identical texts are merged upstream in updateDescription). Psych-DS /
+   * schema.org require `description` to be Text, so an object value triggers an OBJECT_TYPE_MISSING
+   * validator warning — this folds everything down to a string.
+   *
+   * @private
+   * @param {*} description - The description value (a { pluginType: text } map, or already a string).
+   * @returns {string} - A single Text description.
+   */
+  collapseDescription(description) {
+    if (typeof description !== "object" || description === null) {
+      return description;
+    }
+    if (Object.keys(description).length === 0) {
+      console.error("Empty description");
+      return "unknown";
+    }
+    if (Object.keys(description).length > 1 && "default" in description) {
+      delete description["default"];
+    }
+    for (const descKey of Object.keys(description)) {
+      if (description[descKey] === "unknown" && Object.keys(description).length > 1) {
+        delete description[descKey];
+      }
+    }
+    return Object.values(description).join(" | ");
+  }
+  /**
+   * Allows user to set a variable and includes all the fields that are possible according to
+   * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable.
+   * Can also be used to overwrite existing variables if they have the same name.
+   *
+   * @param {VariableFields} variable - The fields of the variable that is being created.
+   */
+  setVariable(variable) {
+    if (!variable.name) {
+      console.warn("Name field is missing. Variable not added.", variable);
+      return;
+    }
+    this.variables[variable.name] = variable;
+    const unexpectedFields = Object.keys(variable).filter(
+      (key) => ![
+        "@type",
+        "name",
+        "description",
+        "value",
+        "identifier",
+        "minValue",
+        "maxValue",
+        "levels",
+        "levelsOrdered",
+        "na",
+        "naValue",
+        "alternateName",
+        "privacy"
+      ].includes(key)
+    );
+    if (unexpectedFields.length > 0) {
+      console.warn(
+        `Unexpected fields (${unexpectedFields.join(
+          ", "
+        )}) detected and included in the variable object.`
+      );
+    }
+  }
+  /**
+   * Allows you to get information for a single variable returning empty dict if it doesn't exist.
+   * Allows you to update fields but not recommended in favor of updateVariable.
+   *
+   * @param {string} name
+   * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist
+   */
+  getVariable(name) {
+    return this.variables[name] || {};
+  }
+  /**
+   * Checks if variable exists in VariablesMap.
+   *
+   * @param {string} name - Name of variable
+   * @returns {boolean} - True if exists, false if doesn't.
+   */
+  containsVariable(name) {
+    return name in this.variables;
+  }
+  /**
+   * Method that gets a list of the names of variables.
+   *
+   * @returns {string[]} - String list containing names of existing variables.
+   */
+  getVariableNames() {
+    var var_list = [];
+    for (const key of Object.keys(this.variables)) {
+      var_list.push(this.variables[key]["name"]);
+    }
+    return var_list;
+  }
+  /**
+   * Allows you to update a variable or add a value in the case of updating values. In other situations will
+   * replace the existing value with the new value. Has special cases and logic for levels and names making it
+   * easier to update variable values.
+   *
+   *
+   * @param {string} var_name - Name of variable to be updated.
+   * @param {string} field_name - Specific field to be updated.
+   * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType.
+   */
+  updateVariable(var_name, field_name, added_value) {
+    const updated_var = this.getVariable(var_name);
+    if (Object.keys(updated_var).length === 0) {
+      console.error(`Variable "${var_name}" does not exist.`);
+      return;
+    }
+    if (field_name === "levels") {
+      this.updateLevels(updated_var, added_value);
+    } else if (field_name === "minValue" || field_name === "maxValue") {
+      this.updateMinMax(updated_var, added_value, field_name);
+    } else if (field_name === "description") {
+      this.updateDescription(updated_var, added_value);
+    } else if (field_name === "name") {
+      this.updateName(updated_var, added_value);
+    } else {
+      updated_var[field_name] = added_value;
+    }
+  }
+  /**
+   * Logic that handles updates to levels field by creating new array if necessary, otherwise
+   * pushing the value if it doesn't already exist. Levels can only be added to with strings.
+   *
+   * @private
+   * @param {*} updated_var - The variable object to be updated.
+   * @param {*} added_value - The value being added to the levels field.
+   */
+  updateLevels(updated_var, added_value) {
+    if (typeof added_value === "object")
+      return;
+    const MAX_LENGTH = 50;
+    if (added_value.length > MAX_LENGTH) {
+      added_value = added_value.substring(0, MAX_LENGTH) + "...";
+    }
+    if (!Array.isArray(updated_var["levels"])) {
+      updated_var["levels"] = [];
+    }
+    if (!updated_var["levels"].includes(added_value)) {
+      updated_var["levels"].push(added_value);
+    }
+  }
+  /**
+   * Logic to update the min and max for the specific value.
+   *
+   * @private
+   * @param {*} updated_var - The variable object to be updated.
+   * @param {*} added_value - The value that is being checked against current min/max.
+   * @param {*} field_name - The name of field that is being checked (min or max).
+   */
+  updateMinMax(updated_var, added_value, field_name) {
+    if (!("minValue" in updated_var) || !("maxValue" in updated_var)) {
+      updated_var["maxValue"] = updated_var["minValue"] = added_value;
+      return;
+    }
+    if (field_name === "minValue" && updated_var["minValue"] > added_value) {
+      updated_var["minValue"] = added_value;
+    } else if (field_name === "maxValue" && updated_var["maxValue"] < added_value) {
+      updated_var["maxValue"] = added_value;
+    }
+  }
+  /**
+   * Logic for updating description field that checks to see value already exists. If it does,
+   * appends the pluginType to the current key and pushes that along with the value. Creates
+   * map if it does not exist.
+   *
+   * @private
+   * @param {*} updated_var - The variable to be updated.
+   * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field.
+   */
+  updateDescription(updated_var, added_value) {
+    const add_key = Object.keys(added_value)[0];
+    const add_value = Object.values(added_value)[0];
+    if (add_key === "undefined" || add_value === "undefined") {
+      console.error("New value is passed in bad format", added_value);
+      return;
+    }
+    var exists = false;
+    if (typeof updated_var["description"] !== "object") {
+      const existing = updated_var["description"];
+      updated_var["description"] = typeof existing === "string" && existing && existing !== "unknown" ? { default: existing } : {};
+    }
+    Object.entries(updated_var["description"]).forEach(([key, value]) => {
+      if (value === add_value) {
+        if (!key.includes(add_key)) {
+          delete updated_var["description"][key];
+          updated_var["description"][key + ", " + add_key] = add_value;
+        }
+        exists = true;
+      }
+    });
+    if (!exists) Object.assign(updated_var["description"], added_value);
+  }
+  /**
+   * Logic for updating name. Needs to retain all the old values while creating a new reference in the map
+   * while keeping the same perspe
+   *
+   * @private
+   * @param {*} updated_var
+   * @param {*} added_value
+   */
+  updateName(updated_var, added_value) {
+    const old_name = updated_var["name"];
+    updated_var["name"] = added_value;
+    delete this.variables[old_name];
+    this.setVariable(updated_var);
+  }
+  /**
+   * Allows you to delete a variable by key/name. Returns console error if not found.
+   *
+   * @param {string} var_name - Name of variable to be deleted.
+   */
+  deleteVariable(var_name) {
+    if (var_name in this.variables) {
+      delete this.variables[var_name];
+    } else {
+      console.error(`Variable "${var_name}" does not exist.`);
+    }
+  }
+};
+
+// src/index.ts
+var JsPsychMetadata = class {
+  /**
+   * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context
+   *  allowing it to access the screen printing information.
+   *
+   * @constructor
+   * @param {JsPsych} JsPsych
+   */
+  constructor(verbose) {
+    /**
+     * Initializes a set that contains the variable fields that are to be ignored, so can help with later 
+     * logic when generating data.
+     *
+     * @private
+     * @type {*}
+     */
+    this.ignored_variables = new Set(SYSTEM_COLUMNS);
+    /**
+     * Verbose mode that is used in by the tools that call this to print fetching messages and 
+     * reading messages.
+     *
+     * @private
+     * @type {boolean}
+     */
+    this.verbose = false;
+    this.extractedArrays = /* @__PURE__ */ new Map();
+    // Plain (non-array) object columns expanded by expandObjectFields. One row per trial,
+    // keyed by the same arrayJoinKeys as extractedArrays, with a column for every dotted
+    // descendant variable (leaf scalars, intermediate object nodes, and nested-array parents).
+    // The CLI writes these as separate Psych-DS CSVs so those dotted names map to real columns.
+    this.extractedObjects = /* @__PURE__ */ new Map();
+    this.arrayJoinKeys = ["trial_index"];
+    this.mixedColumns = /* @__PURE__ */ new Set();
+    this.metadata = {};
+    this.setMetadataField("name", "title");
+    this.setMetadataField("schemaVersion", "Psych-DS 0.4.0");
+    this.setMetadataField("@context", "https://schema.org");
+    this.setMetadataField("@type", "Dataset");
+    this.setMetadataField("description", "Dataset generated using JsPsych");
+    this.authors = new AuthorsMap();
+    this.variables = new VariablesMap();
+    this.pluginCache = new PluginCache();
+    this.verbose = verbose;
+  }
+  /**
+   * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields.
+   *
+   * @param {string} key - Metadata field name
+   * @param {*} value - Data associated with the field
+   */
+  setMetadataField(key, value) {
+    this.metadata[key] = value;
+  }
+  /**
+   * Simple get that accesses the data associated with a field.
+   *
+   * @param {string} key - Field name
+   * @returns {*} - Data associated with the field
+   */
+  getMetadataField(key) {
+    return this.metadata[key];
+  }
+  /**
+   * Checks if the metadata field exists in the metadata.
+   *
+   * @param {string} key - Key of metadata being checked.
+   * @returns {*} - Boolean
+   */
+  containsMetadataField(key) {
+    return key in this.metadata;
+  }
+  /**
+   * Deletes a metadata from the metadata if it exists. 
+   *
+   * @param {string} key - Name of field to be deleted
+   */
+  deleteMetadataField(key) {
+    if (key in this.metadata) {
+      delete this.metadata[key];
+    } else {
+      console.error(`Metadata "${key}" does not exist.`);
+    }
+  }
+  /**
+   * Returns the final Metadata in a single javascript object. Bundles together the author and variables
+   * together in a list rather than object compliant with Psych-DS standards. Seems that javascript get
+   * are implictly called.
+   *
+   * @returns {{}} - Final Metadata object
+   */
+  getMetadata() {
+    const res = this.metadata;
+    res["author"] = this.authors.getList();
+    res["variableMeasured"] = this.variables.getList();
+    return res;
+  }
+  getUserMetadataFields() {
+    const res = {};
+    const ignored_fields = /* @__PURE__ */ new Set(["schemaVersion", "@type", "@context", "author", "variableMeasured"]);
+    for (const key in this.metadata) {
+      if (!ignored_fields.has(key)) {
+        res[key] = this.metadata[key];
+      }
+    }
+    return res;
+  }
+  /**
+   * Returns the variable fields while excluding the authors and variables.`
+   *
+   * @returns {{}} - Final Metadata object
+   */
+  getMetadataFields() {
+    const res = this.metadata;
+    delete res["author"];
+    delete res["variableMeasured"];
+    return res;
+  }
+  /**
+   * Method that creates an author. This method can also be used to overwrite existing authors
+   * with the same name in order to update fields.
+   *
+   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+   */
+  setAuthor(fields) {
+    this.authors.setAuthor(fields);
+  }
+  /**
+   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+   *
+   * @param {string} name - Name of author to be used as key.
+   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+   */
+  getAuthor(name) {
+    return this.authors.getAuthor(name);
+  }
+  /**
+   * Returns a list of the authors defined in the metadata.
+   *
+   * @returns {(string | AuthorFields)[]} - Authors
+   */
+  getAuthorList() {
+    return this.authors.getList();
+  }
+  /**
+   * Deletes an author from the authorsField.
+   *
+   * @param {string} name - Name of author to be deleted.
+   */
+  deleteAuthor(name) {
+    this.authors.deleteAuthor(name);
+  }
+  /**
+   * Method that creates a variable. This method can also be used to overwrite variables with the same name
+   * as a way to update fields.
+   *
+   * @param {{
+   *     @type?: string;
+   *     name: string; // required
+   *     description?: string | {};
+   *     value?: string; // string, boolean, or number
+   *     identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
+   *     minValue?: number;
+   *     maxValue?: number;
+   *     levels?: string[] | []; // technically property values in the other one but not sure how to format it
+   *     levelsOrdered?: boolean;
+   *     na?: boolean;
+   *     naValue?: string;
+   *     alternateName?: string;
+   *     privacy?: string;
+   *   }} fields - Fields associated with the current Psych-DS standard.
+   */
+  setVariable(variable) {
+    this.variables.setVariable(variable);
+  }
+  /**
+   * Allows you to access a variable's information by using the name of the variable. Can
+   * be used to update fields within a variable, but suggest using updateVariable() to prevent errors.
+   *
+   * @param {string} name - Name of variable to be accessed
+   * @returns {{}} - Returns object of fields
+   */
+  getVariable(name) {
+    return this.variables.getVariable(name);
+  }
+  /**
+   * Returns a list of the variables defined in the metadata.
+   *
+   * @returns {{}[]} - Authors
+   */
+  getVariableList() {
+    return this.variables.getList();
+  }
+  /**
+   * Allows you to check if the name of the variable exists in variablesMap.
+   *
+   * @param {string} name - Name of variable
+   * @returns {boolean} - Does variable exist in variables
+   */
+  containsVariable(name) {
+    return this.variables.containsVariable(name);
+  }
+  /**
+   * Allows you to update a variable or add a value in the case of updating values. In other situations will
+   * replace the existing value with the new value.
+   *
+   * @param {string} var_name - Name of variable to be updated.
+   * @param {string} field_name - Name of field to be updated.
+   * @param {(string | boolean | number | {})} added_value - Value to be used in the update.
+   */
+  updateVariable(var_name, field_name, added_value) {
+    this.variables.updateVariable(var_name, field_name, added_value);
+  }
+  /**
+   * Allows you to delete a variable by key/name.
+   *
+   * @param {string} var_name - Name of variable to be deleted.
+   */
+  deleteVariable(var_name) {
+    this.variables.deleteVariable(var_name);
+  }
+  /**
+   * Gets a list of all the variable names.
+   *
+   * @returns {string[]} - List of variable string names.
+   */
+  getVariableNames() {
+    return this.variables.getVariableNames();
+  }
+  /**
+   * Returns accumulated array-column data keyed by column name.
+   * Each entry is a list of rows with join key columns, element_index, and the element's own fields.
+   * Used by the CLI to write Psych-DS compliant separate CSV files.
+   */
+  getExtractedArrays() {
+    return this.extractedArrays;
+  }
+  /**
+   * Returns accumulated plain-object-column data keyed by the top-level column name.
+   * Each entry is one row per trial: the join key columns plus a column for every dotted
+   * descendant variable expanded from that object (matching the names in variableMeasured).
+   * Used by the CLI to write a separate Psych-DS CSV per object column, so those dotted
+   * sub-variables resolve to real columns. No element_index (one row per trial, not per element).
+   */
+  getExtractedObjects() {
+    return this.extractedObjects;
+  }
+  /**
+   * Returns the join key columns used in the most recent generate() call.
+   * The CLI uses this to order columns correctly in extracted array CSVs.
+   */
+  getArrayJoinKeys() {
+    return [...this.arrayJoinKeys];
+  }
+  warnJoinKeyUniqueness(analysis) {
+    const keyStr = this.arrayJoinKeys.join(", ");
+    const exampleStr = analysis.duplicateValues.slice(0, 3).map((v) => Object.entries(v).map(([k, val]) => `${k}=${val}`).join(", ")).join("; ");
+    let msg = `[jspsych-metadata] Join key (${keyStr}) is not unique in this dataset
+  (${analysis.duplicateCount} duplicate rows; e.g. ${exampleStr})
+`;
+    if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length === 0) {
+      const sufficient = analysis.candidates.filter((c) => c.makesUnique).map((c) => c.column);
+      const example = JSON.stringify([sufficient[0], ...this.arrayJoinKeys]);
+      msg += `  Sufficient fix: add one of these columns to arrayJoinKeys:
+    ${sufficient.join(", ")}
+  Pass { arrayJoinKeys: ${example} } as the options argument to generate().`;
+    } else if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length > 0) {
+      const combined = JSON.stringify([...analysis.suggestedAdditionalKeys, ...this.arrayJoinKeys]);
+      msg += `  No single column makes rows unique. Suggested combination:
+    ${analysis.suggestedAdditionalKeys.join(" + ")}
+  Pass { arrayJoinKeys: ${combined} } as the options argument to generate().`;
+    } else {
+      msg += `  No combination of available columns was found to make rows unique.
+  Your data may contain genuinely duplicate rows.
+  Extracted array CSVs will have non-unique join keys.`;
+    }
+    console.warn(msg);
+  }
+  /**
+   * Method that allows you to display metadata at the end of an experiment.
+   *
+   * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling.
+   */
+  displayMetadata(display_element) {
+    const elementId = "jspsych-metadata-display";
+    const metadata_string = JSON.stringify(this.getMetadata(), null, 2);
+    display_element.innerHTML += `

Metadata

`;
+    document.getElementById(elementId).textContent += metadata_string;
+  }
+  /**
+   * Method that begins a download for the dataset_description.json at the end of experiment.
+   * Allows you to download the metadat.
+   */
+  localSave() {
+    let data_string = JSON.stringify(this.getMetadata());
+    saveTextToFile(data_string, "dataset_description.json");
+  }
+  /**
+   * This method loads the metadata into the metadata object. This takes in the"dataset_description.json" string content 
+   * and first parses it as an object. This then loads in all the fields, authors and variables into the metadata object by calling all the 
+   * relevant methods that overwrites the default data.
+   *
+   * @param {string} stringMetadata - String version of the metadata to be loaded from "dataset_description.json".
+   */
+  loadMetadata(stringMetadata) {
+    const meta = JSON.parse(stringMetadata);
+    for (const field_key in meta) {
+      if (field_key === "variableMeasured") {
+        for (const variable of meta[field_key]) {
+          this.setVariable(variable);
+        }
+      } else if (field_key === "author") {
+        for (const author of meta[field_key]) {
+          this.setAuthor(author);
+        }
+      } else {
+        this.setMetadataField(field_key, meta[field_key]);
+      }
+    }
+  }
+  /**
+   * Generates observations based on the input data and processes optional metadata. This is the
+   * outer wrapper function that should called and handles the logic of reading individual observations.
+   *
+   * This method accepts data as a JSON string, a CSV string, or an already-parsed array of
+   * observation objects. A string is parsed according to `ext`; an array is consumed as-is.
+   * Each observation is processed asynchronously via `generateObservation`. Optionally, metadata
+   * options can be provided as an object, and each key-value pair is processed by `processMetadata`.
+   *
+   * NOTE: when `data` is a pre-parsed array it is consumed in place and MUTATED — unnamed
+   * (blank-header) columns are deleted from the row objects. Callers that need the rows to stay
+   * pristine must pass a copy. This lets a caller parse a file once and share the rows with
+   * generate() instead of having generate() re-parse the same content.
+   *
+   * @async
+   * @param {Array|String} data - Observations to generate from: a pre-parsed array (consumed as-is and mutated in place), a JSON string, or a CSV string.
+   * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
+   * @param {'json'|'csv'} [ext='json'] - Format of a string `data`; ignored when `data` is already an array.
+   * @param {Object} [options={}] - arrayJoinKeys / suppressJoinKeyWarning, plus synthesizedSourceRecordId for pre-parsed callers that tagged a synthetic source_record_id themselves.
+   */
+  async generate(data, metadata = {}, ext = "json", options = {}) {
+    this.extractedArrays = /* @__PURE__ */ new Map();
+    this.extractedObjects = /* @__PURE__ */ new Map();
+    this.arrayJoinKeys = options.arrayJoinKeys ?? ["trial_index"];
+    var parsed_data;
+    let synthesizedSourceRecordId = options.synthesizedSourceRecordId ?? false;
+    if (Array.isArray(data)) {
+      parsed_data = data;
+    } else if (ext === "csv") {
+      parsed_data = await parseCSV(data);
+    } else if (ext === "json") {
+      const parseStats = {};
+      parsed_data = parseJsonData(data, { tagSourceRecordId: true }, parseStats);
+      synthesizedSourceRecordId = parseStats.synthesizedSourceRecordId === true;
+    }
+    if (!Array.isArray(parsed_data)) {
+      throw new Error("Parsed data is not in correct format: Expected an array of observations");
+    }
+    const { dropped } = stripUnnamedColumns(parsed_data);
+    if (dropped.length > 0) {
+      console.warn(
+        `Dropped ${dropped.length} unnamed column${dropped.length > 1 ? "s" : ""} from the data \u2014 Psych-DS requires every column to have a name (usually a row-index column added by R's write.csv). Excluded from variableMeasured.`
+      );
+    }
+    const rows = parsed_data;
+    const hasColumn = (col) => ext === "json" && rows.some((row) => row && typeof row === "object" && col in row);
+    const idColumn = hasColumn("source_record_id") ? "source_record_id" : hasColumn("participant_id") ? "participant_id" : void 0;
+    if (idColumn && !this.arrayJoinKeys.includes(idColumn)) {
+      this.arrayJoinKeys = [idColumn, ...this.arrayJoinKeys];
+    }
+    const analysis = analyzeJoinKeys(parsed_data, this.arrayJoinKeys);
+    if (!analysis.isUnique && !options.suppressJoinKeyWarning) this.warnJoinKeyUniqueness(analysis);
+    for (const observation of parsed_data) {
+      await this.generateObservation(observation);
+    }
+    if (synthesizedSourceRecordId && this.containsVariable("source_record_id")) {
+      const existing = this.getVariable("source_record_id");
+      this.setVariable({
+        ...existing,
+        description: { default: "Synthetic source-record identifier (0-based), assigned one per source record (one JSON-Lines line, which is usually but not always one participant) because the raw data carried no identifier column. NOT a real subject ID from the experiment \u2014 it only orders/links records as they appeared in the source file, and serves as a join key connecting each trial to its extracted array/object rows." }
+      });
+    }
+    await this.updateMetadata(metadata);
+  }
+  /**
+   * This function iterates through the entire row of data stepping through one column at a time.
+   * It is designed to only be accessed through calling generate on an entire data file. 
+   * Searching for plugin, plugin version, extension, extension it then calls the 
+   * helper methods that process the individual row of data. There is limited error chcking and 
+   * type conversion from csv due to the way that csv data is represented as strings.
+   * This method also handles extensions, declaring them if necessary and iterate through each.
+   * This method also skips generating descriptions the variables that should the same for 
+   * all variables and instead updates their fields. 
+   *
+   * @private
+   * @async
+   * @param {*} observation Dictionary that represent one row of data
+   * @returns {*}
+   */
+  async generateObservation(observation) {
+    const version2 = observation["plugin_version"] ? observation["plugin_version"] : null;
+    const pluginType = observation["trial_type"];
+    const extensionType = observation["extension_type"];
+    const extensionVersion = observation["extension_version"];
+    const joinValues = this.arrayJoinKeys.reduce((acc, k) => {
+      acc[k] = observation[k];
+      return acc;
+    }, {});
+    for (const variable in observation) {
+      var value = observation[variable];
+      var type = typeof value;
+      if (!this.containsVariable(variable)) {
+        if (this.ignored_variables.has(variable)) {
+          this.variables.registerSystemVariable(variable);
+        } else {
+          this.setVariable({
+            "@type": "PropertyValue",
+            name: variable,
+            description: { default: "unknown" },
+            value: "unknown"
+          });
+        }
+      }
+      if (value === null || value === void 0 || value === "" || value === "null") {
+        continue;
+      }
+      if (type === "string") {
+        const asNumber = Number(value);
+        if (value.trim() !== "" && Number.isFinite(asNumber)) {
+          type = "number";
+          value = asNumber;
+        } else if (value.startsWith("{") || value.startsWith("[")) {
+          const parsed = tryParseJSON(value);
+          if (parsed !== null) {
+            value = parsed;
+            type = Array.isArray(parsed) ? "array" : "object";
+          }
+        }
+      }
+      if (this.ignored_variables.has(variable)) {
+        this.updateFields(variable, value, type);
+      } else {
+        if (type === "object" && value !== null && !Array.isArray(value)) {
+          const objectRow = { ...joinValues };
+          await this.expandObjectFields(variable, value, pluginType, version2, joinValues, objectRow);
+          const existingObjects = this.extractedObjects.get(variable) ?? [];
+          existingObjects.push(objectRow);
+          this.extractedObjects.set(variable, existingObjects);
+        } else if (type === "array" || type === "object" && Array.isArray(value)) {
+          await this.generateMetadata(variable, value, pluginType, version2);
+          const existingVar = this.containsVariable(variable) ? this.getVariable(variable) : null;
+          const existingType = existingVar?.value;
+          if (existingType !== "string" && existingType !== "number" && existingType !== "boolean") {
+            this.updateVariable(variable, "value", "array");
+          }
+          await this.accumulateArrayColumn(variable, value, joinValues, pluginType, version2);
+        } else {
+          await this.generateMetadata(variable, value, pluginType, version2);
+        }
+        if (extensionType) {
+          await Promise.all(
+            extensionType.map(async (ext, index) => {
+              if (ext && extensionVersion[index])
+                await this.generateMetadata(variable, value, ext, extensionVersion[index], true);
+            })
+          );
+        }
+      }
+    }
+  }
+  /**
+   * Iterates through one single datapoint which can be thought of as one row-column pair. 
+   * This method keeps in mind the versionType or pluginType and uses this to generate the 
+   * metadata. 
+   *
+   * @private
+   * @async
+   * @param {*} variable - The column name
+   * @param {*} value - The value at the row-column mapping that is being used to update fields
+   * @param {*} pluginType - The type of the plugin that is used for the fetching (can also be extension if extension?=true)
+   * @param {*} version - The version of the plugin that is not necessary but is used post v8 to ensure accurate fetching
+   * @param {?*} [extension] - This boolean determines whether is a extension to change fetching
+   * @returns {*}
+   */
+  async generateMetadata(variable, value, pluginType, version2, extension) {
+    const type = typeof value;
+    if (!this.containsVariable(variable)) {
+      const new_var = {
+        "@type": "PropertyValue",
+        name: variable,
+        description: { default: "unknown" },
+        value: type
+      };
+      this.setVariable(new_var);
+    } else {
+      const existing = this.getVariable(variable);
+      if (existing.value === "unknown") this.updateVariable(variable, "value", type);
+    }
+    if (pluginType) {
+      const pluginInfo = await this.getPluginInfo(pluginType, variable, version2, extension);
+      const description = pluginInfo["description"];
+      const new_description = description ? { [pluginType]: description } : { [pluginType]: "unknown" };
+      this.updateVariable(variable, "description", new_description);
+    }
+    this.updateFields(variable, value, type);
+  }
+  /**
+   * This calls an update to the individual fields of the metadata, updating levels and 
+   * minValue and maxValue depeneding on the variable type.
+   *
+   * @private
+   * @param {*} variable - The column of the data and name of variable
+   * @param {*} value - The datapoint 
+   * @param {*} type - The type of the datapoint
+   */
+  updateFields(variable, value, type) {
+    if (type === "boolean") return;
+    const existing = this.getVariable(variable);
+    if (type === "number") {
+      if (Array.isArray(existing.levels)) {
+        if (!this.mixedColumns.has(variable)) {
+          this.mixedColumns.add(variable);
+          console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+        }
+        this.updateVariable(variable, "levels", String(value));
+        return;
+      }
+      this.updateVariable(variable, "minValue", value);
+      this.updateVariable(variable, "maxValue", value);
+      return;
+    }
+    if (type !== "object") {
+      if ("minValue" in existing || "maxValue" in existing) {
+        if (!this.mixedColumns.has(variable)) {
+          this.mixedColumns.add(variable);
+          console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+        }
+        if ("minValue" in existing) this.updateVariable(variable, "levels", String(existing.minValue));
+        if ("maxValue" in existing && existing.maxValue !== existing.minValue) {
+          this.updateVariable(variable, "levels", String(existing.maxValue));
+        }
+        delete existing.minValue;
+        delete existing.maxValue;
+        this.updateVariable(variable, "value", "string");
+      }
+      if (existing.value === "boolean" && (value === "true" || value === "false")) {
+        return;
+      }
+      this.updateVariable(variable, "levels", value);
+    }
+  }
+  /**
+   * Iterates through the entire metadata options object by calling processMetadata() to act upon each of the 
+   * individual fields at one time. 
+   *
+   * @async
+   * @param {*} metadata - Metadata options that contains all the metadata according to Psych-DS formatting. 
+   */
+  async updateMetadata(metadata) {
+    for (const key in metadata) {
+      await this.processMetadata(metadata, key);
+    }
+  }
+  /**
+   * This is the method that processes each individual element of the metadata options to be updated. This can be called through generate or outside of it, 
+   * and this processes each element. 
+   *
+   * @private
+   * @param {*} metadata - An object that contains all of the metadata. This is used to access the value. 
+   * @param {*} key - String key that denotes what key-value mapping is being iterated upon. 
+   */
+  processMetadata(metadata, key) {
+    const value = metadata[key];
+    if (key === "variables") {
+      if (typeof value !== "object" || value === null) {
+        console.warn("Variable object is either null or incorrect type");
+        return;
+      }
+      for (let variable_key in value) {
+        if (!this.containsVariable(variable_key)) {
+          console.warn("Metadata does not contain variable:", variable_key);
+          continue;
+        }
+        const variable_parameters = value[variable_key];
+        if (typeof variable_parameters !== "object" || variable_parameters === null) {
+          console.warn(
+            "Parameters of variable:",
+            variable_key,
+            "is either null or incorrect type. The value",
+            variable_parameters,
+            "is either null or not an object."
+          );
+          continue;
+        }
+        for (const parameter in variable_parameters) {
+          const parameter_value = variable_parameters[parameter];
+          this.updateVariable(variable_key, parameter, parameter_value);
+          if (parameter === "value" && parameter_value === "boolean") {
+            this.applyBooleanOverride(variable_key);
+          }
+          if (parameter === "name") variable_key = parameter_value;
+        }
+      }
+    } else if (key === "author") {
+      if (typeof value !== "object" || value === null) {
+        console.warn("Author object is not correct type");
+        return;
+      }
+      for (const author_key in value) {
+        const author = value[author_key];
+        if (typeof author !== "string" && !("name" in author)) author["name"] = author_key;
+        this.setAuthor(author);
+      }
+    } else this.setMetadataField(key, value);
+  }
+  /**
+   * Applies a user-chosen `value:"boolean"` override to an already-populated variable.
+   * Warns when the values detected from the data don't map cleanly to boolean logic
+   * (anything other than true/false/0/1, case-insensitive), then drops the detected
+   * levels/min/max so the variable matches how genuine booleans are recorded (no levels).
+   */
+  applyBooleanOverride(variableName) {
+    const existing = this.getVariable(variableName);
+    const isBooleanLike = (v) => {
+      const s = String(v).trim().toLowerCase();
+      return s === "true" || s === "false" || s === "0" || s === "1";
+    };
+    const offenders = /* @__PURE__ */ new Set();
+    if (Array.isArray(existing.levels)) {
+      for (const level of existing.levels) if (!isBooleanLike(level)) offenders.add(String(level));
+    }
+    if (typeof existing.minValue === "number" && !isBooleanLike(existing.minValue)) offenders.add(String(existing.minValue));
+    if (typeof existing.maxValue === "number" && !isBooleanLike(existing.maxValue)) offenders.add(String(existing.maxValue));
+    if (offenders.size > 0) {
+      const sample = [...offenders].slice(0, 10).join(", ");
+      const more = offenders.size > 10 ? `, \u2026(+${offenders.size - 10} more)` : "";
+      console.warn(
+        `Variable "${variableName}" was set to value:"boolean", but the detected values don't map cleanly to true/false: ${sample}${more}. Double-check this is the intended type.`
+      );
+    }
+    delete existing.levels;
+    delete existing.minValue;
+    delete existing.maxValue;
+  }
+  /**
+   * Registers the keys of a plain JSON object as dotted sub-variables
+   * (e.g. response.Q0, response.Q1) and registers the parent with value: "object".
+   *
+   * Recurses into nested plain objects so structures more than one level deep are
+   * fully expanded (e.g. response.address.city). Nested arrays are registered with
+   * value: "array" (typeof [] === "object", so the inferred type must be overridden)
+   * and, when they hold objects, extracted into a separate CSV keyed by their dotted
+   * column name — mirroring how top-level array columns are handled.
+   *
+   * @param joinValues - The current row's join key values, prepended to every
+   *   extracted nested-array row so the sub-table can be rejoined to the main data.
+   */
+  async expandObjectFields(parentName, obj, pluginType, version2, joinValues, row) {
+    await this.generateMetadata(parentName, obj, pluginType, version2);
+    for (const key of Object.keys(obj)) {
+      const childName = `${parentName}.${key}`;
+      const childValue = obj[key];
+      if (row) row[childName] = childValue;
+      if (childValue !== null && typeof childValue === "object" && !Array.isArray(childValue)) {
+        await this.expandObjectFields(childName, childValue, pluginType, version2, joinValues, row);
+      } else if (Array.isArray(childValue)) {
+        await this.generateMetadata(childName, childValue, pluginType, version2);
+        this.updateVariable(childName, "value", "array");
+        await this.accumulateArrayColumn(childName, childValue, joinValues, pluginType, version2);
+      } else {
+        await this.generateMetadata(childName, childValue, pluginType, version2);
+      }
+    }
+  }
+  /**
+   * Accumulates the object elements of an array column into `extractedArrays` for
+   * separate Psych-DS CSV output, keyed by the column's (possibly dotted) name.
+   * Each emitted row is the join key values, an `element_index`, then the element's
+   * fields under DOTTED names (`columnName.field`) so they don't collide with top-level
+   * columns or with fields of other array columns. Every emitted column is registered in
+   * variableMeasured so the sidecar CSV has no columns missing from the metadata.
+   *
+   * Element fields recurse (see expandElementFields): a nested plain object is expanded
+   * into deeper dotted columns in the SAME row; a nested array is extracted into its own
+   * grandchild CSV, joinable via `${columnName}.element_index` (this element's position)
+   * carried alongside the existing join keys.
+   *
+   * Null / primitive top-level array elements are skipped; arrays with no object elements
+   * produce no rows.
+   */
+  async accumulateArrayColumn(columnName, arr, joinValues, pluginType, version2) {
+    const elements = [];
+    arr.forEach((element, index) => {
+      if (element !== null && element !== void 0) elements.push({ element, index });
+    });
+    if (elements.length === 0) return;
+    if (!this.containsVariable("element_index")) {
+      this.setVariable({
+        "@type": "PropertyValue",
+        name: "element_index",
+        description: { default: "Position of this element within its source array column (0-based)." },
+        value: "number"
+      });
+    }
+    for (const joinKey of Object.keys(joinValues)) {
+      if (!this.containsVariable(joinKey)) {
+        this.setVariable({
+          "@type": "PropertyValue",
+          name: joinKey,
+          description: { default: "Join key referencing the position of an enclosing array element (0-based index)." },
+          value: "number"
+        });
+      }
+    }
+    const existing = this.extractedArrays.get(columnName) ?? [];
+    for (const { element, index } of elements) {
+      const row = { ...joinValues, element_index: index };
+      const nestedJoin = { ...joinValues, [`${columnName}.element_index`]: index };
+      if (typeof element === "object" && !Array.isArray(element)) {
+        await this.expandElementFields(columnName, element, row, nestedJoin, pluginType, version2);
+      } else {
+        const valueName = `${columnName}.value`;
+        row[valueName] = element;
+        if (Array.isArray(element)) {
+          await this.registerNodeVariable(valueName, element, "array", pluginType, version2);
+          await this.accumulateArrayColumn(valueName, element, nestedJoin, pluginType, version2);
+        } else {
+          await this.registerScalarField(valueName, element, pluginType, version2);
+        }
+      }
+      existing.push(row);
+    }
+    this.extractedArrays.set(columnName, existing);
+  }
+  /**
+   * Recursively records one array element's fields into `row` under dotted names. Scalars become
+   * columns with type + min/max/levels tracking; nested plain objects are expanded into the SAME
+   * row (deeper dotted columns); nested arrays are extracted into their own grandchild CSV via
+   * accumulateArrayColumn (keyed by `nestedJoin`). Object/array nodes are also kept as a single
+   * dotted JSON column so their own name is represented as a column too.
+   */
+  async expandElementFields(prefix, obj, row, nestedJoin, pluginType, version2) {
+    for (const key of Object.keys(obj)) {
+      const name = `${prefix}.${key}`;
+      const value = obj[key];
+      row[name] = value;
+      if (value !== null && typeof value === "object" && !Array.isArray(value)) {
+        await this.registerNodeVariable(name, value, "object", pluginType, version2);
+        await this.expandElementFields(name, value, row, nestedJoin, pluginType, version2);
+      } else if (Array.isArray(value)) {
+        await this.registerNodeVariable(name, value, "array", pluginType, version2);
+        await this.accumulateArrayColumn(name, value, nestedJoin, pluginType, version2);
+      } else {
+        await this.registerScalarField(name, value, pluginType, version2);
+      }
+    }
+  }
+  /** Registers an object/array node variable once (with its plugin description, if any). */
+  async registerNodeVariable(name, value, type, pluginType, version2) {
+    if (this.containsVariable(name) && this.getVariable(name).value !== "unknown") return;
+    await this.generateMetadata(name, value, pluginType, version2);
+    if (!this.containsVariable(name)) {
+      this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+    } else {
+      this.updateVariable(name, "value", type);
+    }
+  }
+  /**
+   * Registers one scalar array-element field under its dotted name (so the sidecar column is
+   * represented in variableMeasured), then folds later values into min/max/levels. Empty values
+   * still declare the column (placeholder) without polluting min/max/levels.
+   */
+  async registerScalarField(name, value, pluginType, version2) {
+    if (value === null || value === void 0 || value === "" || value === "null") {
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: "unknown" });
+      }
+      return;
+    }
+    const type = typeof value;
+    const needsRegister = !this.containsVariable(name) || this.getVariable(name).value === "unknown";
+    if (needsRegister) {
+      await this.generateMetadata(name, value, pluginType, version2);
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+        this.updateFields(name, value, type);
+      }
+    } else {
+      this.updateFields(name, value, type);
+    }
+  }
+  /**
+   * Gets the description of a variable in a plugin by fetching the source code of the plugin
+   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+   * to extract the description for the variable (present as JSDoc); caches the result for future use.
+   *
+   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+   * @param {string} variableName - The name of the variable for which information is to be fetched.
+   * @param {string} version - The version of the plugin or extension
+   * @param {string} extension - Boolean indicating if pluginType refers to extension
+   * @returns {Promise} The description of the plugin variable if found, otherwise null.
+   * @throws Will throw an error if the fetch operation fails.
+   */
+  async getPluginInfo(pluginType, variableName, version2, extension) {
+    return this.pluginCache.getPluginInfo(pluginType, variableName, version2, this.verbose, extension);
+  }
+};
+export {
+  PSYCHDS_IGNORE_CONTENT,
+  PSYCHDS_IGNORE_FILENAME,
+  analyzeJoinKeys,
+  buildPsychDSDataFiles,
+  JsPsychMetadata as default,
+  deriveArrayFilename,
+  deriveFallbackBase,
+  disambiguateArrayFilename,
+  hasUnnamedColumns,
+  isValidPsychDSDataFilename,
+  objectsToCSV,
+  parseCSV,
+  parseJsonData,
+  stripUnnamedColumns,
+  toPsychDSValue,
+  unwrapTrials
+};
diff --git a/functions/metadata/dist/index.iife.js b/functions/metadata/dist/index.iife.js
new file mode 100644
index 0000000..3ba70a9
--- /dev/null
+++ b/functions/metadata/dist/index.iife.js
@@ -0,0 +1,6818 @@
+var JsPsychMetadata = (() => {
+  var __defProp = Object.defineProperty;
+  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+  var __getOwnPropNames = Object.getOwnPropertyNames;
+  var __hasOwnProp = Object.prototype.hasOwnProperty;
+  var __export = (target, all) => {
+    for (var name in all)
+      __defProp(target, name, { get: all[name], enumerable: true });
+  };
+  var __copyProps = (to, from2, except, desc) => {
+    if (from2 && typeof from2 === "object" || typeof from2 === "function") {
+      for (let key of __getOwnPropNames(from2))
+        if (!__hasOwnProp.call(to, key) && key !== except)
+          __defProp(to, key, { get: () => from2[key], enumerable: !(desc = __getOwnPropDesc(from2, key)) || desc.enumerable });
+    }
+    return to;
+  };
+  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
+
+  // src/index.ts
+  var index_exports = {};
+  __export(index_exports, {
+    PSYCHDS_IGNORE_CONTENT: () => PSYCHDS_IGNORE_CONTENT,
+    PSYCHDS_IGNORE_FILENAME: () => PSYCHDS_IGNORE_FILENAME,
+    analyzeJoinKeys: () => analyzeJoinKeys,
+    buildPsychDSDataFiles: () => buildPsychDSDataFiles,
+    default: () => JsPsychMetadata,
+    deriveArrayFilename: () => deriveArrayFilename,
+    deriveFallbackBase: () => deriveFallbackBase,
+    disambiguateArrayFilename: () => disambiguateArrayFilename,
+    hasUnnamedColumns: () => hasUnnamedColumns,
+    isValidPsychDSDataFilename: () => isValidPsychDSDataFilename,
+    objectsToCSV: () => objectsToCSV,
+    parseCSV: () => parseCSV,
+    parseJsonData: () => parseJsonData,
+    stripUnnamedColumns: () => stripUnnamedColumns,
+    toPsychDSValue: () => toPsychDSValue,
+    unwrapTrials: () => unwrapTrials
+  });
+
+  // src/AuthorsMap.ts
+  var AuthorsMap = class {
+    /**
+     * Creates an empty instance of authors map. Doesn't generate default metadata because
+     * can't assume anything about the authors.
+     *
+     * @constructor
+     */
+    constructor() {
+      this.authors = {};
+    }
+    /**
+     * Returns the final list format of the authors according to Psych-DS standards.
+     *
+     * @returns {(AuthorFields | string)[]} - List of authors
+     */
+    getList() {
+      const author_list = [];
+      for (const key of Object.keys(this.authors)) {
+        author_list.push(this.authors[key]);
+      }
+      return author_list;
+    }
+    /**
+     * Method that creates an author. This method can also be used to overwrite existing authors
+     * with the same name in order to update fields.
+     *
+     * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+     */
+    setAuthor(author) {
+      if (typeof author === "string") {
+        this.authors[author] = author;
+        return;
+      }
+      if (!author.name) {
+        console.warn("Name field is missing. Author not added.");
+        return;
+      }
+      const { name, ...rest } = author;
+      if (Object.keys(rest).length == 0) {
+        this.authors[name] = name;
+      } else {
+        const newAuthor = { name, ...rest };
+        this.authors[name] = newAuthor;
+        const unexpectedFields = Object.keys(author).filter(
+          (key) => !["@type", "name", "givenName", "familyName", "identifier"].includes(key)
+        );
+        if (unexpectedFields.length > 0) {
+          console.warn(
+            `Unexpected fields (${unexpectedFields.join(
+              ", "
+            )}) detected and included in the author object.`
+          );
+        }
+      }
+    }
+    /**
+     * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+     *
+     * @param {string} name - Name of author to be used as key.
+     * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+     */
+    getAuthor(name) {
+      if (name in this.authors) {
+        return this.authors[name];
+      } else {
+        console.warn("Author (", name, ") not found.");
+        return {};
+      }
+    }
+    /**
+     * Deletes the author if it exists, printing out warning if doesn't exist. 
+     *
+     * @param {string} author_name - Name of author to be deleted
+     */
+    deleteAuthor(author_name) {
+      if (author_name in this.authors) {
+        delete this.authors[author_name];
+      } else {
+        console.error(`Author "${author_name}" does not exist.`);
+      }
+    }
+  };
+
+  // src/PluginCache.ts
+  var PluginCache = class {
+    constructor() {
+      this.pluginFields = {};
+    }
+    /**
+     * Gets the description of a variable in a plugin by fetching the source code of the plugin
+     * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+     * to extract the description for the variable (present as JSDoc); caches the result for future use.
+     *
+     * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+     * @param {string} variableName - The name of the variable for which information is to be fetched.
+     * @param {string} version - The name of the variable for which information is to be fetched. 
+     * @param {boolean} verbose - Indicates whether should run with verbose mode
+     * @param {boolean} [extension] - An optional flag to indicate if an extension should be used.
+     * @returns {Promise} The description of the plugin variable if found, otherwise null.
+     * @throws Will throw an error if the fetch operation fails.
+     */
+    async getPluginInfo(pluginType, variableName, version2, verbose, extension) {
+      if (!(pluginType in this.pluginFields)) {
+        const fields = await this.generatePluginFields(pluginType, version2, verbose, extension);
+        this.pluginFields[pluginType] = fields;
+      }
+      if (variableName in this.pluginFields[pluginType])
+        return this.pluginFields[pluginType][variableName];
+      else
+        return {
+          description: "unknown",
+          type: "unknown"
+        };
+    }
+    /**
+     * Method that handles the generation of the fields and calls helpers methods that 
+     * fetch and parse the plugin data.
+     *
+     * @private
+     * @async
+     * @param {string} pluginType - Name of plugin or extension to fetch.
+     * @param {string} version - String version to fetch
+     * @param {boolean} verbose - Boolean indicating verbose mode
+     * @param {?boolean} [extension] - Optional flag if pluginType is extension
+     * @returns {unknown}
+     */
+    async generatePluginFields(pluginType, version2, verbose, extension) {
+      const script = await this.fetchScript(pluginType, version2, verbose, extension);
+      if (script !== void 0 && script !== null && script !== "") {
+        try {
+          return this.parseJavadocString(script);
+        } catch (err) {
+          console.warn("* Error parsing", pluginType, err);
+          return {};
+        }
+      } else {
+        return {};
+      }
+    }
+    /**
+     * The method that generates the unpkg links based on whether extension vs plugin and the 
+     * specific type.
+     *
+     * @private
+     * @param {string} pluginType - Name of plugin or extension to fetch
+     * @param {string} version - String version used
+     * @param {?boolean} [extension] - Optional flag if pluginType is extension
+     * @returns {string}
+     */
+    generateUnpkg(pluginType, version2, extension) {
+      if (extension) {
+        if (version2) {
+          return `https://unpkg.com/@jspsych/extension-${pluginType}@${version2}/src/index.ts`;
+        } else return `https://unpkg.com/@jspsych/extension-${pluginType}/src/index.ts`;
+      }
+      if (version2) {
+        return `https://unpkg.com/@jspsych/plugin-${pluginType}@${version2}/src/index.ts`;
+      } else return `https://unpkg.com/@jspsych/plugin-${pluginType}/src/index.ts`;
+    }
+    /**
+     * Fetches the actual script text content from unpkg. Calls the method to generate the link 
+     * and then handles error checking and fetching.
+     *
+     * @private
+     * @async
+     * @param {string} pluginType - The plugin or extension name to be fetched
+     * @param {string} version - The string version of the plugin
+     * @param {boolean} verbose - Boolean indicating verbose mode
+     * @param {?boolean} [extension] - Whether pluginType is extension
+     * @returns {unknown}
+     */
+    async fetchScript(pluginType, version2, verbose, extension) {
+      const unpkgUrl = this.generateUnpkg(pluginType, version2, extension);
+      if (verbose) console.log("-> fetching information for [", pluginType, "] from ->", unpkgUrl);
+      try {
+        const response = await fetch(unpkgUrl);
+        if (!response.ok) {
+          console.warn(`Plugin source not found for: ${pluginType} (HTTP ${response.status}). Descriptions will default to "unknown".`);
+          return void 0;
+        }
+        const scriptContent = await response.text();
+        return scriptContent;
+      } catch (error) {
+        console.error(
+          `Plugin fetching failed for:`,
+          pluginType,
+          "with error",
+          error,
+          "Note: if you are using a plugin not supported the main JsPsych branch this will always fail."
+        );
+        return void 0;
+      }
+    }
+    /**
+     * Extracts the content of the top-level `data: { ... }` block from a jsPsych plugin source
+     * file using brace counting. This is more robust than a regex approach because the data block
+     * ends with `},` (not `};`), and plugin sources contain deeply nested objects that would
+     * cause a lazy regex to stop at the wrong closing brace.
+     *
+     * Known limitations (acceptable for current jsPsych plugin sources):
+     * - Matches the first `data:` property in the file; a plugin with a `data:` field inside its
+     *   `parameters` block before the top-level `info.data` block would extract the wrong object.
+     * - Brace counting treats every `{`/`}` as structural; braces inside string literals or JSDoc
+     *   comments (e.g. `/** e.g. {foo: 1} *\/`) would throw off the counter.
+     *
+     * @private
+     * @param {string} script - Full plugin source text.
+     * @returns {string | null} Content between the outer braces of the data block, or null if not found.
+     */
+    extractDataBlock(script) {
+      const dataStart = script.search(/\bdata:\s*\{/);
+      if (dataStart === -1) return null;
+      const braceStart = script.indexOf("{", dataStart);
+      if (braceStart === -1) return null;
+      const braceEnd = this.findMatchingBrace(script, braceStart);
+      if (braceEnd === -1) return null;
+      return script.substring(braceStart + 1, braceEnd);
+    }
+    /**
+     * Parses JSDoc comments and variable blocks from the data section of a jsPsych plugin source.
+     *
+     * @private
+     * @param {string} script - The script text content of the fetching.
+     * @returns {{}}
+     */
+    parseJavadocString(script) {
+      const dataBlock = this.extractDataBlock(script);
+      if (!dataBlock) return {};
+      return this.extractJsdocFields(dataBlock);
+    }
+    /**
+     * Extracts JSDoc-annotated fields from a data block string. Uses brace counting to find
+     * each variable's true closing brace, then recursively processes any `nested:` sub-object
+     * so that nested parameter descriptions are also captured.
+     *
+     * @private
+     * @param {string} block - Content of a data or nested block (without outer braces).
+     * @returns {Record}
+     */
+    extractJsdocFields(block) {
+      const result = {};
+      const varStartRegex = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(\w+):\s*\{/g;
+      const propRegex = /(\w+):\s*([^,\s{}]+)/g;
+      let match;
+      while ((match = varStartRegex.exec(block)) !== null) {
+        const description = match[1].replace(/^[ \t]*\*[ \t]?/gm, "").trim().replace(/\s+/g, " ");
+        const varName = match[2];
+        const braceStart = match.index + match[0].length - 1;
+        const braceEnd = this.findMatchingBrace(block, braceStart);
+        if (braceEnd === -1) continue;
+        varStartRegex.lastIndex = braceEnd + 1;
+        const varContent = block.substring(braceStart + 1, braceEnd);
+        const propsObj = {};
+        let propMatch;
+        propRegex.lastIndex = 0;
+        while ((propMatch = propRegex.exec(varContent)) !== null) {
+          propsObj[propMatch[1]] = propMatch[2];
+        }
+        result[varName] = { description, ...propsObj };
+        const nestedSearch = /\bnested:\s*\{/.exec(varContent);
+        if (nestedSearch) {
+          const nestedBraceStart = varContent.indexOf("{", nestedSearch.index);
+          const nestedBraceEnd = this.findMatchingBrace(varContent, nestedBraceStart);
+          if (nestedBraceEnd !== -1) {
+            Object.assign(result, this.extractJsdocFields(varContent.substring(nestedBraceStart + 1, nestedBraceEnd)));
+          }
+        }
+      }
+      return result;
+    }
+    /**
+     * Returns the index of the `}` that closes the `{` at `startIndex`, using brace counting.
+     * Returns -1 if the source is unbalanced (no matching closing brace found).
+     *
+     * @private
+     * @param {string} str - String to search.
+     * @param {number} startIndex - Index of the opening `{`.
+     * @returns {number}
+     */
+    findMatchingBrace(str, startIndex) {
+      let depth = 0;
+      for (let i = startIndex; i < str.length; i++) {
+        if (str[i] === "{") depth++;
+        else if (str[i] === "}" && --depth === 0) return i;
+      }
+      return -1;
+    }
+  };
+
+  // ../../node_modules/csv-parse/dist/esm/index.js
+  var global$1 = typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {};
+  var lookup = [];
+  var revLookup = [];
+  var Arr = typeof Uint8Array !== "undefined" ? Uint8Array : Array;
+  var inited = false;
+  function init() {
+    inited = true;
+    var code = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+    for (var i = 0, len = code.length; i < len; ++i) {
+      lookup[i] = code[i];
+      revLookup[code.charCodeAt(i)] = i;
+    }
+    revLookup["-".charCodeAt(0)] = 62;
+    revLookup["_".charCodeAt(0)] = 63;
+  }
+  function toByteArray(b64) {
+    if (!inited) {
+      init();
+    }
+    var i, j, l, tmp, placeHolders, arr;
+    var len = b64.length;
+    if (len % 4 > 0) {
+      throw new Error("Invalid string. Length must be a multiple of 4");
+    }
+    placeHolders = b64[len - 2] === "=" ? 2 : b64[len - 1] === "=" ? 1 : 0;
+    arr = new Arr(len * 3 / 4 - placeHolders);
+    l = placeHolders > 0 ? len - 4 : len;
+    var L = 0;
+    for (i = 0, j = 0; i < l; i += 4, j += 3) {
+      tmp = revLookup[b64.charCodeAt(i)] << 18 | revLookup[b64.charCodeAt(i + 1)] << 12 | revLookup[b64.charCodeAt(i + 2)] << 6 | revLookup[b64.charCodeAt(i + 3)];
+      arr[L++] = tmp >> 16 & 255;
+      arr[L++] = tmp >> 8 & 255;
+      arr[L++] = tmp & 255;
+    }
+    if (placeHolders === 2) {
+      tmp = revLookup[b64.charCodeAt(i)] << 2 | revLookup[b64.charCodeAt(i + 1)] >> 4;
+      arr[L++] = tmp & 255;
+    } else if (placeHolders === 1) {
+      tmp = revLookup[b64.charCodeAt(i)] << 10 | revLookup[b64.charCodeAt(i + 1)] << 4 | revLookup[b64.charCodeAt(i + 2)] >> 2;
+      arr[L++] = tmp >> 8 & 255;
+      arr[L++] = tmp & 255;
+    }
+    return arr;
+  }
+  function tripletToBase64(num) {
+    return lookup[num >> 18 & 63] + lookup[num >> 12 & 63] + lookup[num >> 6 & 63] + lookup[num & 63];
+  }
+  function encodeChunk(uint8, start, end) {
+    var tmp;
+    var output = [];
+    for (var i = start; i < end; i += 3) {
+      tmp = (uint8[i] << 16) + (uint8[i + 1] << 8) + uint8[i + 2];
+      output.push(tripletToBase64(tmp));
+    }
+    return output.join("");
+  }
+  function fromByteArray(uint8) {
+    if (!inited) {
+      init();
+    }
+    var tmp;
+    var len = uint8.length;
+    var extraBytes = len % 3;
+    var output = "";
+    var parts = [];
+    var maxChunkLength = 16383;
+    for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) {
+      parts.push(encodeChunk(uint8, i, i + maxChunkLength > len2 ? len2 : i + maxChunkLength));
+    }
+    if (extraBytes === 1) {
+      tmp = uint8[len - 1];
+      output += lookup[tmp >> 2];
+      output += lookup[tmp << 4 & 63];
+      output += "==";
+    } else if (extraBytes === 2) {
+      tmp = (uint8[len - 2] << 8) + uint8[len - 1];
+      output += lookup[tmp >> 10];
+      output += lookup[tmp >> 4 & 63];
+      output += lookup[tmp << 2 & 63];
+      output += "=";
+    }
+    parts.push(output);
+    return parts.join("");
+  }
+  function read(buffer, offset, isLE, mLen, nBytes) {
+    var e, m;
+    var eLen = nBytes * 8 - mLen - 1;
+    var eMax = (1 << eLen) - 1;
+    var eBias = eMax >> 1;
+    var nBits = -7;
+    var i = isLE ? nBytes - 1 : 0;
+    var d = isLE ? -1 : 1;
+    var s = buffer[offset + i];
+    i += d;
+    e = s & (1 << -nBits) - 1;
+    s >>= -nBits;
+    nBits += eLen;
+    for (; nBits > 0; e = e * 256 + buffer[offset + i], i += d, nBits -= 8) {
+    }
+    m = e & (1 << -nBits) - 1;
+    e >>= -nBits;
+    nBits += mLen;
+    for (; nBits > 0; m = m * 256 + buffer[offset + i], i += d, nBits -= 8) {
+    }
+    if (e === 0) {
+      e = 1 - eBias;
+    } else if (e === eMax) {
+      return m ? NaN : (s ? -1 : 1) * Infinity;
+    } else {
+      m = m + Math.pow(2, mLen);
+      e = e - eBias;
+    }
+    return (s ? -1 : 1) * m * Math.pow(2, e - mLen);
+  }
+  function write(buffer, value, offset, isLE, mLen, nBytes) {
+    var e, m, c;
+    var eLen = nBytes * 8 - mLen - 1;
+    var eMax = (1 << eLen) - 1;
+    var eBias = eMax >> 1;
+    var rt = mLen === 23 ? Math.pow(2, -24) - Math.pow(2, -77) : 0;
+    var i = isLE ? 0 : nBytes - 1;
+    var d = isLE ? 1 : -1;
+    var s = value < 0 || value === 0 && 1 / value < 0 ? 1 : 0;
+    value = Math.abs(value);
+    if (isNaN(value) || value === Infinity) {
+      m = isNaN(value) ? 1 : 0;
+      e = eMax;
+    } else {
+      e = Math.floor(Math.log(value) / Math.LN2);
+      if (value * (c = Math.pow(2, -e)) < 1) {
+        e--;
+        c *= 2;
+      }
+      if (e + eBias >= 1) {
+        value += rt / c;
+      } else {
+        value += rt * Math.pow(2, 1 - eBias);
+      }
+      if (value * c >= 2) {
+        e++;
+        c /= 2;
+      }
+      if (e + eBias >= eMax) {
+        m = 0;
+        e = eMax;
+      } else if (e + eBias >= 1) {
+        m = (value * c - 1) * Math.pow(2, mLen);
+        e = e + eBias;
+      } else {
+        m = value * Math.pow(2, eBias - 1) * Math.pow(2, mLen);
+        e = 0;
+      }
+    }
+    for (; mLen >= 8; buffer[offset + i] = m & 255, i += d, m /= 256, mLen -= 8) {
+    }
+    e = e << mLen | m;
+    eLen += mLen;
+    for (; eLen > 0; buffer[offset + i] = e & 255, i += d, e /= 256, eLen -= 8) {
+    }
+    buffer[offset + i - d] |= s * 128;
+  }
+  var toString = {}.toString;
+  var isArray$1 = Array.isArray || function(arr) {
+    return toString.call(arr) == "[object Array]";
+  };
+  var INSPECT_MAX_BYTES = 50;
+  Buffer2.TYPED_ARRAY_SUPPORT = global$1.TYPED_ARRAY_SUPPORT !== void 0 ? global$1.TYPED_ARRAY_SUPPORT : true;
+  kMaxLength();
+  function kMaxLength() {
+    return Buffer2.TYPED_ARRAY_SUPPORT ? 2147483647 : 1073741823;
+  }
+  function createBuffer(that, length) {
+    if (kMaxLength() < length) {
+      throw new RangeError("Invalid typed array length");
+    }
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      that = new Uint8Array(length);
+      that.__proto__ = Buffer2.prototype;
+    } else {
+      if (that === null) {
+        that = new Buffer2(length);
+      }
+      that.length = length;
+    }
+    return that;
+  }
+  function Buffer2(arg, encodingOrOffset, length) {
+    if (!Buffer2.TYPED_ARRAY_SUPPORT && !(this instanceof Buffer2)) {
+      return new Buffer2(arg, encodingOrOffset, length);
+    }
+    if (typeof arg === "number") {
+      if (typeof encodingOrOffset === "string") {
+        throw new Error(
+          "If encoding is specified then the first argument must be a string"
+        );
+      }
+      return allocUnsafe(this, arg);
+    }
+    return from(this, arg, encodingOrOffset, length);
+  }
+  Buffer2.poolSize = 8192;
+  Buffer2._augment = function(arr) {
+    arr.__proto__ = Buffer2.prototype;
+    return arr;
+  };
+  function from(that, value, encodingOrOffset, length) {
+    if (typeof value === "number") {
+      throw new TypeError('"value" argument must not be a number');
+    }
+    if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) {
+      return fromArrayBuffer(that, value, encodingOrOffset, length);
+    }
+    if (typeof value === "string") {
+      return fromString(that, value, encodingOrOffset);
+    }
+    return fromObject(that, value);
+  }
+  Buffer2.from = function(value, encodingOrOffset, length) {
+    return from(null, value, encodingOrOffset, length);
+  };
+  if (Buffer2.TYPED_ARRAY_SUPPORT) {
+    Buffer2.prototype.__proto__ = Uint8Array.prototype;
+    Buffer2.__proto__ = Uint8Array;
+    if (typeof Symbol !== "undefined" && Symbol.species && Buffer2[Symbol.species] === Buffer2) ;
+  }
+  function assertSize(size) {
+    if (typeof size !== "number") {
+      throw new TypeError('"size" argument must be a number');
+    } else if (size < 0) {
+      throw new RangeError('"size" argument must not be negative');
+    }
+  }
+  function alloc(that, size, fill2, encoding) {
+    assertSize(size);
+    if (size <= 0) {
+      return createBuffer(that, size);
+    }
+    if (fill2 !== void 0) {
+      return typeof encoding === "string" ? createBuffer(that, size).fill(fill2, encoding) : createBuffer(that, size).fill(fill2);
+    }
+    return createBuffer(that, size);
+  }
+  Buffer2.alloc = function(size, fill2, encoding) {
+    return alloc(null, size, fill2, encoding);
+  };
+  function allocUnsafe(that, size) {
+    assertSize(size);
+    that = createBuffer(that, size < 0 ? 0 : checked(size) | 0);
+    if (!Buffer2.TYPED_ARRAY_SUPPORT) {
+      for (var i = 0; i < size; ++i) {
+        that[i] = 0;
+      }
+    }
+    return that;
+  }
+  Buffer2.allocUnsafe = function(size) {
+    return allocUnsafe(null, size);
+  };
+  Buffer2.allocUnsafeSlow = function(size) {
+    return allocUnsafe(null, size);
+  };
+  function fromString(that, string, encoding) {
+    if (typeof encoding !== "string" || encoding === "") {
+      encoding = "utf8";
+    }
+    if (!Buffer2.isEncoding(encoding)) {
+      throw new TypeError('"encoding" must be a valid string encoding');
+    }
+    var length = byteLength(string, encoding) | 0;
+    that = createBuffer(that, length);
+    var actual = that.write(string, encoding);
+    if (actual !== length) {
+      that = that.slice(0, actual);
+    }
+    return that;
+  }
+  function fromArrayLike(that, array) {
+    var length = array.length < 0 ? 0 : checked(array.length) | 0;
+    that = createBuffer(that, length);
+    for (var i = 0; i < length; i += 1) {
+      that[i] = array[i] & 255;
+    }
+    return that;
+  }
+  function fromArrayBuffer(that, array, byteOffset, length) {
+    array.byteLength;
+    if (byteOffset < 0 || array.byteLength < byteOffset) {
+      throw new RangeError("'offset' is out of bounds");
+    }
+    if (array.byteLength < byteOffset + (length || 0)) {
+      throw new RangeError("'length' is out of bounds");
+    }
+    if (byteOffset === void 0 && length === void 0) {
+      array = new Uint8Array(array);
+    } else if (length === void 0) {
+      array = new Uint8Array(array, byteOffset);
+    } else {
+      array = new Uint8Array(array, byteOffset, length);
+    }
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      that = array;
+      that.__proto__ = Buffer2.prototype;
+    } else {
+      that = fromArrayLike(that, array);
+    }
+    return that;
+  }
+  function fromObject(that, obj) {
+    if (internalIsBuffer(obj)) {
+      var len = checked(obj.length) | 0;
+      that = createBuffer(that, len);
+      if (that.length === 0) {
+        return that;
+      }
+      obj.copy(that, 0, 0, len);
+      return that;
+    }
+    if (obj) {
+      if (typeof ArrayBuffer !== "undefined" && obj.buffer instanceof ArrayBuffer || "length" in obj) {
+        if (typeof obj.length !== "number" || isnan(obj.length)) {
+          return createBuffer(that, 0);
+        }
+        return fromArrayLike(that, obj);
+      }
+      if (obj.type === "Buffer" && isArray$1(obj.data)) {
+        return fromArrayLike(that, obj.data);
+      }
+    }
+    throw new TypeError("First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.");
+  }
+  function checked(length) {
+    if (length >= kMaxLength()) {
+      throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x" + kMaxLength().toString(16) + " bytes");
+    }
+    return length | 0;
+  }
+  Buffer2.isBuffer = isBuffer;
+  function internalIsBuffer(b) {
+    return !!(b != null && b._isBuffer);
+  }
+  Buffer2.compare = function compare(a, b) {
+    if (!internalIsBuffer(a) || !internalIsBuffer(b)) {
+      throw new TypeError("Arguments must be Buffers");
+    }
+    if (a === b) return 0;
+    var x = a.length;
+    var y = b.length;
+    for (var i = 0, len = Math.min(x, y); i < len; ++i) {
+      if (a[i] !== b[i]) {
+        x = a[i];
+        y = b[i];
+        break;
+      }
+    }
+    if (x < y) return -1;
+    if (y < x) return 1;
+    return 0;
+  };
+  Buffer2.isEncoding = function isEncoding(encoding) {
+    switch (String(encoding).toLowerCase()) {
+      case "hex":
+      case "utf8":
+      case "utf-8":
+      case "ascii":
+      case "latin1":
+      case "binary":
+      case "base64":
+      case "ucs2":
+      case "ucs-2":
+      case "utf16le":
+      case "utf-16le":
+        return true;
+      default:
+        return false;
+    }
+  };
+  Buffer2.concat = function concat(list, length) {
+    if (!isArray$1(list)) {
+      throw new TypeError('"list" argument must be an Array of Buffers');
+    }
+    if (list.length === 0) {
+      return Buffer2.alloc(0);
+    }
+    var i;
+    if (length === void 0) {
+      length = 0;
+      for (i = 0; i < list.length; ++i) {
+        length += list[i].length;
+      }
+    }
+    var buffer = Buffer2.allocUnsafe(length);
+    var pos = 0;
+    for (i = 0; i < list.length; ++i) {
+      var buf = list[i];
+      if (!internalIsBuffer(buf)) {
+        throw new TypeError('"list" argument must be an Array of Buffers');
+      }
+      buf.copy(buffer, pos);
+      pos += buf.length;
+    }
+    return buffer;
+  };
+  function byteLength(string, encoding) {
+    if (internalIsBuffer(string)) {
+      return string.length;
+    }
+    if (typeof ArrayBuffer !== "undefined" && typeof ArrayBuffer.isView === "function" && (ArrayBuffer.isView(string) || string instanceof ArrayBuffer)) {
+      return string.byteLength;
+    }
+    if (typeof string !== "string") {
+      string = "" + string;
+    }
+    var len = string.length;
+    if (len === 0) return 0;
+    var loweredCase = false;
+    for (; ; ) {
+      switch (encoding) {
+        case "ascii":
+        case "latin1":
+        case "binary":
+          return len;
+        case "utf8":
+        case "utf-8":
+        case void 0:
+          return utf8ToBytes(string).length;
+        case "ucs2":
+        case "ucs-2":
+        case "utf16le":
+        case "utf-16le":
+          return len * 2;
+        case "hex":
+          return len >>> 1;
+        case "base64":
+          return base64ToBytes(string).length;
+        default:
+          if (loweredCase) return utf8ToBytes(string).length;
+          encoding = ("" + encoding).toLowerCase();
+          loweredCase = true;
+      }
+    }
+  }
+  Buffer2.byteLength = byteLength;
+  function slowToString(encoding, start, end) {
+    var loweredCase = false;
+    if (start === void 0 || start < 0) {
+      start = 0;
+    }
+    if (start > this.length) {
+      return "";
+    }
+    if (end === void 0 || end > this.length) {
+      end = this.length;
+    }
+    if (end <= 0) {
+      return "";
+    }
+    end >>>= 0;
+    start >>>= 0;
+    if (end <= start) {
+      return "";
+    }
+    if (!encoding) encoding = "utf8";
+    while (true) {
+      switch (encoding) {
+        case "hex":
+          return hexSlice(this, start, end);
+        case "utf8":
+        case "utf-8":
+          return utf8Slice(this, start, end);
+        case "ascii":
+          return asciiSlice(this, start, end);
+        case "latin1":
+        case "binary":
+          return latin1Slice(this, start, end);
+        case "base64":
+          return base64Slice(this, start, end);
+        case "ucs2":
+        case "ucs-2":
+        case "utf16le":
+        case "utf-16le":
+          return utf16leSlice(this, start, end);
+        default:
+          if (loweredCase) throw new TypeError("Unknown encoding: " + encoding);
+          encoding = (encoding + "").toLowerCase();
+          loweredCase = true;
+      }
+    }
+  }
+  Buffer2.prototype._isBuffer = true;
+  function swap(b, n, m) {
+    var i = b[n];
+    b[n] = b[m];
+    b[m] = i;
+  }
+  Buffer2.prototype.swap16 = function swap16() {
+    var len = this.length;
+    if (len % 2 !== 0) {
+      throw new RangeError("Buffer size must be a multiple of 16-bits");
+    }
+    for (var i = 0; i < len; i += 2) {
+      swap(this, i, i + 1);
+    }
+    return this;
+  };
+  Buffer2.prototype.swap32 = function swap32() {
+    var len = this.length;
+    if (len % 4 !== 0) {
+      throw new RangeError("Buffer size must be a multiple of 32-bits");
+    }
+    for (var i = 0; i < len; i += 4) {
+      swap(this, i, i + 3);
+      swap(this, i + 1, i + 2);
+    }
+    return this;
+  };
+  Buffer2.prototype.swap64 = function swap64() {
+    var len = this.length;
+    if (len % 8 !== 0) {
+      throw new RangeError("Buffer size must be a multiple of 64-bits");
+    }
+    for (var i = 0; i < len; i += 8) {
+      swap(this, i, i + 7);
+      swap(this, i + 1, i + 6);
+      swap(this, i + 2, i + 5);
+      swap(this, i + 3, i + 4);
+    }
+    return this;
+  };
+  Buffer2.prototype.toString = function toString2() {
+    var length = this.length | 0;
+    if (length === 0) return "";
+    if (arguments.length === 0) return utf8Slice(this, 0, length);
+    return slowToString.apply(this, arguments);
+  };
+  Buffer2.prototype.equals = function equals(b) {
+    if (!internalIsBuffer(b)) throw new TypeError("Argument must be a Buffer");
+    if (this === b) return true;
+    return Buffer2.compare(this, b) === 0;
+  };
+  Buffer2.prototype.inspect = function inspect() {
+    var str = "";
+    var max = INSPECT_MAX_BYTES;
+    if (this.length > 0) {
+      str = this.toString("hex", 0, max).match(/.{2}/g).join(" ");
+      if (this.length > max) str += " ... ";
+    }
+    return "";
+  };
+  Buffer2.prototype.compare = function compare2(target, start, end, thisStart, thisEnd) {
+    if (!internalIsBuffer(target)) {
+      throw new TypeError("Argument must be a Buffer");
+    }
+    if (start === void 0) {
+      start = 0;
+    }
+    if (end === void 0) {
+      end = target ? target.length : 0;
+    }
+    if (thisStart === void 0) {
+      thisStart = 0;
+    }
+    if (thisEnd === void 0) {
+      thisEnd = this.length;
+    }
+    if (start < 0 || end > target.length || thisStart < 0 || thisEnd > this.length) {
+      throw new RangeError("out of range index");
+    }
+    if (thisStart >= thisEnd && start >= end) {
+      return 0;
+    }
+    if (thisStart >= thisEnd) {
+      return -1;
+    }
+    if (start >= end) {
+      return 1;
+    }
+    start >>>= 0;
+    end >>>= 0;
+    thisStart >>>= 0;
+    thisEnd >>>= 0;
+    if (this === target) return 0;
+    var x = thisEnd - thisStart;
+    var y = end - start;
+    var len = Math.min(x, y);
+    var thisCopy = this.slice(thisStart, thisEnd);
+    var targetCopy = target.slice(start, end);
+    for (var i = 0; i < len; ++i) {
+      if (thisCopy[i] !== targetCopy[i]) {
+        x = thisCopy[i];
+        y = targetCopy[i];
+        break;
+      }
+    }
+    if (x < y) return -1;
+    if (y < x) return 1;
+    return 0;
+  };
+  function bidirectionalIndexOf(buffer, val, byteOffset, encoding, dir) {
+    if (buffer.length === 0) return -1;
+    if (typeof byteOffset === "string") {
+      encoding = byteOffset;
+      byteOffset = 0;
+    } else if (byteOffset > 2147483647) {
+      byteOffset = 2147483647;
+    } else if (byteOffset < -2147483648) {
+      byteOffset = -2147483648;
+    }
+    byteOffset = +byteOffset;
+    if (isNaN(byteOffset)) {
+      byteOffset = dir ? 0 : buffer.length - 1;
+    }
+    if (byteOffset < 0) byteOffset = buffer.length + byteOffset;
+    if (byteOffset >= buffer.length) {
+      if (dir) return -1;
+      else byteOffset = buffer.length - 1;
+    } else if (byteOffset < 0) {
+      if (dir) byteOffset = 0;
+      else return -1;
+    }
+    if (typeof val === "string") {
+      val = Buffer2.from(val, encoding);
+    }
+    if (internalIsBuffer(val)) {
+      if (val.length === 0) {
+        return -1;
+      }
+      return arrayIndexOf(buffer, val, byteOffset, encoding, dir);
+    } else if (typeof val === "number") {
+      val = val & 255;
+      if (Buffer2.TYPED_ARRAY_SUPPORT && typeof Uint8Array.prototype.indexOf === "function") {
+        if (dir) {
+          return Uint8Array.prototype.indexOf.call(buffer, val, byteOffset);
+        } else {
+          return Uint8Array.prototype.lastIndexOf.call(buffer, val, byteOffset);
+        }
+      }
+      return arrayIndexOf(buffer, [val], byteOffset, encoding, dir);
+    }
+    throw new TypeError("val must be string, number or Buffer");
+  }
+  function arrayIndexOf(arr, val, byteOffset, encoding, dir) {
+    var indexSize = 1;
+    var arrLength = arr.length;
+    var valLength = val.length;
+    if (encoding !== void 0) {
+      encoding = String(encoding).toLowerCase();
+      if (encoding === "ucs2" || encoding === "ucs-2" || encoding === "utf16le" || encoding === "utf-16le") {
+        if (arr.length < 2 || val.length < 2) {
+          return -1;
+        }
+        indexSize = 2;
+        arrLength /= 2;
+        valLength /= 2;
+        byteOffset /= 2;
+      }
+    }
+    function read2(buf, i2) {
+      if (indexSize === 1) {
+        return buf[i2];
+      } else {
+        return buf.readUInt16BE(i2 * indexSize);
+      }
+    }
+    var i;
+    if (dir) {
+      var foundIndex = -1;
+      for (i = byteOffset; i < arrLength; i++) {
+        if (read2(arr, i) === read2(val, foundIndex === -1 ? 0 : i - foundIndex)) {
+          if (foundIndex === -1) foundIndex = i;
+          if (i - foundIndex + 1 === valLength) return foundIndex * indexSize;
+        } else {
+          if (foundIndex !== -1) i -= i - foundIndex;
+          foundIndex = -1;
+        }
+      }
+    } else {
+      if (byteOffset + valLength > arrLength) byteOffset = arrLength - valLength;
+      for (i = byteOffset; i >= 0; i--) {
+        var found = true;
+        for (var j = 0; j < valLength; j++) {
+          if (read2(arr, i + j) !== read2(val, j)) {
+            found = false;
+            break;
+          }
+        }
+        if (found) return i;
+      }
+    }
+    return -1;
+  }
+  Buffer2.prototype.includes = function includes(val, byteOffset, encoding) {
+    return this.indexOf(val, byteOffset, encoding) !== -1;
+  };
+  Buffer2.prototype.indexOf = function indexOf(val, byteOffset, encoding) {
+    return bidirectionalIndexOf(this, val, byteOffset, encoding, true);
+  };
+  Buffer2.prototype.lastIndexOf = function lastIndexOf(val, byteOffset, encoding) {
+    return bidirectionalIndexOf(this, val, byteOffset, encoding, false);
+  };
+  function hexWrite(buf, string, offset, length) {
+    offset = Number(offset) || 0;
+    var remaining = buf.length - offset;
+    if (!length) {
+      length = remaining;
+    } else {
+      length = Number(length);
+      if (length > remaining) {
+        length = remaining;
+      }
+    }
+    var strLen = string.length;
+    if (strLen % 2 !== 0) throw new TypeError("Invalid hex string");
+    if (length > strLen / 2) {
+      length = strLen / 2;
+    }
+    for (var i = 0; i < length; ++i) {
+      var parsed = parseInt(string.substr(i * 2, 2), 16);
+      if (isNaN(parsed)) return i;
+      buf[offset + i] = parsed;
+    }
+    return i;
+  }
+  function utf8Write(buf, string, offset, length) {
+    return blitBuffer(utf8ToBytes(string, buf.length - offset), buf, offset, length);
+  }
+  function asciiWrite(buf, string, offset, length) {
+    return blitBuffer(asciiToBytes(string), buf, offset, length);
+  }
+  function latin1Write(buf, string, offset, length) {
+    return asciiWrite(buf, string, offset, length);
+  }
+  function base64Write(buf, string, offset, length) {
+    return blitBuffer(base64ToBytes(string), buf, offset, length);
+  }
+  function ucs2Write(buf, string, offset, length) {
+    return blitBuffer(utf16leToBytes(string, buf.length - offset), buf, offset, length);
+  }
+  Buffer2.prototype.write = function write2(string, offset, length, encoding) {
+    if (offset === void 0) {
+      encoding = "utf8";
+      length = this.length;
+      offset = 0;
+    } else if (length === void 0 && typeof offset === "string") {
+      encoding = offset;
+      length = this.length;
+      offset = 0;
+    } else if (isFinite(offset)) {
+      offset = offset | 0;
+      if (isFinite(length)) {
+        length = length | 0;
+        if (encoding === void 0) encoding = "utf8";
+      } else {
+        encoding = length;
+        length = void 0;
+      }
+    } else {
+      throw new Error(
+        "Buffer.write(string, encoding, offset[, length]) is no longer supported"
+      );
+    }
+    var remaining = this.length - offset;
+    if (length === void 0 || length > remaining) length = remaining;
+    if (string.length > 0 && (length < 0 || offset < 0) || offset > this.length) {
+      throw new RangeError("Attempt to write outside buffer bounds");
+    }
+    if (!encoding) encoding = "utf8";
+    var loweredCase = false;
+    for (; ; ) {
+      switch (encoding) {
+        case "hex":
+          return hexWrite(this, string, offset, length);
+        case "utf8":
+        case "utf-8":
+          return utf8Write(this, string, offset, length);
+        case "ascii":
+          return asciiWrite(this, string, offset, length);
+        case "latin1":
+        case "binary":
+          return latin1Write(this, string, offset, length);
+        case "base64":
+          return base64Write(this, string, offset, length);
+        case "ucs2":
+        case "ucs-2":
+        case "utf16le":
+        case "utf-16le":
+          return ucs2Write(this, string, offset, length);
+        default:
+          if (loweredCase) throw new TypeError("Unknown encoding: " + encoding);
+          encoding = ("" + encoding).toLowerCase();
+          loweredCase = true;
+      }
+    }
+  };
+  Buffer2.prototype.toJSON = function toJSON() {
+    return {
+      type: "Buffer",
+      data: Array.prototype.slice.call(this._arr || this, 0)
+    };
+  };
+  function base64Slice(buf, start, end) {
+    if (start === 0 && end === buf.length) {
+      return fromByteArray(buf);
+    } else {
+      return fromByteArray(buf.slice(start, end));
+    }
+  }
+  function utf8Slice(buf, start, end) {
+    end = Math.min(buf.length, end);
+    var res = [];
+    var i = start;
+    while (i < end) {
+      var firstByte = buf[i];
+      var codePoint = null;
+      var bytesPerSequence = firstByte > 239 ? 4 : firstByte > 223 ? 3 : firstByte > 191 ? 2 : 1;
+      if (i + bytesPerSequence <= end) {
+        var secondByte, thirdByte, fourthByte, tempCodePoint;
+        switch (bytesPerSequence) {
+          case 1:
+            if (firstByte < 128) {
+              codePoint = firstByte;
+            }
+            break;
+          case 2:
+            secondByte = buf[i + 1];
+            if ((secondByte & 192) === 128) {
+              tempCodePoint = (firstByte & 31) << 6 | secondByte & 63;
+              if (tempCodePoint > 127) {
+                codePoint = tempCodePoint;
+              }
+            }
+            break;
+          case 3:
+            secondByte = buf[i + 1];
+            thirdByte = buf[i + 2];
+            if ((secondByte & 192) === 128 && (thirdByte & 192) === 128) {
+              tempCodePoint = (firstByte & 15) << 12 | (secondByte & 63) << 6 | thirdByte & 63;
+              if (tempCodePoint > 2047 && (tempCodePoint < 55296 || tempCodePoint > 57343)) {
+                codePoint = tempCodePoint;
+              }
+            }
+            break;
+          case 4:
+            secondByte = buf[i + 1];
+            thirdByte = buf[i + 2];
+            fourthByte = buf[i + 3];
+            if ((secondByte & 192) === 128 && (thirdByte & 192) === 128 && (fourthByte & 192) === 128) {
+              tempCodePoint = (firstByte & 15) << 18 | (secondByte & 63) << 12 | (thirdByte & 63) << 6 | fourthByte & 63;
+              if (tempCodePoint > 65535 && tempCodePoint < 1114112) {
+                codePoint = tempCodePoint;
+              }
+            }
+        }
+      }
+      if (codePoint === null) {
+        codePoint = 65533;
+        bytesPerSequence = 1;
+      } else if (codePoint > 65535) {
+        codePoint -= 65536;
+        res.push(codePoint >>> 10 & 1023 | 55296);
+        codePoint = 56320 | codePoint & 1023;
+      }
+      res.push(codePoint);
+      i += bytesPerSequence;
+    }
+    return decodeCodePointsArray(res);
+  }
+  var MAX_ARGUMENTS_LENGTH = 4096;
+  function decodeCodePointsArray(codePoints) {
+    var len = codePoints.length;
+    if (len <= MAX_ARGUMENTS_LENGTH) {
+      return String.fromCharCode.apply(String, codePoints);
+    }
+    var res = "";
+    var i = 0;
+    while (i < len) {
+      res += String.fromCharCode.apply(
+        String,
+        codePoints.slice(i, i += MAX_ARGUMENTS_LENGTH)
+      );
+    }
+    return res;
+  }
+  function asciiSlice(buf, start, end) {
+    var ret = "";
+    end = Math.min(buf.length, end);
+    for (var i = start; i < end; ++i) {
+      ret += String.fromCharCode(buf[i] & 127);
+    }
+    return ret;
+  }
+  function latin1Slice(buf, start, end) {
+    var ret = "";
+    end = Math.min(buf.length, end);
+    for (var i = start; i < end; ++i) {
+      ret += String.fromCharCode(buf[i]);
+    }
+    return ret;
+  }
+  function hexSlice(buf, start, end) {
+    var len = buf.length;
+    if (!start || start < 0) start = 0;
+    if (!end || end < 0 || end > len) end = len;
+    var out = "";
+    for (var i = start; i < end; ++i) {
+      out += toHex(buf[i]);
+    }
+    return out;
+  }
+  function utf16leSlice(buf, start, end) {
+    var bytes = buf.slice(start, end);
+    var res = "";
+    for (var i = 0; i < bytes.length; i += 2) {
+      res += String.fromCharCode(bytes[i] + bytes[i + 1] * 256);
+    }
+    return res;
+  }
+  Buffer2.prototype.slice = function slice(start, end) {
+    var len = this.length;
+    start = ~~start;
+    end = end === void 0 ? len : ~~end;
+    if (start < 0) {
+      start += len;
+      if (start < 0) start = 0;
+    } else if (start > len) {
+      start = len;
+    }
+    if (end < 0) {
+      end += len;
+      if (end < 0) end = 0;
+    } else if (end > len) {
+      end = len;
+    }
+    if (end < start) end = start;
+    var newBuf;
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      newBuf = this.subarray(start, end);
+      newBuf.__proto__ = Buffer2.prototype;
+    } else {
+      var sliceLen = end - start;
+      newBuf = new Buffer2(sliceLen, void 0);
+      for (var i = 0; i < sliceLen; ++i) {
+        newBuf[i] = this[i + start];
+      }
+    }
+    return newBuf;
+  };
+  function checkOffset(offset, ext, length) {
+    if (offset % 1 !== 0 || offset < 0) throw new RangeError("offset is not uint");
+    if (offset + ext > length) throw new RangeError("Trying to access beyond buffer length");
+  }
+  Buffer2.prototype.readUIntLE = function readUIntLE(offset, byteLength2, noAssert) {
+    offset = offset | 0;
+    byteLength2 = byteLength2 | 0;
+    if (!noAssert) checkOffset(offset, byteLength2, this.length);
+    var val = this[offset];
+    var mul = 1;
+    var i = 0;
+    while (++i < byteLength2 && (mul *= 256)) {
+      val += this[offset + i] * mul;
+    }
+    return val;
+  };
+  Buffer2.prototype.readUIntBE = function readUIntBE(offset, byteLength2, noAssert) {
+    offset = offset | 0;
+    byteLength2 = byteLength2 | 0;
+    if (!noAssert) {
+      checkOffset(offset, byteLength2, this.length);
+    }
+    var val = this[offset + --byteLength2];
+    var mul = 1;
+    while (byteLength2 > 0 && (mul *= 256)) {
+      val += this[offset + --byteLength2] * mul;
+    }
+    return val;
+  };
+  Buffer2.prototype.readUInt8 = function readUInt8(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 1, this.length);
+    return this[offset];
+  };
+  Buffer2.prototype.readUInt16LE = function readUInt16LE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 2, this.length);
+    return this[offset] | this[offset + 1] << 8;
+  };
+  Buffer2.prototype.readUInt16BE = function readUInt16BE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 2, this.length);
+    return this[offset] << 8 | this[offset + 1];
+  };
+  Buffer2.prototype.readUInt32LE = function readUInt32LE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 4, this.length);
+    return (this[offset] | this[offset + 1] << 8 | this[offset + 2] << 16) + this[offset + 3] * 16777216;
+  };
+  Buffer2.prototype.readUInt32BE = function readUInt32BE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 4, this.length);
+    return this[offset] * 16777216 + (this[offset + 1] << 16 | this[offset + 2] << 8 | this[offset + 3]);
+  };
+  Buffer2.prototype.readIntLE = function readIntLE(offset, byteLength2, noAssert) {
+    offset = offset | 0;
+    byteLength2 = byteLength2 | 0;
+    if (!noAssert) checkOffset(offset, byteLength2, this.length);
+    var val = this[offset];
+    var mul = 1;
+    var i = 0;
+    while (++i < byteLength2 && (mul *= 256)) {
+      val += this[offset + i] * mul;
+    }
+    mul *= 128;
+    if (val >= mul) val -= Math.pow(2, 8 * byteLength2);
+    return val;
+  };
+  Buffer2.prototype.readIntBE = function readIntBE(offset, byteLength2, noAssert) {
+    offset = offset | 0;
+    byteLength2 = byteLength2 | 0;
+    if (!noAssert) checkOffset(offset, byteLength2, this.length);
+    var i = byteLength2;
+    var mul = 1;
+    var val = this[offset + --i];
+    while (i > 0 && (mul *= 256)) {
+      val += this[offset + --i] * mul;
+    }
+    mul *= 128;
+    if (val >= mul) val -= Math.pow(2, 8 * byteLength2);
+    return val;
+  };
+  Buffer2.prototype.readInt8 = function readInt8(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 1, this.length);
+    if (!(this[offset] & 128)) return this[offset];
+    return (255 - this[offset] + 1) * -1;
+  };
+  Buffer2.prototype.readInt16LE = function readInt16LE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 2, this.length);
+    var val = this[offset] | this[offset + 1] << 8;
+    return val & 32768 ? val | 4294901760 : val;
+  };
+  Buffer2.prototype.readInt16BE = function readInt16BE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 2, this.length);
+    var val = this[offset + 1] | this[offset] << 8;
+    return val & 32768 ? val | 4294901760 : val;
+  };
+  Buffer2.prototype.readInt32LE = function readInt32LE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 4, this.length);
+    return this[offset] | this[offset + 1] << 8 | this[offset + 2] << 16 | this[offset + 3] << 24;
+  };
+  Buffer2.prototype.readInt32BE = function readInt32BE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 4, this.length);
+    return this[offset] << 24 | this[offset + 1] << 16 | this[offset + 2] << 8 | this[offset + 3];
+  };
+  Buffer2.prototype.readFloatLE = function readFloatLE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 4, this.length);
+    return read(this, offset, true, 23, 4);
+  };
+  Buffer2.prototype.readFloatBE = function readFloatBE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 4, this.length);
+    return read(this, offset, false, 23, 4);
+  };
+  Buffer2.prototype.readDoubleLE = function readDoubleLE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 8, this.length);
+    return read(this, offset, true, 52, 8);
+  };
+  Buffer2.prototype.readDoubleBE = function readDoubleBE(offset, noAssert) {
+    if (!noAssert) checkOffset(offset, 8, this.length);
+    return read(this, offset, false, 52, 8);
+  };
+  function checkInt(buf, value, offset, ext, max, min) {
+    if (!internalIsBuffer(buf)) throw new TypeError('"buffer" argument must be a Buffer instance');
+    if (value > max || value < min) throw new RangeError('"value" argument is out of bounds');
+    if (offset + ext > buf.length) throw new RangeError("Index out of range");
+  }
+  Buffer2.prototype.writeUIntLE = function writeUIntLE(value, offset, byteLength2, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    byteLength2 = byteLength2 | 0;
+    if (!noAssert) {
+      var maxBytes = Math.pow(2, 8 * byteLength2) - 1;
+      checkInt(this, value, offset, byteLength2, maxBytes, 0);
+    }
+    var mul = 1;
+    var i = 0;
+    this[offset] = value & 255;
+    while (++i < byteLength2 && (mul *= 256)) {
+      this[offset + i] = value / mul & 255;
+    }
+    return offset + byteLength2;
+  };
+  Buffer2.prototype.writeUIntBE = function writeUIntBE(value, offset, byteLength2, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    byteLength2 = byteLength2 | 0;
+    if (!noAssert) {
+      var maxBytes = Math.pow(2, 8 * byteLength2) - 1;
+      checkInt(this, value, offset, byteLength2, maxBytes, 0);
+    }
+    var i = byteLength2 - 1;
+    var mul = 1;
+    this[offset + i] = value & 255;
+    while (--i >= 0 && (mul *= 256)) {
+      this[offset + i] = value / mul & 255;
+    }
+    return offset + byteLength2;
+  };
+  Buffer2.prototype.writeUInt8 = function writeUInt8(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 1, 255, 0);
+    if (!Buffer2.TYPED_ARRAY_SUPPORT) value = Math.floor(value);
+    this[offset] = value & 255;
+    return offset + 1;
+  };
+  function objectWriteUInt16(buf, value, offset, littleEndian) {
+    if (value < 0) value = 65535 + value + 1;
+    for (var i = 0, j = Math.min(buf.length - offset, 2); i < j; ++i) {
+      buf[offset + i] = (value & 255 << 8 * (littleEndian ? i : 1 - i)) >>> (littleEndian ? i : 1 - i) * 8;
+    }
+  }
+  Buffer2.prototype.writeUInt16LE = function writeUInt16LE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 2, 65535, 0);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value & 255;
+      this[offset + 1] = value >>> 8;
+    } else {
+      objectWriteUInt16(this, value, offset, true);
+    }
+    return offset + 2;
+  };
+  Buffer2.prototype.writeUInt16BE = function writeUInt16BE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 2, 65535, 0);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value >>> 8;
+      this[offset + 1] = value & 255;
+    } else {
+      objectWriteUInt16(this, value, offset, false);
+    }
+    return offset + 2;
+  };
+  function objectWriteUInt32(buf, value, offset, littleEndian) {
+    if (value < 0) value = 4294967295 + value + 1;
+    for (var i = 0, j = Math.min(buf.length - offset, 4); i < j; ++i) {
+      buf[offset + i] = value >>> (littleEndian ? i : 3 - i) * 8 & 255;
+    }
+  }
+  Buffer2.prototype.writeUInt32LE = function writeUInt32LE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 4, 4294967295, 0);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset + 3] = value >>> 24;
+      this[offset + 2] = value >>> 16;
+      this[offset + 1] = value >>> 8;
+      this[offset] = value & 255;
+    } else {
+      objectWriteUInt32(this, value, offset, true);
+    }
+    return offset + 4;
+  };
+  Buffer2.prototype.writeUInt32BE = function writeUInt32BE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 4, 4294967295, 0);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value >>> 24;
+      this[offset + 1] = value >>> 16;
+      this[offset + 2] = value >>> 8;
+      this[offset + 3] = value & 255;
+    } else {
+      objectWriteUInt32(this, value, offset, false);
+    }
+    return offset + 4;
+  };
+  Buffer2.prototype.writeIntLE = function writeIntLE(value, offset, byteLength2, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) {
+      var limit = Math.pow(2, 8 * byteLength2 - 1);
+      checkInt(this, value, offset, byteLength2, limit - 1, -limit);
+    }
+    var i = 0;
+    var mul = 1;
+    var sub = 0;
+    this[offset] = value & 255;
+    while (++i < byteLength2 && (mul *= 256)) {
+      if (value < 0 && sub === 0 && this[offset + i - 1] !== 0) {
+        sub = 1;
+      }
+      this[offset + i] = (value / mul >> 0) - sub & 255;
+    }
+    return offset + byteLength2;
+  };
+  Buffer2.prototype.writeIntBE = function writeIntBE(value, offset, byteLength2, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) {
+      var limit = Math.pow(2, 8 * byteLength2 - 1);
+      checkInt(this, value, offset, byteLength2, limit - 1, -limit);
+    }
+    var i = byteLength2 - 1;
+    var mul = 1;
+    var sub = 0;
+    this[offset + i] = value & 255;
+    while (--i >= 0 && (mul *= 256)) {
+      if (value < 0 && sub === 0 && this[offset + i + 1] !== 0) {
+        sub = 1;
+      }
+      this[offset + i] = (value / mul >> 0) - sub & 255;
+    }
+    return offset + byteLength2;
+  };
+  Buffer2.prototype.writeInt8 = function writeInt8(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 1, 127, -128);
+    if (!Buffer2.TYPED_ARRAY_SUPPORT) value = Math.floor(value);
+    if (value < 0) value = 255 + value + 1;
+    this[offset] = value & 255;
+    return offset + 1;
+  };
+  Buffer2.prototype.writeInt16LE = function writeInt16LE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 2, 32767, -32768);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value & 255;
+      this[offset + 1] = value >>> 8;
+    } else {
+      objectWriteUInt16(this, value, offset, true);
+    }
+    return offset + 2;
+  };
+  Buffer2.prototype.writeInt16BE = function writeInt16BE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 2, 32767, -32768);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value >>> 8;
+      this[offset + 1] = value & 255;
+    } else {
+      objectWriteUInt16(this, value, offset, false);
+    }
+    return offset + 2;
+  };
+  Buffer2.prototype.writeInt32LE = function writeInt32LE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 4, 2147483647, -2147483648);
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value & 255;
+      this[offset + 1] = value >>> 8;
+      this[offset + 2] = value >>> 16;
+      this[offset + 3] = value >>> 24;
+    } else {
+      objectWriteUInt32(this, value, offset, true);
+    }
+    return offset + 4;
+  };
+  Buffer2.prototype.writeInt32BE = function writeInt32BE(value, offset, noAssert) {
+    value = +value;
+    offset = offset | 0;
+    if (!noAssert) checkInt(this, value, offset, 4, 2147483647, -2147483648);
+    if (value < 0) value = 4294967295 + value + 1;
+    if (Buffer2.TYPED_ARRAY_SUPPORT) {
+      this[offset] = value >>> 24;
+      this[offset + 1] = value >>> 16;
+      this[offset + 2] = value >>> 8;
+      this[offset + 3] = value & 255;
+    } else {
+      objectWriteUInt32(this, value, offset, false);
+    }
+    return offset + 4;
+  };
+  function checkIEEE754(buf, value, offset, ext, max, min) {
+    if (offset + ext > buf.length) throw new RangeError("Index out of range");
+    if (offset < 0) throw new RangeError("Index out of range");
+  }
+  function writeFloat(buf, value, offset, littleEndian, noAssert) {
+    if (!noAssert) {
+      checkIEEE754(buf, value, offset, 4);
+    }
+    write(buf, value, offset, littleEndian, 23, 4);
+    return offset + 4;
+  }
+  Buffer2.prototype.writeFloatLE = function writeFloatLE(value, offset, noAssert) {
+    return writeFloat(this, value, offset, true, noAssert);
+  };
+  Buffer2.prototype.writeFloatBE = function writeFloatBE(value, offset, noAssert) {
+    return writeFloat(this, value, offset, false, noAssert);
+  };
+  function writeDouble(buf, value, offset, littleEndian, noAssert) {
+    if (!noAssert) {
+      checkIEEE754(buf, value, offset, 8);
+    }
+    write(buf, value, offset, littleEndian, 52, 8);
+    return offset + 8;
+  }
+  Buffer2.prototype.writeDoubleLE = function writeDoubleLE(value, offset, noAssert) {
+    return writeDouble(this, value, offset, true, noAssert);
+  };
+  Buffer2.prototype.writeDoubleBE = function writeDoubleBE(value, offset, noAssert) {
+    return writeDouble(this, value, offset, false, noAssert);
+  };
+  Buffer2.prototype.copy = function copy(target, targetStart, start, end) {
+    if (!start) start = 0;
+    if (!end && end !== 0) end = this.length;
+    if (targetStart >= target.length) targetStart = target.length;
+    if (!targetStart) targetStart = 0;
+    if (end > 0 && end < start) end = start;
+    if (end === start) return 0;
+    if (target.length === 0 || this.length === 0) return 0;
+    if (targetStart < 0) {
+      throw new RangeError("targetStart out of bounds");
+    }
+    if (start < 0 || start >= this.length) throw new RangeError("sourceStart out of bounds");
+    if (end < 0) throw new RangeError("sourceEnd out of bounds");
+    if (end > this.length) end = this.length;
+    if (target.length - targetStart < end - start) {
+      end = target.length - targetStart + start;
+    }
+    var len = end - start;
+    var i;
+    if (this === target && start < targetStart && targetStart < end) {
+      for (i = len - 1; i >= 0; --i) {
+        target[i + targetStart] = this[i + start];
+      }
+    } else if (len < 1e3 || !Buffer2.TYPED_ARRAY_SUPPORT) {
+      for (i = 0; i < len; ++i) {
+        target[i + targetStart] = this[i + start];
+      }
+    } else {
+      Uint8Array.prototype.set.call(
+        target,
+        this.subarray(start, start + len),
+        targetStart
+      );
+    }
+    return len;
+  };
+  Buffer2.prototype.fill = function fill(val, start, end, encoding) {
+    if (typeof val === "string") {
+      if (typeof start === "string") {
+        encoding = start;
+        start = 0;
+        end = this.length;
+      } else if (typeof end === "string") {
+        encoding = end;
+        end = this.length;
+      }
+      if (val.length === 1) {
+        var code = val.charCodeAt(0);
+        if (code < 256) {
+          val = code;
+        }
+      }
+      if (encoding !== void 0 && typeof encoding !== "string") {
+        throw new TypeError("encoding must be a string");
+      }
+      if (typeof encoding === "string" && !Buffer2.isEncoding(encoding)) {
+        throw new TypeError("Unknown encoding: " + encoding);
+      }
+    } else if (typeof val === "number") {
+      val = val & 255;
+    }
+    if (start < 0 || this.length < start || this.length < end) {
+      throw new RangeError("Out of range index");
+    }
+    if (end <= start) {
+      return this;
+    }
+    start = start >>> 0;
+    end = end === void 0 ? this.length : end >>> 0;
+    if (!val) val = 0;
+    var i;
+    if (typeof val === "number") {
+      for (i = start; i < end; ++i) {
+        this[i] = val;
+      }
+    } else {
+      var bytes = internalIsBuffer(val) ? val : utf8ToBytes(new Buffer2(val, encoding).toString());
+      var len = bytes.length;
+      for (i = 0; i < end - start; ++i) {
+        this[i + start] = bytes[i % len];
+      }
+    }
+    return this;
+  };
+  var INVALID_BASE64_RE = /[^+\/0-9A-Za-z-_]/g;
+  function base64clean(str) {
+    str = stringtrim(str).replace(INVALID_BASE64_RE, "");
+    if (str.length < 2) return "";
+    while (str.length % 4 !== 0) {
+      str = str + "=";
+    }
+    return str;
+  }
+  function stringtrim(str) {
+    if (str.trim) return str.trim();
+    return str.replace(/^\s+|\s+$/g, "");
+  }
+  function toHex(n) {
+    if (n < 16) return "0" + n.toString(16);
+    return n.toString(16);
+  }
+  function utf8ToBytes(string, units) {
+    units = units || Infinity;
+    var codePoint;
+    var length = string.length;
+    var leadSurrogate = null;
+    var bytes = [];
+    for (var i = 0; i < length; ++i) {
+      codePoint = string.charCodeAt(i);
+      if (codePoint > 55295 && codePoint < 57344) {
+        if (!leadSurrogate) {
+          if (codePoint > 56319) {
+            if ((units -= 3) > -1) bytes.push(239, 191, 189);
+            continue;
+          } else if (i + 1 === length) {
+            if ((units -= 3) > -1) bytes.push(239, 191, 189);
+            continue;
+          }
+          leadSurrogate = codePoint;
+          continue;
+        }
+        if (codePoint < 56320) {
+          if ((units -= 3) > -1) bytes.push(239, 191, 189);
+          leadSurrogate = codePoint;
+          continue;
+        }
+        codePoint = (leadSurrogate - 55296 << 10 | codePoint - 56320) + 65536;
+      } else if (leadSurrogate) {
+        if ((units -= 3) > -1) bytes.push(239, 191, 189);
+      }
+      leadSurrogate = null;
+      if (codePoint < 128) {
+        if ((units -= 1) < 0) break;
+        bytes.push(codePoint);
+      } else if (codePoint < 2048) {
+        if ((units -= 2) < 0) break;
+        bytes.push(
+          codePoint >> 6 | 192,
+          codePoint & 63 | 128
+        );
+      } else if (codePoint < 65536) {
+        if ((units -= 3) < 0) break;
+        bytes.push(
+          codePoint >> 12 | 224,
+          codePoint >> 6 & 63 | 128,
+          codePoint & 63 | 128
+        );
+      } else if (codePoint < 1114112) {
+        if ((units -= 4) < 0) break;
+        bytes.push(
+          codePoint >> 18 | 240,
+          codePoint >> 12 & 63 | 128,
+          codePoint >> 6 & 63 | 128,
+          codePoint & 63 | 128
+        );
+      } else {
+        throw new Error("Invalid code point");
+      }
+    }
+    return bytes;
+  }
+  function asciiToBytes(str) {
+    var byteArray = [];
+    for (var i = 0; i < str.length; ++i) {
+      byteArray.push(str.charCodeAt(i) & 255);
+    }
+    return byteArray;
+  }
+  function utf16leToBytes(str, units) {
+    var c, hi, lo;
+    var byteArray = [];
+    for (var i = 0; i < str.length; ++i) {
+      if ((units -= 2) < 0) break;
+      c = str.charCodeAt(i);
+      hi = c >> 8;
+      lo = c % 256;
+      byteArray.push(lo);
+      byteArray.push(hi);
+    }
+    return byteArray;
+  }
+  function base64ToBytes(str) {
+    return toByteArray(base64clean(str));
+  }
+  function blitBuffer(src, dst, offset, length) {
+    for (var i = 0; i < length; ++i) {
+      if (i + offset >= dst.length || i >= src.length) break;
+      dst[i + offset] = src[i];
+    }
+    return i;
+  }
+  function isnan(val) {
+    return val !== val;
+  }
+  function isBuffer(obj) {
+    return obj != null && (!!obj._isBuffer || isFastBuffer(obj) || isSlowBuffer(obj));
+  }
+  function isFastBuffer(obj) {
+    return !!obj.constructor && typeof obj.constructor.isBuffer === "function" && obj.constructor.isBuffer(obj);
+  }
+  function isSlowBuffer(obj) {
+    return typeof obj.readFloatLE === "function" && typeof obj.slice === "function" && isFastBuffer(obj.slice(0, 0));
+  }
+  var domain;
+  function EventHandlers() {
+  }
+  EventHandlers.prototype = /* @__PURE__ */ Object.create(null);
+  function EventEmitter() {
+    EventEmitter.init.call(this);
+  }
+  EventEmitter.EventEmitter = EventEmitter;
+  EventEmitter.usingDomains = false;
+  EventEmitter.prototype.domain = void 0;
+  EventEmitter.prototype._events = void 0;
+  EventEmitter.prototype._maxListeners = void 0;
+  EventEmitter.defaultMaxListeners = 10;
+  EventEmitter.init = function() {
+    this.domain = null;
+    if (EventEmitter.usingDomains) {
+      if (domain.active) ;
+    }
+    if (!this._events || this._events === Object.getPrototypeOf(this)._events) {
+      this._events = new EventHandlers();
+      this._eventsCount = 0;
+    }
+    this._maxListeners = this._maxListeners || void 0;
+  };
+  EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) {
+    if (typeof n !== "number" || n < 0 || isNaN(n))
+      throw new TypeError('"n" argument must be a positive number');
+    this._maxListeners = n;
+    return this;
+  };
+  function $getMaxListeners(that) {
+    if (that._maxListeners === void 0)
+      return EventEmitter.defaultMaxListeners;
+    return that._maxListeners;
+  }
+  EventEmitter.prototype.getMaxListeners = function getMaxListeners() {
+    return $getMaxListeners(this);
+  };
+  function emitNone(handler, isFn, self2) {
+    if (isFn)
+      handler.call(self2);
+    else {
+      var len = handler.length;
+      var listeners2 = arrayClone(handler, len);
+      for (var i = 0; i < len; ++i)
+        listeners2[i].call(self2);
+    }
+  }
+  function emitOne(handler, isFn, self2, arg1) {
+    if (isFn)
+      handler.call(self2, arg1);
+    else {
+      var len = handler.length;
+      var listeners2 = arrayClone(handler, len);
+      for (var i = 0; i < len; ++i)
+        listeners2[i].call(self2, arg1);
+    }
+  }
+  function emitTwo(handler, isFn, self2, arg1, arg2) {
+    if (isFn)
+      handler.call(self2, arg1, arg2);
+    else {
+      var len = handler.length;
+      var listeners2 = arrayClone(handler, len);
+      for (var i = 0; i < len; ++i)
+        listeners2[i].call(self2, arg1, arg2);
+    }
+  }
+  function emitThree(handler, isFn, self2, arg1, arg2, arg3) {
+    if (isFn)
+      handler.call(self2, arg1, arg2, arg3);
+    else {
+      var len = handler.length;
+      var listeners2 = arrayClone(handler, len);
+      for (var i = 0; i < len; ++i)
+        listeners2[i].call(self2, arg1, arg2, arg3);
+    }
+  }
+  function emitMany(handler, isFn, self2, args) {
+    if (isFn)
+      handler.apply(self2, args);
+    else {
+      var len = handler.length;
+      var listeners2 = arrayClone(handler, len);
+      for (var i = 0; i < len; ++i)
+        listeners2[i].apply(self2, args);
+    }
+  }
+  EventEmitter.prototype.emit = function emit(type) {
+    var er, handler, len, args, i, events, domain2;
+    var doError = type === "error";
+    events = this._events;
+    if (events)
+      doError = doError && events.error == null;
+    else if (!doError)
+      return false;
+    domain2 = this.domain;
+    if (doError) {
+      er = arguments[1];
+      if (domain2) {
+        if (!er)
+          er = new Error('Uncaught, unspecified "error" event');
+        er.domainEmitter = this;
+        er.domain = domain2;
+        er.domainThrown = false;
+        domain2.emit("error", er);
+      } else if (er instanceof Error) {
+        throw er;
+      } else {
+        var err = new Error('Uncaught, unspecified "error" event. (' + er + ")");
+        err.context = er;
+        throw err;
+      }
+      return false;
+    }
+    handler = events[type];
+    if (!handler)
+      return false;
+    var isFn = typeof handler === "function";
+    len = arguments.length;
+    switch (len) {
+      // fast cases
+      case 1:
+        emitNone(handler, isFn, this);
+        break;
+      case 2:
+        emitOne(handler, isFn, this, arguments[1]);
+        break;
+      case 3:
+        emitTwo(handler, isFn, this, arguments[1], arguments[2]);
+        break;
+      case 4:
+        emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]);
+        break;
+      // slower
+      default:
+        args = new Array(len - 1);
+        for (i = 1; i < len; i++)
+          args[i - 1] = arguments[i];
+        emitMany(handler, isFn, this, args);
+    }
+    return true;
+  };
+  function _addListener(target, type, listener, prepend) {
+    var m;
+    var events;
+    var existing;
+    if (typeof listener !== "function")
+      throw new TypeError('"listener" argument must be a function');
+    events = target._events;
+    if (!events) {
+      events = target._events = new EventHandlers();
+      target._eventsCount = 0;
+    } else {
+      if (events.newListener) {
+        target.emit(
+          "newListener",
+          type,
+          listener.listener ? listener.listener : listener
+        );
+        events = target._events;
+      }
+      existing = events[type];
+    }
+    if (!existing) {
+      existing = events[type] = listener;
+      ++target._eventsCount;
+    } else {
+      if (typeof existing === "function") {
+        existing = events[type] = prepend ? [listener, existing] : [existing, listener];
+      } else {
+        if (prepend) {
+          existing.unshift(listener);
+        } else {
+          existing.push(listener);
+        }
+      }
+      if (!existing.warned) {
+        m = $getMaxListeners(target);
+        if (m && m > 0 && existing.length > m) {
+          existing.warned = true;
+          var w = new Error("Possible EventEmitter memory leak detected. " + existing.length + " " + type + " listeners added. Use emitter.setMaxListeners() to increase limit");
+          w.name = "MaxListenersExceededWarning";
+          w.emitter = target;
+          w.type = type;
+          w.count = existing.length;
+          emitWarning(w);
+        }
+      }
+    }
+    return target;
+  }
+  function emitWarning(e) {
+    typeof console.warn === "function" ? console.warn(e) : console.log(e);
+  }
+  EventEmitter.prototype.addListener = function addListener(type, listener) {
+    return _addListener(this, type, listener, false);
+  };
+  EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+  EventEmitter.prototype.prependListener = function prependListener(type, listener) {
+    return _addListener(this, type, listener, true);
+  };
+  function _onceWrap(target, type, listener) {
+    var fired = false;
+    function g() {
+      target.removeListener(type, g);
+      if (!fired) {
+        fired = true;
+        listener.apply(target, arguments);
+      }
+    }
+    g.listener = listener;
+    return g;
+  }
+  EventEmitter.prototype.once = function once(type, listener) {
+    if (typeof listener !== "function")
+      throw new TypeError('"listener" argument must be a function');
+    this.on(type, _onceWrap(this, type, listener));
+    return this;
+  };
+  EventEmitter.prototype.prependOnceListener = function prependOnceListener(type, listener) {
+    if (typeof listener !== "function")
+      throw new TypeError('"listener" argument must be a function');
+    this.prependListener(type, _onceWrap(this, type, listener));
+    return this;
+  };
+  EventEmitter.prototype.removeListener = function removeListener(type, listener) {
+    var list, events, position, i, originalListener;
+    if (typeof listener !== "function")
+      throw new TypeError('"listener" argument must be a function');
+    events = this._events;
+    if (!events)
+      return this;
+    list = events[type];
+    if (!list)
+      return this;
+    if (list === listener || list.listener && list.listener === listener) {
+      if (--this._eventsCount === 0)
+        this._events = new EventHandlers();
+      else {
+        delete events[type];
+        if (events.removeListener)
+          this.emit("removeListener", type, list.listener || listener);
+      }
+    } else if (typeof list !== "function") {
+      position = -1;
+      for (i = list.length; i-- > 0; ) {
+        if (list[i] === listener || list[i].listener && list[i].listener === listener) {
+          originalListener = list[i].listener;
+          position = i;
+          break;
+        }
+      }
+      if (position < 0)
+        return this;
+      if (list.length === 1) {
+        list[0] = void 0;
+        if (--this._eventsCount === 0) {
+          this._events = new EventHandlers();
+          return this;
+        } else {
+          delete events[type];
+        }
+      } else {
+        spliceOne(list, position);
+      }
+      if (events.removeListener)
+        this.emit("removeListener", type, originalListener || listener);
+    }
+    return this;
+  };
+  EventEmitter.prototype.removeAllListeners = function removeAllListeners(type) {
+    var listeners2, events;
+    events = this._events;
+    if (!events)
+      return this;
+    if (!events.removeListener) {
+      if (arguments.length === 0) {
+        this._events = new EventHandlers();
+        this._eventsCount = 0;
+      } else if (events[type]) {
+        if (--this._eventsCount === 0)
+          this._events = new EventHandlers();
+        else
+          delete events[type];
+      }
+      return this;
+    }
+    if (arguments.length === 0) {
+      var keys2 = Object.keys(events);
+      for (var i = 0, key; i < keys2.length; ++i) {
+        key = keys2[i];
+        if (key === "removeListener") continue;
+        this.removeAllListeners(key);
+      }
+      this.removeAllListeners("removeListener");
+      this._events = new EventHandlers();
+      this._eventsCount = 0;
+      return this;
+    }
+    listeners2 = events[type];
+    if (typeof listeners2 === "function") {
+      this.removeListener(type, listeners2);
+    } else if (listeners2) {
+      do {
+        this.removeListener(type, listeners2[listeners2.length - 1]);
+      } while (listeners2[0]);
+    }
+    return this;
+  };
+  EventEmitter.prototype.listeners = function listeners(type) {
+    var evlistener;
+    var ret;
+    var events = this._events;
+    if (!events)
+      ret = [];
+    else {
+      evlistener = events[type];
+      if (!evlistener)
+        ret = [];
+      else if (typeof evlistener === "function")
+        ret = [evlistener.listener || evlistener];
+      else
+        ret = unwrapListeners(evlistener);
+    }
+    return ret;
+  };
+  EventEmitter.listenerCount = function(emitter, type) {
+    if (typeof emitter.listenerCount === "function") {
+      return emitter.listenerCount(type);
+    } else {
+      return listenerCount$1.call(emitter, type);
+    }
+  };
+  EventEmitter.prototype.listenerCount = listenerCount$1;
+  function listenerCount$1(type) {
+    var events = this._events;
+    if (events) {
+      var evlistener = events[type];
+      if (typeof evlistener === "function") {
+        return 1;
+      } else if (evlistener) {
+        return evlistener.length;
+      }
+    }
+    return 0;
+  }
+  EventEmitter.prototype.eventNames = function eventNames() {
+    return this._eventsCount > 0 ? Reflect.ownKeys(this._events) : [];
+  };
+  function spliceOne(list, index) {
+    for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1)
+      list[i] = list[k];
+    list.pop();
+  }
+  function arrayClone(arr, i) {
+    var copy2 = new Array(i);
+    while (i--)
+      copy2[i] = arr[i];
+    return copy2;
+  }
+  function unwrapListeners(arr) {
+    var ret = new Array(arr.length);
+    for (var i = 0; i < ret.length; ++i) {
+      ret[i] = arr[i].listener || arr[i];
+    }
+    return ret;
+  }
+  function defaultSetTimout() {
+    throw new Error("setTimeout has not been defined");
+  }
+  function defaultClearTimeout() {
+    throw new Error("clearTimeout has not been defined");
+  }
+  var cachedSetTimeout = defaultSetTimout;
+  var cachedClearTimeout = defaultClearTimeout;
+  if (typeof global$1.setTimeout === "function") {
+    cachedSetTimeout = setTimeout;
+  }
+  if (typeof global$1.clearTimeout === "function") {
+    cachedClearTimeout = clearTimeout;
+  }
+  function runTimeout(fun) {
+    if (cachedSetTimeout === setTimeout) {
+      return setTimeout(fun, 0);
+    }
+    if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) {
+      cachedSetTimeout = setTimeout;
+      return setTimeout(fun, 0);
+    }
+    try {
+      return cachedSetTimeout(fun, 0);
+    } catch (e) {
+      try {
+        return cachedSetTimeout.call(null, fun, 0);
+      } catch (e2) {
+        return cachedSetTimeout.call(this, fun, 0);
+      }
+    }
+  }
+  function runClearTimeout(marker) {
+    if (cachedClearTimeout === clearTimeout) {
+      return clearTimeout(marker);
+    }
+    if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) {
+      cachedClearTimeout = clearTimeout;
+      return clearTimeout(marker);
+    }
+    try {
+      return cachedClearTimeout(marker);
+    } catch (e) {
+      try {
+        return cachedClearTimeout.call(null, marker);
+      } catch (e2) {
+        return cachedClearTimeout.call(this, marker);
+      }
+    }
+  }
+  var queue = [];
+  var draining = false;
+  var currentQueue;
+  var queueIndex = -1;
+  function cleanUpNextTick() {
+    if (!draining || !currentQueue) {
+      return;
+    }
+    draining = false;
+    if (currentQueue.length) {
+      queue = currentQueue.concat(queue);
+    } else {
+      queueIndex = -1;
+    }
+    if (queue.length) {
+      drainQueue();
+    }
+  }
+  function drainQueue() {
+    if (draining) {
+      return;
+    }
+    var timeout = runTimeout(cleanUpNextTick);
+    draining = true;
+    var len = queue.length;
+    while (len) {
+      currentQueue = queue;
+      queue = [];
+      while (++queueIndex < len) {
+        if (currentQueue) {
+          currentQueue[queueIndex].run();
+        }
+      }
+      queueIndex = -1;
+      len = queue.length;
+    }
+    currentQueue = null;
+    draining = false;
+    runClearTimeout(timeout);
+  }
+  function nextTick(fun) {
+    var args = new Array(arguments.length - 1);
+    if (arguments.length > 1) {
+      for (var i = 1; i < arguments.length; i++) {
+        args[i - 1] = arguments[i];
+      }
+    }
+    queue.push(new Item(fun, args));
+    if (queue.length === 1 && !draining) {
+      runTimeout(drainQueue);
+    }
+  }
+  function Item(fun, array) {
+    this.fun = fun;
+    this.array = array;
+  }
+  Item.prototype.run = function() {
+    this.fun.apply(null, this.array);
+  };
+  var title = "browser";
+  var platform = "browser";
+  var browser = true;
+  var env = {};
+  var argv = [];
+  var version = "";
+  var versions = {};
+  var release = {};
+  var config = {};
+  function noop() {
+  }
+  var on = noop;
+  var addListener2 = noop;
+  var once2 = noop;
+  var off = noop;
+  var removeListener2 = noop;
+  var removeAllListeners2 = noop;
+  var emit2 = noop;
+  function binding(name) {
+    throw new Error("process.binding is not supported");
+  }
+  function cwd() {
+    return "/";
+  }
+  function chdir(dir) {
+    throw new Error("process.chdir is not supported");
+  }
+  function umask() {
+    return 0;
+  }
+  var performance = global$1.performance || {};
+  var performanceNow = performance.now || performance.mozNow || performance.msNow || performance.oNow || performance.webkitNow || function() {
+    return (/* @__PURE__ */ new Date()).getTime();
+  };
+  function hrtime(previousTimestamp) {
+    var clocktime = performanceNow.call(performance) * 1e-3;
+    var seconds = Math.floor(clocktime);
+    var nanoseconds = Math.floor(clocktime % 1 * 1e9);
+    if (previousTimestamp) {
+      seconds = seconds - previousTimestamp[0];
+      nanoseconds = nanoseconds - previousTimestamp[1];
+      if (nanoseconds < 0) {
+        seconds--;
+        nanoseconds += 1e9;
+      }
+    }
+    return [seconds, nanoseconds];
+  }
+  var startTime = /* @__PURE__ */ new Date();
+  function uptime() {
+    var currentTime = /* @__PURE__ */ new Date();
+    var dif = currentTime - startTime;
+    return dif / 1e3;
+  }
+  var process = {
+    nextTick,
+    title,
+    browser,
+    env,
+    argv,
+    version,
+    versions,
+    on,
+    addListener: addListener2,
+    once: once2,
+    off,
+    removeListener: removeListener2,
+    removeAllListeners: removeAllListeners2,
+    emit: emit2,
+    binding,
+    cwd,
+    chdir,
+    umask,
+    hrtime,
+    platform,
+    release,
+    config,
+    uptime
+  };
+  var inherits;
+  if (typeof Object.create === "function") {
+    inherits = function inherits2(ctor, superCtor) {
+      ctor.super_ = superCtor;
+      ctor.prototype = Object.create(superCtor.prototype, {
+        constructor: {
+          value: ctor,
+          enumerable: false,
+          writable: true,
+          configurable: true
+        }
+      });
+    };
+  } else {
+    inherits = function inherits2(ctor, superCtor) {
+      ctor.super_ = superCtor;
+      var TempCtor = function() {
+      };
+      TempCtor.prototype = superCtor.prototype;
+      ctor.prototype = new TempCtor();
+      ctor.prototype.constructor = ctor;
+    };
+  }
+  var inherits$1 = inherits;
+  var formatRegExp = /%[sdj%]/g;
+  function format(f) {
+    if (!isString(f)) {
+      var objects = [];
+      for (var i = 0; i < arguments.length; i++) {
+        objects.push(inspect2(arguments[i]));
+      }
+      return objects.join(" ");
+    }
+    var i = 1;
+    var args = arguments;
+    var len = args.length;
+    var str = String(f).replace(formatRegExp, function(x2) {
+      if (x2 === "%%") return "%";
+      if (i >= len) return x2;
+      switch (x2) {
+        case "%s":
+          return String(args[i++]);
+        case "%d":
+          return Number(args[i++]);
+        case "%j":
+          try {
+            return JSON.stringify(args[i++]);
+          } catch (_) {
+            return "[Circular]";
+          }
+        default:
+          return x2;
+      }
+    });
+    for (var x = args[i]; i < len; x = args[++i]) {
+      if (isNull(x) || !isObject(x)) {
+        str += " " + x;
+      } else {
+        str += " " + inspect2(x);
+      }
+    }
+    return str;
+  }
+  function deprecate(fn, msg) {
+    if (isUndefined(global$1.process)) {
+      return function() {
+        return deprecate(fn, msg).apply(this, arguments);
+      };
+    }
+    if (process.noDeprecation === true) {
+      return fn;
+    }
+    var warned = false;
+    function deprecated() {
+      if (!warned) {
+        if (process.throwDeprecation) {
+          throw new Error(msg);
+        } else if (process.traceDeprecation) {
+          console.trace(msg);
+        } else {
+          console.error(msg);
+        }
+        warned = true;
+      }
+      return fn.apply(this, arguments);
+    }
+    return deprecated;
+  }
+  var debugs = {};
+  var debugEnviron;
+  function debuglog(set) {
+    if (isUndefined(debugEnviron))
+      debugEnviron = process.env.NODE_DEBUG || "";
+    set = set.toUpperCase();
+    if (!debugs[set]) {
+      if (new RegExp("\\b" + set + "\\b", "i").test(debugEnviron)) {
+        var pid = 0;
+        debugs[set] = function() {
+          var msg = format.apply(null, arguments);
+          console.error("%s %d: %s", set, pid, msg);
+        };
+      } else {
+        debugs[set] = function() {
+        };
+      }
+    }
+    return debugs[set];
+  }
+  function inspect2(obj, opts) {
+    var ctx = {
+      seen: [],
+      stylize: stylizeNoColor
+    };
+    if (arguments.length >= 3) ctx.depth = arguments[2];
+    if (arguments.length >= 4) ctx.colors = arguments[3];
+    if (isBoolean(opts)) {
+      ctx.showHidden = opts;
+    } else if (opts) {
+      _extend(ctx, opts);
+    }
+    if (isUndefined(ctx.showHidden)) ctx.showHidden = false;
+    if (isUndefined(ctx.depth)) ctx.depth = 2;
+    if (isUndefined(ctx.colors)) ctx.colors = false;
+    if (isUndefined(ctx.customInspect)) ctx.customInspect = true;
+    if (ctx.colors) ctx.stylize = stylizeWithColor;
+    return formatValue(ctx, obj, ctx.depth);
+  }
+  inspect2.colors = {
+    "bold": [1, 22],
+    "italic": [3, 23],
+    "underline": [4, 24],
+    "inverse": [7, 27],
+    "white": [37, 39],
+    "grey": [90, 39],
+    "black": [30, 39],
+    "blue": [34, 39],
+    "cyan": [36, 39],
+    "green": [32, 39],
+    "magenta": [35, 39],
+    "red": [31, 39],
+    "yellow": [33, 39]
+  };
+  inspect2.styles = {
+    "special": "cyan",
+    "number": "yellow",
+    "boolean": "yellow",
+    "undefined": "grey",
+    "null": "bold",
+    "string": "green",
+    "date": "magenta",
+    // "name": intentionally not styling
+    "regexp": "red"
+  };
+  function stylizeWithColor(str, styleType) {
+    var style = inspect2.styles[styleType];
+    if (style) {
+      return "\x1B[" + inspect2.colors[style][0] + "m" + str + "\x1B[" + inspect2.colors[style][1] + "m";
+    } else {
+      return str;
+    }
+  }
+  function stylizeNoColor(str, styleType) {
+    return str;
+  }
+  function arrayToHash(array) {
+    var hash = {};
+    array.forEach(function(val, idx) {
+      hash[val] = true;
+    });
+    return hash;
+  }
+  function formatValue(ctx, value, recurseTimes) {
+    if (ctx.customInspect && value && isFunction(value.inspect) && // Filter out the util module, it's inspect function is special
+    value.inspect !== inspect2 && // Also filter out any prototype objects using the circular check.
+    !(value.constructor && value.constructor.prototype === value)) {
+      var ret = value.inspect(recurseTimes, ctx);
+      if (!isString(ret)) {
+        ret = formatValue(ctx, ret, recurseTimes);
+      }
+      return ret;
+    }
+    var primitive = formatPrimitive(ctx, value);
+    if (primitive) {
+      return primitive;
+    }
+    var keys2 = Object.keys(value);
+    var visibleKeys = arrayToHash(keys2);
+    if (ctx.showHidden) {
+      keys2 = Object.getOwnPropertyNames(value);
+    }
+    if (isError(value) && (keys2.indexOf("message") >= 0 || keys2.indexOf("description") >= 0)) {
+      return formatError(value);
+    }
+    if (keys2.length === 0) {
+      if (isFunction(value)) {
+        var name = value.name ? ": " + value.name : "";
+        return ctx.stylize("[Function" + name + "]", "special");
+      }
+      if (isRegExp(value)) {
+        return ctx.stylize(RegExp.prototype.toString.call(value), "regexp");
+      }
+      if (isDate(value)) {
+        return ctx.stylize(Date.prototype.toString.call(value), "date");
+      }
+      if (isError(value)) {
+        return formatError(value);
+      }
+    }
+    var base = "", array = false, braces = ["{", "}"];
+    if (isArray(value)) {
+      array = true;
+      braces = ["[", "]"];
+    }
+    if (isFunction(value)) {
+      var n = value.name ? ": " + value.name : "";
+      base = " [Function" + n + "]";
+    }
+    if (isRegExp(value)) {
+      base = " " + RegExp.prototype.toString.call(value);
+    }
+    if (isDate(value)) {
+      base = " " + Date.prototype.toUTCString.call(value);
+    }
+    if (isError(value)) {
+      base = " " + formatError(value);
+    }
+    if (keys2.length === 0 && (!array || value.length == 0)) {
+      return braces[0] + base + braces[1];
+    }
+    if (recurseTimes < 0) {
+      if (isRegExp(value)) {
+        return ctx.stylize(RegExp.prototype.toString.call(value), "regexp");
+      } else {
+        return ctx.stylize("[Object]", "special");
+      }
+    }
+    ctx.seen.push(value);
+    var output;
+    if (array) {
+      output = formatArray(ctx, value, recurseTimes, visibleKeys, keys2);
+    } else {
+      output = keys2.map(function(key) {
+        return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array);
+      });
+    }
+    ctx.seen.pop();
+    return reduceToSingleString(output, base, braces);
+  }
+  function formatPrimitive(ctx, value) {
+    if (isUndefined(value))
+      return ctx.stylize("undefined", "undefined");
+    if (isString(value)) {
+      var simple = "'" + JSON.stringify(value).replace(/^"|"$/g, "").replace(/'/g, "\\'").replace(/\\"/g, '"') + "'";
+      return ctx.stylize(simple, "string");
+    }
+    if (isNumber(value))
+      return ctx.stylize("" + value, "number");
+    if (isBoolean(value))
+      return ctx.stylize("" + value, "boolean");
+    if (isNull(value))
+      return ctx.stylize("null", "null");
+  }
+  function formatError(value) {
+    return "[" + Error.prototype.toString.call(value) + "]";
+  }
+  function formatArray(ctx, value, recurseTimes, visibleKeys, keys2) {
+    var output = [];
+    for (var i = 0, l = value.length; i < l; ++i) {
+      if (hasOwnProperty(value, String(i))) {
+        output.push(formatProperty(
+          ctx,
+          value,
+          recurseTimes,
+          visibleKeys,
+          String(i),
+          true
+        ));
+      } else {
+        output.push("");
+      }
+    }
+    keys2.forEach(function(key) {
+      if (!key.match(/^\d+$/)) {
+        output.push(formatProperty(
+          ctx,
+          value,
+          recurseTimes,
+          visibleKeys,
+          key,
+          true
+        ));
+      }
+    });
+    return output;
+  }
+  function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) {
+    var name, str, desc;
+    desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] };
+    if (desc.get) {
+      if (desc.set) {
+        str = ctx.stylize("[Getter/Setter]", "special");
+      } else {
+        str = ctx.stylize("[Getter]", "special");
+      }
+    } else {
+      if (desc.set) {
+        str = ctx.stylize("[Setter]", "special");
+      }
+    }
+    if (!hasOwnProperty(visibleKeys, key)) {
+      name = "[" + key + "]";
+    }
+    if (!str) {
+      if (ctx.seen.indexOf(desc.value) < 0) {
+        if (isNull(recurseTimes)) {
+          str = formatValue(ctx, desc.value, null);
+        } else {
+          str = formatValue(ctx, desc.value, recurseTimes - 1);
+        }
+        if (str.indexOf("\n") > -1) {
+          if (array) {
+            str = str.split("\n").map(function(line) {
+              return "  " + line;
+            }).join("\n").substr(2);
+          } else {
+            str = "\n" + str.split("\n").map(function(line) {
+              return "   " + line;
+            }).join("\n");
+          }
+        }
+      } else {
+        str = ctx.stylize("[Circular]", "special");
+      }
+    }
+    if (isUndefined(name)) {
+      if (array && key.match(/^\d+$/)) {
+        return str;
+      }
+      name = JSON.stringify("" + key);
+      if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) {
+        name = name.substr(1, name.length - 2);
+        name = ctx.stylize(name, "name");
+      } else {
+        name = name.replace(/'/g, "\\'").replace(/\\"/g, '"').replace(/(^"|"$)/g, "'");
+        name = ctx.stylize(name, "string");
+      }
+    }
+    return name + ": " + str;
+  }
+  function reduceToSingleString(output, base, braces) {
+    var length = output.reduce(function(prev, cur) {
+      if (cur.indexOf("\n") >= 0) ;
+      return prev + cur.replace(/\u001b\[\d\d?m/g, "").length + 1;
+    }, 0);
+    if (length > 60) {
+      return braces[0] + (base === "" ? "" : base + "\n ") + " " + output.join(",\n  ") + " " + braces[1];
+    }
+    return braces[0] + base + " " + output.join(", ") + " " + braces[1];
+  }
+  function isArray(ar) {
+    return Array.isArray(ar);
+  }
+  function isBoolean(arg) {
+    return typeof arg === "boolean";
+  }
+  function isNull(arg) {
+    return arg === null;
+  }
+  function isNumber(arg) {
+    return typeof arg === "number";
+  }
+  function isString(arg) {
+    return typeof arg === "string";
+  }
+  function isUndefined(arg) {
+    return arg === void 0;
+  }
+  function isRegExp(re) {
+    return isObject(re) && objectToString(re) === "[object RegExp]";
+  }
+  function isObject(arg) {
+    return typeof arg === "object" && arg !== null;
+  }
+  function isDate(d) {
+    return isObject(d) && objectToString(d) === "[object Date]";
+  }
+  function isError(e) {
+    return isObject(e) && (objectToString(e) === "[object Error]" || e instanceof Error);
+  }
+  function isFunction(arg) {
+    return typeof arg === "function";
+  }
+  function objectToString(o) {
+    return Object.prototype.toString.call(o);
+  }
+  function _extend(origin, add) {
+    if (!add || !isObject(add)) return origin;
+    var keys2 = Object.keys(add);
+    var i = keys2.length;
+    while (i--) {
+      origin[keys2[i]] = add[keys2[i]];
+    }
+    return origin;
+  }
+  function hasOwnProperty(obj, prop) {
+    return Object.prototype.hasOwnProperty.call(obj, prop);
+  }
+  function BufferList() {
+    this.head = null;
+    this.tail = null;
+    this.length = 0;
+  }
+  BufferList.prototype.push = function(v) {
+    var entry = { data: v, next: null };
+    if (this.length > 0) this.tail.next = entry;
+    else this.head = entry;
+    this.tail = entry;
+    ++this.length;
+  };
+  BufferList.prototype.unshift = function(v) {
+    var entry = { data: v, next: this.head };
+    if (this.length === 0) this.tail = entry;
+    this.head = entry;
+    ++this.length;
+  };
+  BufferList.prototype.shift = function() {
+    if (this.length === 0) return;
+    var ret = this.head.data;
+    if (this.length === 1) this.head = this.tail = null;
+    else this.head = this.head.next;
+    --this.length;
+    return ret;
+  };
+  BufferList.prototype.clear = function() {
+    this.head = this.tail = null;
+    this.length = 0;
+  };
+  BufferList.prototype.join = function(s) {
+    if (this.length === 0) return "";
+    var p = this.head;
+    var ret = "" + p.data;
+    while (p = p.next) {
+      ret += s + p.data;
+    }
+    return ret;
+  };
+  BufferList.prototype.concat = function(n) {
+    if (this.length === 0) return Buffer2.alloc(0);
+    if (this.length === 1) return this.head.data;
+    var ret = Buffer2.allocUnsafe(n >>> 0);
+    var p = this.head;
+    var i = 0;
+    while (p) {
+      p.data.copy(ret, i);
+      i += p.data.length;
+      p = p.next;
+    }
+    return ret;
+  };
+  var isBufferEncoding = Buffer2.isEncoding || function(encoding) {
+    switch (encoding && encoding.toLowerCase()) {
+      case "hex":
+      case "utf8":
+      case "utf-8":
+      case "ascii":
+      case "binary":
+      case "base64":
+      case "ucs2":
+      case "ucs-2":
+      case "utf16le":
+      case "utf-16le":
+      case "raw":
+        return true;
+      default:
+        return false;
+    }
+  };
+  function assertEncoding(encoding) {
+    if (encoding && !isBufferEncoding(encoding)) {
+      throw new Error("Unknown encoding: " + encoding);
+    }
+  }
+  function StringDecoder(encoding) {
+    this.encoding = (encoding || "utf8").toLowerCase().replace(/[-_]/, "");
+    assertEncoding(encoding);
+    switch (this.encoding) {
+      case "utf8":
+        this.surrogateSize = 3;
+        break;
+      case "ucs2":
+      case "utf16le":
+        this.surrogateSize = 2;
+        this.detectIncompleteChar = utf16DetectIncompleteChar;
+        break;
+      case "base64":
+        this.surrogateSize = 3;
+        this.detectIncompleteChar = base64DetectIncompleteChar;
+        break;
+      default:
+        this.write = passThroughWrite;
+        return;
+    }
+    this.charBuffer = new Buffer2(6);
+    this.charReceived = 0;
+    this.charLength = 0;
+  }
+  StringDecoder.prototype.write = function(buffer) {
+    var charStr = "";
+    while (this.charLength) {
+      var available = buffer.length >= this.charLength - this.charReceived ? this.charLength - this.charReceived : buffer.length;
+      buffer.copy(this.charBuffer, this.charReceived, 0, available);
+      this.charReceived += available;
+      if (this.charReceived < this.charLength) {
+        return "";
+      }
+      buffer = buffer.slice(available, buffer.length);
+      charStr = this.charBuffer.slice(0, this.charLength).toString(this.encoding);
+      var charCode = charStr.charCodeAt(charStr.length - 1);
+      if (charCode >= 55296 && charCode <= 56319) {
+        this.charLength += this.surrogateSize;
+        charStr = "";
+        continue;
+      }
+      this.charReceived = this.charLength = 0;
+      if (buffer.length === 0) {
+        return charStr;
+      }
+      break;
+    }
+    this.detectIncompleteChar(buffer);
+    var end = buffer.length;
+    if (this.charLength) {
+      buffer.copy(this.charBuffer, 0, buffer.length - this.charReceived, end);
+      end -= this.charReceived;
+    }
+    charStr += buffer.toString(this.encoding, 0, end);
+    var end = charStr.length - 1;
+    var charCode = charStr.charCodeAt(end);
+    if (charCode >= 55296 && charCode <= 56319) {
+      var size = this.surrogateSize;
+      this.charLength += size;
+      this.charReceived += size;
+      this.charBuffer.copy(this.charBuffer, size, 0, size);
+      buffer.copy(this.charBuffer, 0, 0, size);
+      return charStr.substring(0, end);
+    }
+    return charStr;
+  };
+  StringDecoder.prototype.detectIncompleteChar = function(buffer) {
+    var i = buffer.length >= 3 ? 3 : buffer.length;
+    for (; i > 0; i--) {
+      var c = buffer[buffer.length - i];
+      if (i == 1 && c >> 5 == 6) {
+        this.charLength = 2;
+        break;
+      }
+      if (i <= 2 && c >> 4 == 14) {
+        this.charLength = 3;
+        break;
+      }
+      if (i <= 3 && c >> 3 == 30) {
+        this.charLength = 4;
+        break;
+      }
+    }
+    this.charReceived = i;
+  };
+  StringDecoder.prototype.end = function(buffer) {
+    var res = "";
+    if (buffer && buffer.length)
+      res = this.write(buffer);
+    if (this.charReceived) {
+      var cr2 = this.charReceived;
+      var buf = this.charBuffer;
+      var enc = this.encoding;
+      res += buf.slice(0, cr2).toString(enc);
+    }
+    return res;
+  };
+  function passThroughWrite(buffer) {
+    return buffer.toString(this.encoding);
+  }
+  function utf16DetectIncompleteChar(buffer) {
+    this.charReceived = buffer.length % 2;
+    this.charLength = this.charReceived ? 2 : 0;
+  }
+  function base64DetectIncompleteChar(buffer) {
+    this.charReceived = buffer.length % 3;
+    this.charLength = this.charReceived ? 3 : 0;
+  }
+  Readable.ReadableState = ReadableState;
+  var debug = debuglog("stream");
+  inherits$1(Readable, EventEmitter);
+  function prependListener2(emitter, event, fn) {
+    if (typeof emitter.prependListener === "function") {
+      return emitter.prependListener(event, fn);
+    } else {
+      if (!emitter._events || !emitter._events[event])
+        emitter.on(event, fn);
+      else if (Array.isArray(emitter._events[event]))
+        emitter._events[event].unshift(fn);
+      else
+        emitter._events[event] = [fn, emitter._events[event]];
+    }
+  }
+  function listenerCount(emitter, type) {
+    return emitter.listeners(type).length;
+  }
+  function ReadableState(options, stream) {
+    options = options || {};
+    this.objectMode = !!options.objectMode;
+    if (stream instanceof Duplex) this.objectMode = this.objectMode || !!options.readableObjectMode;
+    var hwm = options.highWaterMark;
+    var defaultHwm = this.objectMode ? 16 : 16 * 1024;
+    this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm;
+    this.highWaterMark = ~~this.highWaterMark;
+    this.buffer = new BufferList();
+    this.length = 0;
+    this.pipes = null;
+    this.pipesCount = 0;
+    this.flowing = null;
+    this.ended = false;
+    this.endEmitted = false;
+    this.reading = false;
+    this.sync = true;
+    this.needReadable = false;
+    this.emittedReadable = false;
+    this.readableListening = false;
+    this.resumeScheduled = false;
+    this.defaultEncoding = options.defaultEncoding || "utf8";
+    this.ranOut = false;
+    this.awaitDrain = 0;
+    this.readingMore = false;
+    this.decoder = null;
+    this.encoding = null;
+    if (options.encoding) {
+      this.decoder = new StringDecoder(options.encoding);
+      this.encoding = options.encoding;
+    }
+  }
+  function Readable(options) {
+    if (!(this instanceof Readable)) return new Readable(options);
+    this._readableState = new ReadableState(options, this);
+    this.readable = true;
+    if (options && typeof options.read === "function") this._read = options.read;
+    EventEmitter.call(this);
+  }
+  Readable.prototype.push = function(chunk, encoding) {
+    var state = this._readableState;
+    if (!state.objectMode && typeof chunk === "string") {
+      encoding = encoding || state.defaultEncoding;
+      if (encoding !== state.encoding) {
+        chunk = Buffer2.from(chunk, encoding);
+        encoding = "";
+      }
+    }
+    return readableAddChunk(this, state, chunk, encoding, false);
+  };
+  Readable.prototype.unshift = function(chunk) {
+    var state = this._readableState;
+    return readableAddChunk(this, state, chunk, "", true);
+  };
+  Readable.prototype.isPaused = function() {
+    return this._readableState.flowing === false;
+  };
+  function readableAddChunk(stream, state, chunk, encoding, addToFront) {
+    var er = chunkInvalid(state, chunk);
+    if (er) {
+      stream.emit("error", er);
+    } else if (chunk === null) {
+      state.reading = false;
+      onEofChunk(stream, state);
+    } else if (state.objectMode || chunk && chunk.length > 0) {
+      if (state.ended && !addToFront) {
+        var e = new Error("stream.push() after EOF");
+        stream.emit("error", e);
+      } else if (state.endEmitted && addToFront) {
+        var _e = new Error("stream.unshift() after end event");
+        stream.emit("error", _e);
+      } else {
+        var skipAdd;
+        if (state.decoder && !addToFront && !encoding) {
+          chunk = state.decoder.write(chunk);
+          skipAdd = !state.objectMode && chunk.length === 0;
+        }
+        if (!addToFront) state.reading = false;
+        if (!skipAdd) {
+          if (state.flowing && state.length === 0 && !state.sync) {
+            stream.emit("data", chunk);
+            stream.read(0);
+          } else {
+            state.length += state.objectMode ? 1 : chunk.length;
+            if (addToFront) state.buffer.unshift(chunk);
+            else state.buffer.push(chunk);
+            if (state.needReadable) emitReadable(stream);
+          }
+        }
+        maybeReadMore(stream, state);
+      }
+    } else if (!addToFront) {
+      state.reading = false;
+    }
+    return needMoreData(state);
+  }
+  function needMoreData(state) {
+    return !state.ended && (state.needReadable || state.length < state.highWaterMark || state.length === 0);
+  }
+  Readable.prototype.setEncoding = function(enc) {
+    this._readableState.decoder = new StringDecoder(enc);
+    this._readableState.encoding = enc;
+    return this;
+  };
+  var MAX_HWM = 8388608;
+  function computeNewHighWaterMark(n) {
+    if (n >= MAX_HWM) {
+      n = MAX_HWM;
+    } else {
+      n--;
+      n |= n >>> 1;
+      n |= n >>> 2;
+      n |= n >>> 4;
+      n |= n >>> 8;
+      n |= n >>> 16;
+      n++;
+    }
+    return n;
+  }
+  function howMuchToRead(n, state) {
+    if (n <= 0 || state.length === 0 && state.ended) return 0;
+    if (state.objectMode) return 1;
+    if (n !== n) {
+      if (state.flowing && state.length) return state.buffer.head.data.length;
+      else return state.length;
+    }
+    if (n > state.highWaterMark) state.highWaterMark = computeNewHighWaterMark(n);
+    if (n <= state.length) return n;
+    if (!state.ended) {
+      state.needReadable = true;
+      return 0;
+    }
+    return state.length;
+  }
+  Readable.prototype.read = function(n) {
+    debug("read", n);
+    n = parseInt(n, 10);
+    var state = this._readableState;
+    var nOrig = n;
+    if (n !== 0) state.emittedReadable = false;
+    if (n === 0 && state.needReadable && (state.length >= state.highWaterMark || state.ended)) {
+      debug("read: emitReadable", state.length, state.ended);
+      if (state.length === 0 && state.ended) endReadable(this);
+      else emitReadable(this);
+      return null;
+    }
+    n = howMuchToRead(n, state);
+    if (n === 0 && state.ended) {
+      if (state.length === 0) endReadable(this);
+      return null;
+    }
+    var doRead = state.needReadable;
+    debug("need readable", doRead);
+    if (state.length === 0 || state.length - n < state.highWaterMark) {
+      doRead = true;
+      debug("length less than watermark", doRead);
+    }
+    if (state.ended || state.reading) {
+      doRead = false;
+      debug("reading or ended", doRead);
+    } else if (doRead) {
+      debug("do read");
+      state.reading = true;
+      state.sync = true;
+      if (state.length === 0) state.needReadable = true;
+      this._read(state.highWaterMark);
+      state.sync = false;
+      if (!state.reading) n = howMuchToRead(nOrig, state);
+    }
+    var ret;
+    if (n > 0) ret = fromList(n, state);
+    else ret = null;
+    if (ret === null) {
+      state.needReadable = true;
+      n = 0;
+    } else {
+      state.length -= n;
+    }
+    if (state.length === 0) {
+      if (!state.ended) state.needReadable = true;
+      if (nOrig !== n && state.ended) endReadable(this);
+    }
+    if (ret !== null) this.emit("data", ret);
+    return ret;
+  };
+  function chunkInvalid(state, chunk) {
+    var er = null;
+    if (!isBuffer(chunk) && typeof chunk !== "string" && chunk !== null && chunk !== void 0 && !state.objectMode) {
+      er = new TypeError("Invalid non-string/buffer chunk");
+    }
+    return er;
+  }
+  function onEofChunk(stream, state) {
+    if (state.ended) return;
+    if (state.decoder) {
+      var chunk = state.decoder.end();
+      if (chunk && chunk.length) {
+        state.buffer.push(chunk);
+        state.length += state.objectMode ? 1 : chunk.length;
+      }
+    }
+    state.ended = true;
+    emitReadable(stream);
+  }
+  function emitReadable(stream) {
+    var state = stream._readableState;
+    state.needReadable = false;
+    if (!state.emittedReadable) {
+      debug("emitReadable", state.flowing);
+      state.emittedReadable = true;
+      if (state.sync) nextTick(emitReadable_, stream);
+      else emitReadable_(stream);
+    }
+  }
+  function emitReadable_(stream) {
+    debug("emit readable");
+    stream.emit("readable");
+    flow(stream);
+  }
+  function maybeReadMore(stream, state) {
+    if (!state.readingMore) {
+      state.readingMore = true;
+      nextTick(maybeReadMore_, stream, state);
+    }
+  }
+  function maybeReadMore_(stream, state) {
+    var len = state.length;
+    while (!state.reading && !state.flowing && !state.ended && state.length < state.highWaterMark) {
+      debug("maybeReadMore read 0");
+      stream.read(0);
+      if (len === state.length)
+        break;
+      else len = state.length;
+    }
+    state.readingMore = false;
+  }
+  Readable.prototype._read = function(n) {
+    this.emit("error", new Error("not implemented"));
+  };
+  Readable.prototype.pipe = function(dest, pipeOpts) {
+    var src = this;
+    var state = this._readableState;
+    switch (state.pipesCount) {
+      case 0:
+        state.pipes = dest;
+        break;
+      case 1:
+        state.pipes = [state.pipes, dest];
+        break;
+      default:
+        state.pipes.push(dest);
+        break;
+    }
+    state.pipesCount += 1;
+    debug("pipe count=%d opts=%j", state.pipesCount, pipeOpts);
+    var doEnd = !pipeOpts || pipeOpts.end !== false;
+    var endFn = doEnd ? onend2 : cleanup;
+    if (state.endEmitted) nextTick(endFn);
+    else src.once("end", endFn);
+    dest.on("unpipe", onunpipe);
+    function onunpipe(readable) {
+      debug("onunpipe");
+      if (readable === src) {
+        cleanup();
+      }
+    }
+    function onend2() {
+      debug("onend");
+      dest.end();
+    }
+    var ondrain = pipeOnDrain(src);
+    dest.on("drain", ondrain);
+    var cleanedUp = false;
+    function cleanup() {
+      debug("cleanup");
+      dest.removeListener("close", onclose);
+      dest.removeListener("finish", onfinish);
+      dest.removeListener("drain", ondrain);
+      dest.removeListener("error", onerror);
+      dest.removeListener("unpipe", onunpipe);
+      src.removeListener("end", onend2);
+      src.removeListener("end", cleanup);
+      src.removeListener("data", ondata);
+      cleanedUp = true;
+      if (state.awaitDrain && (!dest._writableState || dest._writableState.needDrain)) ondrain();
+    }
+    var increasedAwaitDrain = false;
+    src.on("data", ondata);
+    function ondata(chunk) {
+      debug("ondata");
+      increasedAwaitDrain = false;
+      var ret = dest.write(chunk);
+      if (false === ret && !increasedAwaitDrain) {
+        if ((state.pipesCount === 1 && state.pipes === dest || state.pipesCount > 1 && indexOf2(state.pipes, dest) !== -1) && !cleanedUp) {
+          debug("false write response, pause", src._readableState.awaitDrain);
+          src._readableState.awaitDrain++;
+          increasedAwaitDrain = true;
+        }
+        src.pause();
+      }
+    }
+    function onerror(er) {
+      debug("onerror", er);
+      unpipe();
+      dest.removeListener("error", onerror);
+      if (listenerCount(dest, "error") === 0) dest.emit("error", er);
+    }
+    prependListener2(dest, "error", onerror);
+    function onclose() {
+      dest.removeListener("finish", onfinish);
+      unpipe();
+    }
+    dest.once("close", onclose);
+    function onfinish() {
+      debug("onfinish");
+      dest.removeListener("close", onclose);
+      unpipe();
+    }
+    dest.once("finish", onfinish);
+    function unpipe() {
+      debug("unpipe");
+      src.unpipe(dest);
+    }
+    dest.emit("pipe", src);
+    if (!state.flowing) {
+      debug("pipe resume");
+      src.resume();
+    }
+    return dest;
+  };
+  function pipeOnDrain(src) {
+    return function() {
+      var state = src._readableState;
+      debug("pipeOnDrain", state.awaitDrain);
+      if (state.awaitDrain) state.awaitDrain--;
+      if (state.awaitDrain === 0 && src.listeners("data").length) {
+        state.flowing = true;
+        flow(src);
+      }
+    };
+  }
+  Readable.prototype.unpipe = function(dest) {
+    var state = this._readableState;
+    if (state.pipesCount === 0) return this;
+    if (state.pipesCount === 1) {
+      if (dest && dest !== state.pipes) return this;
+      if (!dest) dest = state.pipes;
+      state.pipes = null;
+      state.pipesCount = 0;
+      state.flowing = false;
+      if (dest) dest.emit("unpipe", this);
+      return this;
+    }
+    if (!dest) {
+      var dests = state.pipes;
+      var len = state.pipesCount;
+      state.pipes = null;
+      state.pipesCount = 0;
+      state.flowing = false;
+      for (var _i = 0; _i < len; _i++) {
+        dests[_i].emit("unpipe", this);
+      }
+      return this;
+    }
+    var i = indexOf2(state.pipes, dest);
+    if (i === -1) return this;
+    state.pipes.splice(i, 1);
+    state.pipesCount -= 1;
+    if (state.pipesCount === 1) state.pipes = state.pipes[0];
+    dest.emit("unpipe", this);
+    return this;
+  };
+  Readable.prototype.on = function(ev, fn) {
+    var res = EventEmitter.prototype.on.call(this, ev, fn);
+    if (ev === "data") {
+      if (this._readableState.flowing !== false) this.resume();
+    } else if (ev === "readable") {
+      var state = this._readableState;
+      if (!state.endEmitted && !state.readableListening) {
+        state.readableListening = state.needReadable = true;
+        state.emittedReadable = false;
+        if (!state.reading) {
+          nextTick(nReadingNextTick, this);
+        } else if (state.length) {
+          emitReadable(this);
+        }
+      }
+    }
+    return res;
+  };
+  Readable.prototype.addListener = Readable.prototype.on;
+  function nReadingNextTick(self2) {
+    debug("readable nexttick read 0");
+    self2.read(0);
+  }
+  Readable.prototype.resume = function() {
+    var state = this._readableState;
+    if (!state.flowing) {
+      debug("resume");
+      state.flowing = true;
+      resume(this, state);
+    }
+    return this;
+  };
+  function resume(stream, state) {
+    if (!state.resumeScheduled) {
+      state.resumeScheduled = true;
+      nextTick(resume_, stream, state);
+    }
+  }
+  function resume_(stream, state) {
+    if (!state.reading) {
+      debug("resume read 0");
+      stream.read(0);
+    }
+    state.resumeScheduled = false;
+    state.awaitDrain = 0;
+    stream.emit("resume");
+    flow(stream);
+    if (state.flowing && !state.reading) stream.read(0);
+  }
+  Readable.prototype.pause = function() {
+    debug("call pause flowing=%j", this._readableState.flowing);
+    if (false !== this._readableState.flowing) {
+      debug("pause");
+      this._readableState.flowing = false;
+      this.emit("pause");
+    }
+    return this;
+  };
+  function flow(stream) {
+    var state = stream._readableState;
+    debug("flow", state.flowing);
+    while (state.flowing && stream.read() !== null) {
+    }
+  }
+  Readable.prototype.wrap = function(stream) {
+    var state = this._readableState;
+    var paused = false;
+    var self2 = this;
+    stream.on("end", function() {
+      debug("wrapped end");
+      if (state.decoder && !state.ended) {
+        var chunk = state.decoder.end();
+        if (chunk && chunk.length) self2.push(chunk);
+      }
+      self2.push(null);
+    });
+    stream.on("data", function(chunk) {
+      debug("wrapped data");
+      if (state.decoder) chunk = state.decoder.write(chunk);
+      if (state.objectMode && (chunk === null || chunk === void 0)) return;
+      else if (!state.objectMode && (!chunk || !chunk.length)) return;
+      var ret = self2.push(chunk);
+      if (!ret) {
+        paused = true;
+        stream.pause();
+      }
+    });
+    for (var i in stream) {
+      if (this[i] === void 0 && typeof stream[i] === "function") {
+        this[i] = /* @__PURE__ */ (function(method) {
+          return function() {
+            return stream[method].apply(stream, arguments);
+          };
+        })(i);
+      }
+    }
+    var events = ["error", "close", "destroy", "pause", "resume"];
+    forEach(events, function(ev) {
+      stream.on(ev, self2.emit.bind(self2, ev));
+    });
+    self2._read = function(n) {
+      debug("wrapped _read", n);
+      if (paused) {
+        paused = false;
+        stream.resume();
+      }
+    };
+    return self2;
+  };
+  Readable._fromList = fromList;
+  function fromList(n, state) {
+    if (state.length === 0) return null;
+    var ret;
+    if (state.objectMode) ret = state.buffer.shift();
+    else if (!n || n >= state.length) {
+      if (state.decoder) ret = state.buffer.join("");
+      else if (state.buffer.length === 1) ret = state.buffer.head.data;
+      else ret = state.buffer.concat(state.length);
+      state.buffer.clear();
+    } else {
+      ret = fromListPartial(n, state.buffer, state.decoder);
+    }
+    return ret;
+  }
+  function fromListPartial(n, list, hasStrings) {
+    var ret;
+    if (n < list.head.data.length) {
+      ret = list.head.data.slice(0, n);
+      list.head.data = list.head.data.slice(n);
+    } else if (n === list.head.data.length) {
+      ret = list.shift();
+    } else {
+      ret = hasStrings ? copyFromBufferString(n, list) : copyFromBuffer(n, list);
+    }
+    return ret;
+  }
+  function copyFromBufferString(n, list) {
+    var p = list.head;
+    var c = 1;
+    var ret = p.data;
+    n -= ret.length;
+    while (p = p.next) {
+      var str = p.data;
+      var nb = n > str.length ? str.length : n;
+      if (nb === str.length) ret += str;
+      else ret += str.slice(0, n);
+      n -= nb;
+      if (n === 0) {
+        if (nb === str.length) {
+          ++c;
+          if (p.next) list.head = p.next;
+          else list.head = list.tail = null;
+        } else {
+          list.head = p;
+          p.data = str.slice(nb);
+        }
+        break;
+      }
+      ++c;
+    }
+    list.length -= c;
+    return ret;
+  }
+  function copyFromBuffer(n, list) {
+    var ret = Buffer2.allocUnsafe(n);
+    var p = list.head;
+    var c = 1;
+    p.data.copy(ret);
+    n -= p.data.length;
+    while (p = p.next) {
+      var buf = p.data;
+      var nb = n > buf.length ? buf.length : n;
+      buf.copy(ret, ret.length - n, 0, nb);
+      n -= nb;
+      if (n === 0) {
+        if (nb === buf.length) {
+          ++c;
+          if (p.next) list.head = p.next;
+          else list.head = list.tail = null;
+        } else {
+          list.head = p;
+          p.data = buf.slice(nb);
+        }
+        break;
+      }
+      ++c;
+    }
+    list.length -= c;
+    return ret;
+  }
+  function endReadable(stream) {
+    var state = stream._readableState;
+    if (state.length > 0) throw new Error('"endReadable()" called on non-empty stream');
+    if (!state.endEmitted) {
+      state.ended = true;
+      nextTick(endReadableNT, state, stream);
+    }
+  }
+  function endReadableNT(state, stream) {
+    if (!state.endEmitted && state.length === 0) {
+      state.endEmitted = true;
+      stream.readable = false;
+      stream.emit("end");
+    }
+  }
+  function forEach(xs, f) {
+    for (var i = 0, l = xs.length; i < l; i++) {
+      f(xs[i], i);
+    }
+  }
+  function indexOf2(xs, x) {
+    for (var i = 0, l = xs.length; i < l; i++) {
+      if (xs[i] === x) return i;
+    }
+    return -1;
+  }
+  Writable.WritableState = WritableState;
+  inherits$1(Writable, EventEmitter);
+  function nop() {
+  }
+  function WriteReq(chunk, encoding, cb) {
+    this.chunk = chunk;
+    this.encoding = encoding;
+    this.callback = cb;
+    this.next = null;
+  }
+  function WritableState(options, stream) {
+    Object.defineProperty(this, "buffer", {
+      get: deprecate(function() {
+        return this.getBuffer();
+      }, "_writableState.buffer is deprecated. Use _writableState.getBuffer instead.")
+    });
+    options = options || {};
+    this.objectMode = !!options.objectMode;
+    if (stream instanceof Duplex) this.objectMode = this.objectMode || !!options.writableObjectMode;
+    var hwm = options.highWaterMark;
+    var defaultHwm = this.objectMode ? 16 : 16 * 1024;
+    this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm;
+    this.highWaterMark = ~~this.highWaterMark;
+    this.needDrain = false;
+    this.ending = false;
+    this.ended = false;
+    this.finished = false;
+    var noDecode = options.decodeStrings === false;
+    this.decodeStrings = !noDecode;
+    this.defaultEncoding = options.defaultEncoding || "utf8";
+    this.length = 0;
+    this.writing = false;
+    this.corked = 0;
+    this.sync = true;
+    this.bufferProcessing = false;
+    this.onwrite = function(er) {
+      onwrite(stream, er);
+    };
+    this.writecb = null;
+    this.writelen = 0;
+    this.bufferedRequest = null;
+    this.lastBufferedRequest = null;
+    this.pendingcb = 0;
+    this.prefinished = false;
+    this.errorEmitted = false;
+    this.bufferedRequestCount = 0;
+    this.corkedRequestsFree = new CorkedRequest(this);
+  }
+  WritableState.prototype.getBuffer = function writableStateGetBuffer() {
+    var current = this.bufferedRequest;
+    var out = [];
+    while (current) {
+      out.push(current);
+      current = current.next;
+    }
+    return out;
+  };
+  function Writable(options) {
+    if (!(this instanceof Writable) && !(this instanceof Duplex)) return new Writable(options);
+    this._writableState = new WritableState(options, this);
+    this.writable = true;
+    if (options) {
+      if (typeof options.write === "function") this._write = options.write;
+      if (typeof options.writev === "function") this._writev = options.writev;
+    }
+    EventEmitter.call(this);
+  }
+  Writable.prototype.pipe = function() {
+    this.emit("error", new Error("Cannot pipe, not readable"));
+  };
+  function writeAfterEnd(stream, cb) {
+    var er = new Error("write after end");
+    stream.emit("error", er);
+    nextTick(cb, er);
+  }
+  function validChunk(stream, state, chunk, cb) {
+    var valid = true;
+    var er = false;
+    if (chunk === null) {
+      er = new TypeError("May not write null values to stream");
+    } else if (!Buffer2.isBuffer(chunk) && typeof chunk !== "string" && chunk !== void 0 && !state.objectMode) {
+      er = new TypeError("Invalid non-string/buffer chunk");
+    }
+    if (er) {
+      stream.emit("error", er);
+      nextTick(cb, er);
+      valid = false;
+    }
+    return valid;
+  }
+  Writable.prototype.write = function(chunk, encoding, cb) {
+    var state = this._writableState;
+    var ret = false;
+    if (typeof encoding === "function") {
+      cb = encoding;
+      encoding = null;
+    }
+    if (Buffer2.isBuffer(chunk)) encoding = "buffer";
+    else if (!encoding) encoding = state.defaultEncoding;
+    if (typeof cb !== "function") cb = nop;
+    if (state.ended) writeAfterEnd(this, cb);
+    else if (validChunk(this, state, chunk, cb)) {
+      state.pendingcb++;
+      ret = writeOrBuffer(this, state, chunk, encoding, cb);
+    }
+    return ret;
+  };
+  Writable.prototype.cork = function() {
+    var state = this._writableState;
+    state.corked++;
+  };
+  Writable.prototype.uncork = function() {
+    var state = this._writableState;
+    if (state.corked) {
+      state.corked--;
+      if (!state.writing && !state.corked && !state.finished && !state.bufferProcessing && state.bufferedRequest) clearBuffer(this, state);
+    }
+  };
+  Writable.prototype.setDefaultEncoding = function setDefaultEncoding(encoding) {
+    if (typeof encoding === "string") encoding = encoding.toLowerCase();
+    if (!(["hex", "utf8", "utf-8", "ascii", "binary", "base64", "ucs2", "ucs-2", "utf16le", "utf-16le", "raw"].indexOf((encoding + "").toLowerCase()) > -1)) throw new TypeError("Unknown encoding: " + encoding);
+    this._writableState.defaultEncoding = encoding;
+    return this;
+  };
+  function decodeChunk(state, chunk, encoding) {
+    if (!state.objectMode && state.decodeStrings !== false && typeof chunk === "string") {
+      chunk = Buffer2.from(chunk, encoding);
+    }
+    return chunk;
+  }
+  function writeOrBuffer(stream, state, chunk, encoding, cb) {
+    chunk = decodeChunk(state, chunk, encoding);
+    if (Buffer2.isBuffer(chunk)) encoding = "buffer";
+    var len = state.objectMode ? 1 : chunk.length;
+    state.length += len;
+    var ret = state.length < state.highWaterMark;
+    if (!ret) state.needDrain = true;
+    if (state.writing || state.corked) {
+      var last = state.lastBufferedRequest;
+      state.lastBufferedRequest = new WriteReq(chunk, encoding, cb);
+      if (last) {
+        last.next = state.lastBufferedRequest;
+      } else {
+        state.bufferedRequest = state.lastBufferedRequest;
+      }
+      state.bufferedRequestCount += 1;
+    } else {
+      doWrite(stream, state, false, len, chunk, encoding, cb);
+    }
+    return ret;
+  }
+  function doWrite(stream, state, writev, len, chunk, encoding, cb) {
+    state.writelen = len;
+    state.writecb = cb;
+    state.writing = true;
+    state.sync = true;
+    if (writev) stream._writev(chunk, state.onwrite);
+    else stream._write(chunk, encoding, state.onwrite);
+    state.sync = false;
+  }
+  function onwriteError(stream, state, sync, er, cb) {
+    --state.pendingcb;
+    if (sync) nextTick(cb, er);
+    else cb(er);
+    stream._writableState.errorEmitted = true;
+    stream.emit("error", er);
+  }
+  function onwriteStateUpdate(state) {
+    state.writing = false;
+    state.writecb = null;
+    state.length -= state.writelen;
+    state.writelen = 0;
+  }
+  function onwrite(stream, er) {
+    var state = stream._writableState;
+    var sync = state.sync;
+    var cb = state.writecb;
+    onwriteStateUpdate(state);
+    if (er) onwriteError(stream, state, sync, er, cb);
+    else {
+      var finished = needFinish(state);
+      if (!finished && !state.corked && !state.bufferProcessing && state.bufferedRequest) {
+        clearBuffer(stream, state);
+      }
+      if (sync) {
+        nextTick(afterWrite, stream, state, finished, cb);
+      } else {
+        afterWrite(stream, state, finished, cb);
+      }
+    }
+  }
+  function afterWrite(stream, state, finished, cb) {
+    if (!finished) onwriteDrain(stream, state);
+    state.pendingcb--;
+    cb();
+    finishMaybe(stream, state);
+  }
+  function onwriteDrain(stream, state) {
+    if (state.length === 0 && state.needDrain) {
+      state.needDrain = false;
+      stream.emit("drain");
+    }
+  }
+  function clearBuffer(stream, state) {
+    state.bufferProcessing = true;
+    var entry = state.bufferedRequest;
+    if (stream._writev && entry && entry.next) {
+      var l = state.bufferedRequestCount;
+      var buffer = new Array(l);
+      var holder = state.corkedRequestsFree;
+      holder.entry = entry;
+      var count = 0;
+      while (entry) {
+        buffer[count] = entry;
+        entry = entry.next;
+        count += 1;
+      }
+      doWrite(stream, state, true, state.length, buffer, "", holder.finish);
+      state.pendingcb++;
+      state.lastBufferedRequest = null;
+      if (holder.next) {
+        state.corkedRequestsFree = holder.next;
+        holder.next = null;
+      } else {
+        state.corkedRequestsFree = new CorkedRequest(state);
+      }
+    } else {
+      while (entry) {
+        var chunk = entry.chunk;
+        var encoding = entry.encoding;
+        var cb = entry.callback;
+        var len = state.objectMode ? 1 : chunk.length;
+        doWrite(stream, state, false, len, chunk, encoding, cb);
+        entry = entry.next;
+        if (state.writing) {
+          break;
+        }
+      }
+      if (entry === null) state.lastBufferedRequest = null;
+    }
+    state.bufferedRequestCount = 0;
+    state.bufferedRequest = entry;
+    state.bufferProcessing = false;
+  }
+  Writable.prototype._write = function(chunk, encoding, cb) {
+    cb(new Error("not implemented"));
+  };
+  Writable.prototype._writev = null;
+  Writable.prototype.end = function(chunk, encoding, cb) {
+    var state = this._writableState;
+    if (typeof chunk === "function") {
+      cb = chunk;
+      chunk = null;
+      encoding = null;
+    } else if (typeof encoding === "function") {
+      cb = encoding;
+      encoding = null;
+    }
+    if (chunk !== null && chunk !== void 0) this.write(chunk, encoding);
+    if (state.corked) {
+      state.corked = 1;
+      this.uncork();
+    }
+    if (!state.ending && !state.finished) endWritable(this, state, cb);
+  };
+  function needFinish(state) {
+    return state.ending && state.length === 0 && state.bufferedRequest === null && !state.finished && !state.writing;
+  }
+  function prefinish(stream, state) {
+    if (!state.prefinished) {
+      state.prefinished = true;
+      stream.emit("prefinish");
+    }
+  }
+  function finishMaybe(stream, state) {
+    var need = needFinish(state);
+    if (need) {
+      if (state.pendingcb === 0) {
+        prefinish(stream, state);
+        state.finished = true;
+        stream.emit("finish");
+      } else {
+        prefinish(stream, state);
+      }
+    }
+    return need;
+  }
+  function endWritable(stream, state, cb) {
+    state.ending = true;
+    finishMaybe(stream, state);
+    if (cb) {
+      if (state.finished) nextTick(cb);
+      else stream.once("finish", cb);
+    }
+    state.ended = true;
+    stream.writable = false;
+  }
+  function CorkedRequest(state) {
+    var _this = this;
+    this.next = null;
+    this.entry = null;
+    this.finish = function(err) {
+      var entry = _this.entry;
+      _this.entry = null;
+      while (entry) {
+        var cb = entry.callback;
+        state.pendingcb--;
+        cb(err);
+        entry = entry.next;
+      }
+      if (state.corkedRequestsFree) {
+        state.corkedRequestsFree.next = _this;
+      } else {
+        state.corkedRequestsFree = _this;
+      }
+    };
+  }
+  inherits$1(Duplex, Readable);
+  var keys = Object.keys(Writable.prototype);
+  for (v = 0; v < keys.length; v++) {
+    method = keys[v];
+    if (!Duplex.prototype[method]) Duplex.prototype[method] = Writable.prototype[method];
+  }
+  var method;
+  var v;
+  function Duplex(options) {
+    if (!(this instanceof Duplex)) return new Duplex(options);
+    Readable.call(this, options);
+    Writable.call(this, options);
+    if (options && options.readable === false) this.readable = false;
+    if (options && options.writable === false) this.writable = false;
+    this.allowHalfOpen = true;
+    if (options && options.allowHalfOpen === false) this.allowHalfOpen = false;
+    this.once("end", onend);
+  }
+  function onend() {
+    if (this.allowHalfOpen || this._writableState.ended) return;
+    nextTick(onEndNT, this);
+  }
+  function onEndNT(self2) {
+    self2.end();
+  }
+  inherits$1(Transform, Duplex);
+  function TransformState(stream) {
+    this.afterTransform = function(er, data) {
+      return afterTransform(stream, er, data);
+    };
+    this.needTransform = false;
+    this.transforming = false;
+    this.writecb = null;
+    this.writechunk = null;
+    this.writeencoding = null;
+  }
+  function afterTransform(stream, er, data) {
+    var ts = stream._transformState;
+    ts.transforming = false;
+    var cb = ts.writecb;
+    if (!cb) return stream.emit("error", new Error("no writecb in Transform class"));
+    ts.writechunk = null;
+    ts.writecb = null;
+    if (data !== null && data !== void 0) stream.push(data);
+    cb(er);
+    var rs = stream._readableState;
+    rs.reading = false;
+    if (rs.needReadable || rs.length < rs.highWaterMark) {
+      stream._read(rs.highWaterMark);
+    }
+  }
+  function Transform(options) {
+    if (!(this instanceof Transform)) return new Transform(options);
+    Duplex.call(this, options);
+    this._transformState = new TransformState(this);
+    var stream = this;
+    this._readableState.needReadable = true;
+    this._readableState.sync = false;
+    if (options) {
+      if (typeof options.transform === "function") this._transform = options.transform;
+      if (typeof options.flush === "function") this._flush = options.flush;
+    }
+    this.once("prefinish", function() {
+      if (typeof this._flush === "function") this._flush(function(er) {
+        done(stream, er);
+      });
+      else done(stream);
+    });
+  }
+  Transform.prototype.push = function(chunk, encoding) {
+    this._transformState.needTransform = false;
+    return Duplex.prototype.push.call(this, chunk, encoding);
+  };
+  Transform.prototype._transform = function(chunk, encoding, cb) {
+    throw new Error("Not implemented");
+  };
+  Transform.prototype._write = function(chunk, encoding, cb) {
+    var ts = this._transformState;
+    ts.writecb = cb;
+    ts.writechunk = chunk;
+    ts.writeencoding = encoding;
+    if (!ts.transforming) {
+      var rs = this._readableState;
+      if (ts.needTransform || rs.needReadable || rs.length < rs.highWaterMark) this._read(rs.highWaterMark);
+    }
+  };
+  Transform.prototype._read = function(n) {
+    var ts = this._transformState;
+    if (ts.writechunk !== null && ts.writecb && !ts.transforming) {
+      ts.transforming = true;
+      this._transform(ts.writechunk, ts.writeencoding, ts.afterTransform);
+    } else {
+      ts.needTransform = true;
+    }
+  };
+  function done(stream, er) {
+    if (er) return stream.emit("error", er);
+    var ws = stream._writableState;
+    var ts = stream._transformState;
+    if (ws.length) throw new Error("Calling transform done when ws.length != 0");
+    if (ts.transforming) throw new Error("Calling transform done when still transforming");
+    return stream.push(null);
+  }
+  inherits$1(PassThrough, Transform);
+  function PassThrough(options) {
+    if (!(this instanceof PassThrough)) return new PassThrough(options);
+    Transform.call(this, options);
+  }
+  PassThrough.prototype._transform = function(chunk, encoding, cb) {
+    cb(null, chunk);
+  };
+  inherits$1(Stream, EventEmitter);
+  Stream.Readable = Readable;
+  Stream.Writable = Writable;
+  Stream.Duplex = Duplex;
+  Stream.Transform = Transform;
+  Stream.PassThrough = PassThrough;
+  Stream.Stream = Stream;
+  function Stream() {
+    EventEmitter.call(this);
+  }
+  Stream.prototype.pipe = function(dest, options) {
+    var source = this;
+    function ondata(chunk) {
+      if (dest.writable) {
+        if (false === dest.write(chunk) && source.pause) {
+          source.pause();
+        }
+      }
+    }
+    source.on("data", ondata);
+    function ondrain() {
+      if (source.readable && source.resume) {
+        source.resume();
+      }
+    }
+    dest.on("drain", ondrain);
+    if (!dest._isStdio && (!options || options.end !== false)) {
+      source.on("end", onend2);
+      source.on("close", onclose);
+    }
+    var didOnEnd = false;
+    function onend2() {
+      if (didOnEnd) return;
+      didOnEnd = true;
+      dest.end();
+    }
+    function onclose() {
+      if (didOnEnd) return;
+      didOnEnd = true;
+      if (typeof dest.destroy === "function") dest.destroy();
+    }
+    function onerror(er) {
+      cleanup();
+      if (EventEmitter.listenerCount(this, "error") === 0) {
+        throw er;
+      }
+    }
+    source.on("error", onerror);
+    dest.on("error", onerror);
+    function cleanup() {
+      source.removeListener("data", ondata);
+      dest.removeListener("drain", ondrain);
+      source.removeListener("end", onend2);
+      source.removeListener("close", onclose);
+      source.removeListener("error", onerror);
+      dest.removeListener("error", onerror);
+      source.removeListener("end", cleanup);
+      source.removeListener("close", cleanup);
+      dest.removeListener("close", cleanup);
+    }
+    source.on("end", cleanup);
+    source.on("close", cleanup);
+    dest.on("close", cleanup);
+    dest.emit("pipe", source);
+    return dest;
+  };
+  var is_object = function(obj) {
+    return typeof obj === "object" && obj !== null && !Array.isArray(obj);
+  };
+  var CsvError = class _CsvError extends Error {
+    constructor(code, message, options, ...contexts) {
+      if (Array.isArray(message)) message = message.join(" ").trim();
+      super(message);
+      if (Error.captureStackTrace !== void 0) {
+        Error.captureStackTrace(this, _CsvError);
+      }
+      this.code = code;
+      for (const context of contexts) {
+        for (const key in context) {
+          const value = context[key];
+          this[key] = isBuffer(value) ? value.toString(options.encoding) : value == null ? value : JSON.parse(JSON.stringify(value));
+        }
+      }
+    }
+  };
+  var normalize_columns_array = function(columns) {
+    const normalizedColumns = [];
+    for (let i = 0, l = columns.length; i < l; i++) {
+      const column = columns[i];
+      if (column === void 0 || column === null || column === false) {
+        normalizedColumns[i] = { disabled: true };
+      } else if (typeof column === "string") {
+        normalizedColumns[i] = { name: column };
+      } else if (is_object(column)) {
+        if (typeof column.name !== "string") {
+          throw new CsvError("CSV_OPTION_COLUMNS_MISSING_NAME", [
+            "Option columns missing name:",
+            `property "name" is required at position ${i}`,
+            "when column is an object literal"
+          ]);
+        }
+        normalizedColumns[i] = column;
+      } else {
+        throw new CsvError("CSV_INVALID_COLUMN_DEFINITION", [
+          "Invalid column definition:",
+          "expect a string or a literal object,",
+          `got ${JSON.stringify(column)} at position ${i}`
+        ]);
+      }
+    }
+    return normalizedColumns;
+  };
+  var ResizeableBuffer = class {
+    constructor(size = 100) {
+      this.size = size;
+      this.length = 0;
+      this.buf = Buffer2.allocUnsafe(size);
+    }
+    prepend(val) {
+      if (isBuffer(val)) {
+        const length = this.length + val.length;
+        if (length >= this.size) {
+          this.resize();
+          if (length >= this.size) {
+            throw Error("INVALID_BUFFER_STATE");
+          }
+        }
+        const buf = this.buf;
+        this.buf = Buffer2.allocUnsafe(this.size);
+        val.copy(this.buf, 0);
+        buf.copy(this.buf, val.length);
+        this.length += val.length;
+      } else {
+        const length = this.length++;
+        if (length === this.size) {
+          this.resize();
+        }
+        const buf = this.clone();
+        this.buf[0] = val;
+        buf.copy(this.buf, 1, 0, length);
+      }
+    }
+    append(val) {
+      const length = this.length++;
+      if (length === this.size) {
+        this.resize();
+      }
+      this.buf[length] = val;
+    }
+    clone() {
+      return Buffer2.from(this.buf.slice(0, this.length));
+    }
+    resize() {
+      const length = this.length;
+      this.size = this.size * 2;
+      const buf = Buffer2.allocUnsafe(this.size);
+      this.buf.copy(buf, 0, 0, length);
+      this.buf = buf;
+    }
+    toString(encoding) {
+      if (encoding) {
+        return this.buf.slice(0, this.length).toString(encoding);
+      } else {
+        return Uint8Array.prototype.slice.call(this.buf.slice(0, this.length));
+      }
+    }
+    toJSON() {
+      return this.toString("utf8");
+    }
+    reset() {
+      this.length = 0;
+    }
+  };
+  var np = 12;
+  var cr$1 = 13;
+  var nl$1 = 10;
+  var space = 32;
+  var tab = 9;
+  var init_state = function(options) {
+    return {
+      bomSkipped: false,
+      bufBytesStart: 0,
+      castField: options.cast_function,
+      commenting: false,
+      // Current error encountered by a record
+      error: void 0,
+      enabled: options.from_line === 1,
+      escaping: false,
+      escapeIsQuote: isBuffer(options.escape) && isBuffer(options.quote) && Buffer2.compare(options.escape, options.quote) === 0,
+      // columns can be `false`, `true`, `Array`
+      expectedRecordLength: Array.isArray(options.columns) ? options.columns.length : void 0,
+      field: new ResizeableBuffer(20),
+      firstLineToHeaders: options.cast_first_line_to_header,
+      needMoreDataSize: Math.max(
+        // Skip if the remaining buffer smaller than comment
+        options.comment !== null ? options.comment.length : 0,
+        ...options.delimiter.map((delimiter) => delimiter.length),
+        // Skip if the remaining buffer can be escape sequence
+        options.quote !== null ? options.quote.length : 0
+      ),
+      previousBuf: void 0,
+      quoting: false,
+      stop: false,
+      rawBuffer: new ResizeableBuffer(100),
+      record: [],
+      recordHasError: false,
+      record_length: 0,
+      recordDelimiterMaxLength: options.record_delimiter.length === 0 ? 0 : Math.max(...options.record_delimiter.map((v) => v.length)),
+      trimChars: [Buffer2.from(" ", options.encoding)[0], Buffer2.from("	", options.encoding)[0]],
+      wasQuoting: false,
+      wasRowDelimiter: false,
+      timchars: [
+        Buffer2.from(Buffer2.from([cr$1], "utf8").toString(), options.encoding),
+        Buffer2.from(Buffer2.from([nl$1], "utf8").toString(), options.encoding),
+        Buffer2.from(Buffer2.from([np], "utf8").toString(), options.encoding),
+        Buffer2.from(Buffer2.from([space], "utf8").toString(), options.encoding),
+        Buffer2.from(Buffer2.from([tab], "utf8").toString(), options.encoding)
+      ]
+    };
+  };
+  var underscore = function(str) {
+    return str.replace(/([A-Z])/g, function(_, match) {
+      return "_" + match.toLowerCase();
+    });
+  };
+  var normalize_options = function(opts) {
+    const options = {};
+    for (const opt in opts) {
+      options[underscore(opt)] = opts[opt];
+    }
+    if (options.encoding === void 0 || options.encoding === true) {
+      options.encoding = "utf8";
+    } else if (options.encoding === null || options.encoding === false) {
+      options.encoding = null;
+    } else if (typeof options.encoding !== "string" && options.encoding !== null) {
+      throw new CsvError("CSV_INVALID_OPTION_ENCODING", [
+        "Invalid option encoding:",
+        "encoding must be a string or null to return a buffer,",
+        `got ${JSON.stringify(options.encoding)}`
+      ], options);
+    }
+    if (options.bom === void 0 || options.bom === null || options.bom === false) {
+      options.bom = false;
+    } else if (options.bom !== true) {
+      throw new CsvError("CSV_INVALID_OPTION_BOM", [
+        "Invalid option bom:",
+        "bom must be true,",
+        `got ${JSON.stringify(options.bom)}`
+      ], options);
+    }
+    options.cast_function = null;
+    if (options.cast === void 0 || options.cast === null || options.cast === false || options.cast === "") {
+      options.cast = void 0;
+    } else if (typeof options.cast === "function") {
+      options.cast_function = options.cast;
+      options.cast = true;
+    } else if (options.cast !== true) {
+      throw new CsvError("CSV_INVALID_OPTION_CAST", [
+        "Invalid option cast:",
+        "cast must be true or a function,",
+        `got ${JSON.stringify(options.cast)}`
+      ], options);
+    }
+    if (options.cast_date === void 0 || options.cast_date === null || options.cast_date === false || options.cast_date === "") {
+      options.cast_date = false;
+    } else if (options.cast_date === true) {
+      options.cast_date = function(value) {
+        const date = Date.parse(value);
+        return !isNaN(date) ? new Date(date) : value;
+      };
+    } else if (typeof options.cast_date !== "function") {
+      throw new CsvError("CSV_INVALID_OPTION_CAST_DATE", [
+        "Invalid option cast_date:",
+        "cast_date must be true or a function,",
+        `got ${JSON.stringify(options.cast_date)}`
+      ], options);
+    }
+    options.cast_first_line_to_header = null;
+    if (options.columns === true) {
+      options.cast_first_line_to_header = void 0;
+    } else if (typeof options.columns === "function") {
+      options.cast_first_line_to_header = options.columns;
+      options.columns = true;
+    } else if (Array.isArray(options.columns)) {
+      options.columns = normalize_columns_array(options.columns);
+    } else if (options.columns === void 0 || options.columns === null || options.columns === false) {
+      options.columns = false;
+    } else {
+      throw new CsvError("CSV_INVALID_OPTION_COLUMNS", [
+        "Invalid option columns:",
+        "expect an array, a function or true,",
+        `got ${JSON.stringify(options.columns)}`
+      ], options);
+    }
+    if (options.group_columns_by_name === void 0 || options.group_columns_by_name === null || options.group_columns_by_name === false) {
+      options.group_columns_by_name = false;
+    } else if (options.group_columns_by_name !== true) {
+      throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [
+        "Invalid option group_columns_by_name:",
+        "expect an boolean,",
+        `got ${JSON.stringify(options.group_columns_by_name)}`
+      ], options);
+    } else if (options.columns === false) {
+      throw new CsvError("CSV_INVALID_OPTION_GROUP_COLUMNS_BY_NAME", [
+        "Invalid option group_columns_by_name:",
+        "the `columns` mode must be activated."
+      ], options);
+    }
+    if (options.comment === void 0 || options.comment === null || options.comment === false || options.comment === "") {
+      options.comment = null;
+    } else {
+      if (typeof options.comment === "string") {
+        options.comment = Buffer2.from(options.comment, options.encoding);
+      }
+      if (!isBuffer(options.comment)) {
+        throw new CsvError("CSV_INVALID_OPTION_COMMENT", [
+          "Invalid option comment:",
+          "comment must be a buffer or a string,",
+          `got ${JSON.stringify(options.comment)}`
+        ], options);
+      }
+    }
+    if (options.comment_no_infix === void 0 || options.comment_no_infix === null || options.comment_no_infix === false) {
+      options.comment_no_infix = false;
+    } else if (options.comment_no_infix !== true) {
+      throw new CsvError("CSV_INVALID_OPTION_COMMENT", [
+        "Invalid option comment_no_infix:",
+        "value must be a boolean,",
+        `got ${JSON.stringify(options.comment_no_infix)}`
+      ], options);
+    }
+    const delimiter_json = JSON.stringify(options.delimiter);
+    if (!Array.isArray(options.delimiter)) options.delimiter = [options.delimiter];
+    if (options.delimiter.length === 0) {
+      throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [
+        "Invalid option delimiter:",
+        "delimiter must be a non empty string or buffer or array of string|buffer,",
+        `got ${delimiter_json}`
+      ], options);
+    }
+    options.delimiter = options.delimiter.map(function(delimiter) {
+      if (delimiter === void 0 || delimiter === null || delimiter === false) {
+        return Buffer2.from(",", options.encoding);
+      }
+      if (typeof delimiter === "string") {
+        delimiter = Buffer2.from(delimiter, options.encoding);
+      }
+      if (!isBuffer(delimiter) || delimiter.length === 0) {
+        throw new CsvError("CSV_INVALID_OPTION_DELIMITER", [
+          "Invalid option delimiter:",
+          "delimiter must be a non empty string or buffer or array of string|buffer,",
+          `got ${delimiter_json}`
+        ], options);
+      }
+      return delimiter;
+    });
+    if (options.escape === void 0 || options.escape === true) {
+      options.escape = Buffer2.from('"', options.encoding);
+    } else if (typeof options.escape === "string") {
+      options.escape = Buffer2.from(options.escape, options.encoding);
+    } else if (options.escape === null || options.escape === false) {
+      options.escape = null;
+    }
+    if (options.escape !== null) {
+      if (!isBuffer(options.escape)) {
+        throw new Error(`Invalid Option: escape must be a buffer, a string or a boolean, got ${JSON.stringify(options.escape)}`);
+      }
+    }
+    if (options.from === void 0 || options.from === null) {
+      options.from = 1;
+    } else {
+      if (typeof options.from === "string" && /\d+/.test(options.from)) {
+        options.from = parseInt(options.from);
+      }
+      if (Number.isInteger(options.from)) {
+        if (options.from < 0) {
+          throw new Error(`Invalid Option: from must be a positive integer, got ${JSON.stringify(opts.from)}`);
+        }
+      } else {
+        throw new Error(`Invalid Option: from must be an integer, got ${JSON.stringify(options.from)}`);
+      }
+    }
+    if (options.from_line === void 0 || options.from_line === null) {
+      options.from_line = 1;
+    } else {
+      if (typeof options.from_line === "string" && /\d+/.test(options.from_line)) {
+        options.from_line = parseInt(options.from_line);
+      }
+      if (Number.isInteger(options.from_line)) {
+        if (options.from_line <= 0) {
+          throw new Error(`Invalid Option: from_line must be a positive integer greater than 0, got ${JSON.stringify(opts.from_line)}`);
+        }
+      } else {
+        throw new Error(`Invalid Option: from_line must be an integer, got ${JSON.stringify(opts.from_line)}`);
+      }
+    }
+    if (options.ignore_last_delimiters === void 0 || options.ignore_last_delimiters === null) {
+      options.ignore_last_delimiters = false;
+    } else if (typeof options.ignore_last_delimiters === "number") {
+      options.ignore_last_delimiters = Math.floor(options.ignore_last_delimiters);
+      if (options.ignore_last_delimiters === 0) {
+        options.ignore_last_delimiters = false;
+      }
+    } else if (typeof options.ignore_last_delimiters !== "boolean") {
+      throw new CsvError("CSV_INVALID_OPTION_IGNORE_LAST_DELIMITERS", [
+        "Invalid option `ignore_last_delimiters`:",
+        "the value must be a boolean value or an integer,",
+        `got ${JSON.stringify(options.ignore_last_delimiters)}`
+      ], options);
+    }
+    if (options.ignore_last_delimiters === true && options.columns === false) {
+      throw new CsvError("CSV_IGNORE_LAST_DELIMITERS_REQUIRES_COLUMNS", [
+        "The option `ignore_last_delimiters`",
+        "requires the activation of the `columns` option"
+      ], options);
+    }
+    if (options.info === void 0 || options.info === null || options.info === false) {
+      options.info = false;
+    } else if (options.info !== true) {
+      throw new Error(`Invalid Option: info must be true, got ${JSON.stringify(options.info)}`);
+    }
+    if (options.max_record_size === void 0 || options.max_record_size === null || options.max_record_size === false) {
+      options.max_record_size = 0;
+    } else if (Number.isInteger(options.max_record_size) && options.max_record_size >= 0) ;
+    else if (typeof options.max_record_size === "string" && /\d+/.test(options.max_record_size)) {
+      options.max_record_size = parseInt(options.max_record_size);
+    } else {
+      throw new Error(`Invalid Option: max_record_size must be a positive integer, got ${JSON.stringify(options.max_record_size)}`);
+    }
+    if (options.objname === void 0 || options.objname === null || options.objname === false) {
+      options.objname = void 0;
+    } else if (isBuffer(options.objname)) {
+      if (options.objname.length === 0) {
+        throw new Error(`Invalid Option: objname must be a non empty buffer`);
+      }
+      if (options.encoding === null) ;
+      else {
+        options.objname = options.objname.toString(options.encoding);
+      }
+    } else if (typeof options.objname === "string") {
+      if (options.objname.length === 0) {
+        throw new Error(`Invalid Option: objname must be a non empty string`);
+      }
+    } else if (typeof options.objname === "number") ;
+    else {
+      throw new Error(`Invalid Option: objname must be a string or a buffer, got ${options.objname}`);
+    }
+    if (options.objname !== void 0) {
+      if (typeof options.objname === "number") {
+        if (options.columns !== false) {
+          throw Error("Invalid Option: objname index cannot be combined with columns or be defined as a field");
+        }
+      } else {
+        if (options.columns === false) {
+          throw Error("Invalid Option: objname field must be combined with columns or be defined as an index");
+        }
+      }
+    }
+    if (options.on_record === void 0 || options.on_record === null) {
+      options.on_record = void 0;
+    } else if (typeof options.on_record !== "function") {
+      throw new CsvError("CSV_INVALID_OPTION_ON_RECORD", [
+        "Invalid option `on_record`:",
+        "expect a function,",
+        `got ${JSON.stringify(options.on_record)}`
+      ], options);
+    }
+    if (options.on_skip !== void 0 && options.on_skip !== null && typeof options.on_skip !== "function") {
+      throw new Error(`Invalid Option: on_skip must be a function, got ${JSON.stringify(options.on_skip)}`);
+    }
+    if (options.quote === null || options.quote === false || options.quote === "") {
+      options.quote = null;
+    } else {
+      if (options.quote === void 0 || options.quote === true) {
+        options.quote = Buffer2.from('"', options.encoding);
+      } else if (typeof options.quote === "string") {
+        options.quote = Buffer2.from(options.quote, options.encoding);
+      }
+      if (!isBuffer(options.quote)) {
+        throw new Error(`Invalid Option: quote must be a buffer or a string, got ${JSON.stringify(options.quote)}`);
+      }
+    }
+    if (options.raw === void 0 || options.raw === null || options.raw === false) {
+      options.raw = false;
+    } else if (options.raw !== true) {
+      throw new Error(`Invalid Option: raw must be true, got ${JSON.stringify(options.raw)}`);
+    }
+    if (options.record_delimiter === void 0) {
+      options.record_delimiter = [];
+    } else if (typeof options.record_delimiter === "string" || isBuffer(options.record_delimiter)) {
+      if (options.record_delimiter.length === 0) {
+        throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+          "Invalid option `record_delimiter`:",
+          "value must be a non empty string or buffer,",
+          `got ${JSON.stringify(options.record_delimiter)}`
+        ], options);
+      }
+      options.record_delimiter = [options.record_delimiter];
+    } else if (!Array.isArray(options.record_delimiter)) {
+      throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+        "Invalid option `record_delimiter`:",
+        "value must be a string, a buffer or array of string|buffer,",
+        `got ${JSON.stringify(options.record_delimiter)}`
+      ], options);
+    }
+    options.record_delimiter = options.record_delimiter.map(function(rd, i) {
+      if (typeof rd !== "string" && !isBuffer(rd)) {
+        throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+          "Invalid option `record_delimiter`:",
+          "value must be a string, a buffer or array of string|buffer",
+          `at index ${i},`,
+          `got ${JSON.stringify(rd)}`
+        ], options);
+      } else if (rd.length === 0) {
+        throw new CsvError("CSV_INVALID_OPTION_RECORD_DELIMITER", [
+          "Invalid option `record_delimiter`:",
+          "value must be a non empty string or buffer",
+          `at index ${i},`,
+          `got ${JSON.stringify(rd)}`
+        ], options);
+      }
+      if (typeof rd === "string") {
+        rd = Buffer2.from(rd, options.encoding);
+      }
+      return rd;
+    });
+    if (typeof options.relax_column_count === "boolean") ;
+    else if (options.relax_column_count === void 0 || options.relax_column_count === null) {
+      options.relax_column_count = false;
+    } else {
+      throw new Error(`Invalid Option: relax_column_count must be a boolean, got ${JSON.stringify(options.relax_column_count)}`);
+    }
+    if (typeof options.relax_column_count_less === "boolean") ;
+    else if (options.relax_column_count_less === void 0 || options.relax_column_count_less === null) {
+      options.relax_column_count_less = false;
+    } else {
+      throw new Error(`Invalid Option: relax_column_count_less must be a boolean, got ${JSON.stringify(options.relax_column_count_less)}`);
+    }
+    if (typeof options.relax_column_count_more === "boolean") ;
+    else if (options.relax_column_count_more === void 0 || options.relax_column_count_more === null) {
+      options.relax_column_count_more = false;
+    } else {
+      throw new Error(`Invalid Option: relax_column_count_more must be a boolean, got ${JSON.stringify(options.relax_column_count_more)}`);
+    }
+    if (typeof options.relax_quotes === "boolean") ;
+    else if (options.relax_quotes === void 0 || options.relax_quotes === null) {
+      options.relax_quotes = false;
+    } else {
+      throw new Error(`Invalid Option: relax_quotes must be a boolean, got ${JSON.stringify(options.relax_quotes)}`);
+    }
+    if (typeof options.skip_empty_lines === "boolean") ;
+    else if (options.skip_empty_lines === void 0 || options.skip_empty_lines === null) {
+      options.skip_empty_lines = false;
+    } else {
+      throw new Error(`Invalid Option: skip_empty_lines must be a boolean, got ${JSON.stringify(options.skip_empty_lines)}`);
+    }
+    if (typeof options.skip_records_with_empty_values === "boolean") ;
+    else if (options.skip_records_with_empty_values === void 0 || options.skip_records_with_empty_values === null) {
+      options.skip_records_with_empty_values = false;
+    } else {
+      throw new Error(`Invalid Option: skip_records_with_empty_values must be a boolean, got ${JSON.stringify(options.skip_records_with_empty_values)}`);
+    }
+    if (typeof options.skip_records_with_error === "boolean") ;
+    else if (options.skip_records_with_error === void 0 || options.skip_records_with_error === null) {
+      options.skip_records_with_error = false;
+    } else {
+      throw new Error(`Invalid Option: skip_records_with_error must be a boolean, got ${JSON.stringify(options.skip_records_with_error)}`);
+    }
+    if (options.rtrim === void 0 || options.rtrim === null || options.rtrim === false) {
+      options.rtrim = false;
+    } else if (options.rtrim !== true) {
+      throw new Error(`Invalid Option: rtrim must be a boolean, got ${JSON.stringify(options.rtrim)}`);
+    }
+    if (options.ltrim === void 0 || options.ltrim === null || options.ltrim === false) {
+      options.ltrim = false;
+    } else if (options.ltrim !== true) {
+      throw new Error(`Invalid Option: ltrim must be a boolean, got ${JSON.stringify(options.ltrim)}`);
+    }
+    if (options.trim === void 0 || options.trim === null || options.trim === false) {
+      options.trim = false;
+    } else if (options.trim !== true) {
+      throw new Error(`Invalid Option: trim must be a boolean, got ${JSON.stringify(options.trim)}`);
+    }
+    if (options.trim === true && opts.ltrim !== false) {
+      options.ltrim = true;
+    } else if (options.ltrim !== true) {
+      options.ltrim = false;
+    }
+    if (options.trim === true && opts.rtrim !== false) {
+      options.rtrim = true;
+    } else if (options.rtrim !== true) {
+      options.rtrim = false;
+    }
+    if (options.to === void 0 || options.to === null) {
+      options.to = -1;
+    } else {
+      if (typeof options.to === "string" && /\d+/.test(options.to)) {
+        options.to = parseInt(options.to);
+      }
+      if (Number.isInteger(options.to)) {
+        if (options.to <= 0) {
+          throw new Error(`Invalid Option: to must be a positive integer greater than 0, got ${JSON.stringify(opts.to)}`);
+        }
+      } else {
+        throw new Error(`Invalid Option: to must be an integer, got ${JSON.stringify(opts.to)}`);
+      }
+    }
+    if (options.to_line === void 0 || options.to_line === null) {
+      options.to_line = -1;
+    } else {
+      if (typeof options.to_line === "string" && /\d+/.test(options.to_line)) {
+        options.to_line = parseInt(options.to_line);
+      }
+      if (Number.isInteger(options.to_line)) {
+        if (options.to_line <= 0) {
+          throw new Error(`Invalid Option: to_line must be a positive integer greater than 0, got ${JSON.stringify(opts.to_line)}`);
+        }
+      } else {
+        throw new Error(`Invalid Option: to_line must be an integer, got ${JSON.stringify(opts.to_line)}`);
+      }
+    }
+    return options;
+  };
+  var isRecordEmpty = function(record) {
+    return record.every((field) => field == null || field.toString && field.toString().trim() === "");
+  };
+  var cr = 13;
+  var nl = 10;
+  var boms = {
+    // Note, the following are equals:
+    // Buffer.from("\ufeff")
+    // Buffer.from([239, 187, 191])
+    // Buffer.from('EFBBBF', 'hex')
+    "utf8": Buffer2.from([239, 187, 191]),
+    // Note, the following are equals:
+    // Buffer.from "\ufeff", 'utf16le
+    // Buffer.from([255, 254])
+    "utf16le": Buffer2.from([255, 254])
+  };
+  var transform = function(original_options = {}) {
+    const info = {
+      bytes: 0,
+      comment_lines: 0,
+      empty_lines: 0,
+      invalid_field_length: 0,
+      lines: 1,
+      records: 0
+    };
+    const options = normalize_options(original_options);
+    return {
+      info,
+      original_options,
+      options,
+      state: init_state(options),
+      __needMoreData: function(i, bufLen, end) {
+        if (end) return false;
+        const { encoding, escape, quote } = this.options;
+        const { quoting, needMoreDataSize, recordDelimiterMaxLength } = this.state;
+        const numOfCharLeft = bufLen - i - 1;
+        const requiredLength = Math.max(
+          needMoreDataSize,
+          // Skip if the remaining buffer smaller than record delimiter
+          // If "record_delimiter" is yet to be discovered:
+          // 1. It is equals to `[]` and "recordDelimiterMaxLength" equals `0`
+          // 2. We set the length to windows line ending in the current encoding
+          // Note, that encoding is known from user or bom discovery at that point
+          // recordDelimiterMaxLength,
+          recordDelimiterMaxLength === 0 ? Buffer2.from("\r\n", encoding).length : recordDelimiterMaxLength,
+          // Skip if remaining buffer can be an escaped quote
+          quoting ? (escape === null ? 0 : escape.length) + quote.length : 0,
+          // Skip if remaining buffer can be record delimiter following the closing quote
+          quoting ? quote.length + recordDelimiterMaxLength : 0
+        );
+        return numOfCharLeft < requiredLength;
+      },
+      // Central parser implementation
+      parse: function(nextBuf, end, push, close) {
+        const { bom, comment_no_infix, encoding, from_line, ltrim, max_record_size, raw, relax_quotes, rtrim, skip_empty_lines, to, to_line } = this.options;
+        let { comment, escape, quote, record_delimiter } = this.options;
+        const { bomSkipped, previousBuf, rawBuffer, escapeIsQuote } = this.state;
+        let buf;
+        if (previousBuf === void 0) {
+          if (nextBuf === void 0) {
+            close();
+            return;
+          } else {
+            buf = nextBuf;
+          }
+        } else if (previousBuf !== void 0 && nextBuf === void 0) {
+          buf = previousBuf;
+        } else {
+          buf = Buffer2.concat([previousBuf, nextBuf]);
+        }
+        if (bomSkipped === false) {
+          if (bom === false) {
+            this.state.bomSkipped = true;
+          } else if (buf.length < 3) {
+            if (end === false) {
+              this.state.previousBuf = buf;
+              return;
+            }
+          } else {
+            for (const encoding2 in boms) {
+              if (boms[encoding2].compare(buf, 0, boms[encoding2].length) === 0) {
+                const bomLength = boms[encoding2].length;
+                this.state.bufBytesStart += bomLength;
+                buf = buf.slice(bomLength);
+                this.options = normalize_options({ ...this.original_options, encoding: encoding2 });
+                ({ comment, escape, quote } = this.options);
+                break;
+              }
+            }
+            this.state.bomSkipped = true;
+          }
+        }
+        const bufLen = buf.length;
+        let pos;
+        for (pos = 0; pos < bufLen; pos++) {
+          if (this.__needMoreData(pos, bufLen, end)) {
+            break;
+          }
+          if (this.state.wasRowDelimiter === true) {
+            this.info.lines++;
+            this.state.wasRowDelimiter = false;
+          }
+          if (to_line !== -1 && this.info.lines > to_line) {
+            this.state.stop = true;
+            close();
+            return;
+          }
+          if (this.state.quoting === false && record_delimiter.length === 0) {
+            const record_delimiterCount = this.__autoDiscoverRecordDelimiter(buf, pos);
+            if (record_delimiterCount) {
+              record_delimiter = this.options.record_delimiter;
+            }
+          }
+          const chr = buf[pos];
+          if (raw === true) {
+            rawBuffer.append(chr);
+          }
+          if ((chr === cr || chr === nl) && this.state.wasRowDelimiter === false) {
+            this.state.wasRowDelimiter = true;
+          }
+          if (this.state.escaping === true) {
+            this.state.escaping = false;
+          } else {
+            if (escape !== null && this.state.quoting === true && this.__isEscape(buf, pos, chr) && pos + escape.length < bufLen) {
+              if (escapeIsQuote) {
+                if (this.__isQuote(buf, pos + escape.length)) {
+                  this.state.escaping = true;
+                  pos += escape.length - 1;
+                  continue;
+                }
+              } else {
+                this.state.escaping = true;
+                pos += escape.length - 1;
+                continue;
+              }
+            }
+            if (this.state.commenting === false && this.__isQuote(buf, pos)) {
+              if (this.state.quoting === true) {
+                const nextChr = buf[pos + quote.length];
+                const isNextChrTrimable = rtrim && this.__isCharTrimable(buf, pos + quote.length);
+                const isNextChrComment = comment !== null && this.__compareBytes(comment, buf, pos + quote.length, nextChr);
+                const isNextChrDelimiter = this.__isDelimiter(buf, pos + quote.length, nextChr);
+                const isNextChrRecordDelimiter = record_delimiter.length === 0 ? this.__autoDiscoverRecordDelimiter(buf, pos + quote.length) : this.__isRecordDelimiter(nextChr, buf, pos + quote.length);
+                if (escape !== null && this.__isEscape(buf, pos, chr) && this.__isQuote(buf, pos + escape.length)) {
+                  pos += escape.length - 1;
+                } else if (!nextChr || isNextChrDelimiter || isNextChrRecordDelimiter || isNextChrComment || isNextChrTrimable) {
+                  this.state.quoting = false;
+                  this.state.wasQuoting = true;
+                  pos += quote.length - 1;
+                  continue;
+                } else if (relax_quotes === false) {
+                  const err = this.__error(
+                    new CsvError("CSV_INVALID_CLOSING_QUOTE", [
+                      "Invalid Closing Quote:",
+                      `got "${String.fromCharCode(nextChr)}"`,
+                      `at line ${this.info.lines}`,
+                      "instead of delimiter, record delimiter, trimable character",
+                      "(if activated) or comment"
+                    ], this.options, this.__infoField())
+                  );
+                  if (err !== void 0) return err;
+                } else {
+                  this.state.quoting = false;
+                  this.state.wasQuoting = true;
+                  this.state.field.prepend(quote);
+                  pos += quote.length - 1;
+                }
+              } else {
+                if (this.state.field.length !== 0) {
+                  if (relax_quotes === false) {
+                    const info2 = this.__infoField();
+                    const bom2 = Object.keys(boms).map((b) => boms[b].equals(this.state.field.toString()) ? b : false).filter(Boolean)[0];
+                    const err = this.__error(
+                      new CsvError("INVALID_OPENING_QUOTE", [
+                        "Invalid Opening Quote:",
+                        `a quote is found on field ${JSON.stringify(info2.column)} at line ${info2.lines}, value is ${JSON.stringify(this.state.field.toString(encoding))}`,
+                        bom2 ? `(${bom2} bom)` : void 0
+                      ], this.options, info2, {
+                        field: this.state.field
+                      })
+                    );
+                    if (err !== void 0) return err;
+                  }
+                } else {
+                  this.state.quoting = true;
+                  pos += quote.length - 1;
+                  continue;
+                }
+              }
+            }
+            if (this.state.quoting === false) {
+              const recordDelimiterLength = this.__isRecordDelimiter(chr, buf, pos);
+              if (recordDelimiterLength !== 0) {
+                const skipCommentLine = this.state.commenting && (this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0);
+                if (skipCommentLine) {
+                  this.info.comment_lines++;
+                } else {
+                  if (this.state.enabled === false && this.info.lines + (this.state.wasRowDelimiter === true ? 1 : 0) >= from_line) {
+                    this.state.enabled = true;
+                    this.__resetField();
+                    this.__resetRecord();
+                    pos += recordDelimiterLength - 1;
+                    continue;
+                  }
+                  if (skip_empty_lines === true && this.state.wasQuoting === false && this.state.record.length === 0 && this.state.field.length === 0) {
+                    this.info.empty_lines++;
+                    pos += recordDelimiterLength - 1;
+                    continue;
+                  }
+                  this.info.bytes = this.state.bufBytesStart + pos;
+                  const errField = this.__onField();
+                  if (errField !== void 0) return errField;
+                  this.info.bytes = this.state.bufBytesStart + pos + recordDelimiterLength;
+                  const errRecord = this.__onRecord(push);
+                  if (errRecord !== void 0) return errRecord;
+                  if (to !== -1 && this.info.records >= to) {
+                    this.state.stop = true;
+                    close();
+                    return;
+                  }
+                }
+                this.state.commenting = false;
+                pos += recordDelimiterLength - 1;
+                continue;
+              }
+              if (this.state.commenting) {
+                continue;
+              }
+              if (comment !== null && (comment_no_infix === false || this.state.record.length === 0 && this.state.field.length === 0)) {
+                const commentCount = this.__compareBytes(comment, buf, pos, chr);
+                if (commentCount !== 0) {
+                  this.state.commenting = true;
+                  continue;
+                }
+              }
+              const delimiterLength = this.__isDelimiter(buf, pos, chr);
+              if (delimiterLength !== 0) {
+                this.info.bytes = this.state.bufBytesStart + pos;
+                const errField = this.__onField();
+                if (errField !== void 0) return errField;
+                pos += delimiterLength - 1;
+                continue;
+              }
+            }
+          }
+          if (this.state.commenting === false) {
+            if (max_record_size !== 0 && this.state.record_length + this.state.field.length > max_record_size) {
+              return this.__error(
+                new CsvError("CSV_MAX_RECORD_SIZE", [
+                  "Max Record Size:",
+                  "record exceed the maximum number of tolerated bytes",
+                  `of ${max_record_size}`,
+                  `at line ${this.info.lines}`
+                ], this.options, this.__infoField())
+              );
+            }
+          }
+          const lappend = ltrim === false || this.state.quoting === true || this.state.field.length !== 0 || !this.__isCharTrimable(buf, pos);
+          const rappend = rtrim === false || this.state.wasQuoting === false;
+          if (lappend === true && rappend === true) {
+            this.state.field.append(chr);
+          } else if (rtrim === true && !this.__isCharTrimable(buf, pos)) {
+            return this.__error(
+              new CsvError("CSV_NON_TRIMABLE_CHAR_AFTER_CLOSING_QUOTE", [
+                "Invalid Closing Quote:",
+                "found non trimable byte after quote",
+                `at line ${this.info.lines}`
+              ], this.options, this.__infoField())
+            );
+          } else {
+            if (lappend === false) {
+              pos += this.__isCharTrimable(buf, pos) - 1;
+            }
+            continue;
+          }
+        }
+        if (end === true) {
+          if (this.state.quoting === true) {
+            const err = this.__error(
+              new CsvError("CSV_QUOTE_NOT_CLOSED", [
+                "Quote Not Closed:",
+                `the parsing is finished with an opening quote at line ${this.info.lines}`
+              ], this.options, this.__infoField())
+            );
+            if (err !== void 0) return err;
+          } else {
+            if (this.state.wasQuoting === true || this.state.record.length !== 0 || this.state.field.length !== 0) {
+              this.info.bytes = this.state.bufBytesStart + pos;
+              const errField = this.__onField();
+              if (errField !== void 0) return errField;
+              const errRecord = this.__onRecord(push);
+              if (errRecord !== void 0) return errRecord;
+            } else if (this.state.wasRowDelimiter === true) {
+              this.info.empty_lines++;
+            } else if (this.state.commenting === true) {
+              this.info.comment_lines++;
+            }
+          }
+        } else {
+          this.state.bufBytesStart += pos;
+          this.state.previousBuf = buf.slice(pos);
+        }
+        if (this.state.wasRowDelimiter === true) {
+          this.info.lines++;
+          this.state.wasRowDelimiter = false;
+        }
+      },
+      __onRecord: function(push) {
+        const { columns, group_columns_by_name, encoding, info: info2, from: from2, relax_column_count, relax_column_count_less, relax_column_count_more, raw, skip_records_with_empty_values } = this.options;
+        const { enabled, record } = this.state;
+        if (enabled === false) {
+          return this.__resetRecord();
+        }
+        const recordLength = record.length;
+        if (columns === true) {
+          if (skip_records_with_empty_values === true && isRecordEmpty(record)) {
+            this.__resetRecord();
+            return;
+          }
+          return this.__firstLineToColumns(record);
+        }
+        if (columns === false && this.info.records === 0) {
+          this.state.expectedRecordLength = recordLength;
+        }
+        if (recordLength !== this.state.expectedRecordLength) {
+          const err = columns === false ? new CsvError("CSV_RECORD_INCONSISTENT_FIELDS_LENGTH", [
+            "Invalid Record Length:",
+            `expect ${this.state.expectedRecordLength},`,
+            `got ${recordLength} on line ${this.info.lines}`
+          ], this.options, this.__infoField(), {
+            record
+          }) : new CsvError("CSV_RECORD_INCONSISTENT_COLUMNS", [
+            "Invalid Record Length:",
+            `columns length is ${columns.length},`,
+            // rename columns
+            `got ${recordLength} on line ${this.info.lines}`
+          ], this.options, this.__infoField(), {
+            record
+          });
+          if (relax_column_count === true || relax_column_count_less === true && recordLength < this.state.expectedRecordLength || relax_column_count_more === true && recordLength > this.state.expectedRecordLength) {
+            this.info.invalid_field_length++;
+            this.state.error = err;
+          } else {
+            const finalErr = this.__error(err);
+            if (finalErr) return finalErr;
+          }
+        }
+        if (skip_records_with_empty_values === true && isRecordEmpty(record)) {
+          this.__resetRecord();
+          return;
+        }
+        if (this.state.recordHasError === true) {
+          this.__resetRecord();
+          this.state.recordHasError = false;
+          return;
+        }
+        this.info.records++;
+        if (from2 === 1 || this.info.records >= from2) {
+          const { objname } = this.options;
+          if (columns !== false) {
+            const obj = {};
+            for (let i = 0, l = record.length; i < l; i++) {
+              if (columns[i] === void 0 || columns[i].disabled) continue;
+              if (group_columns_by_name === true && obj[columns[i].name] !== void 0) {
+                if (Array.isArray(obj[columns[i].name])) {
+                  obj[columns[i].name] = obj[columns[i].name].concat(record[i]);
+                } else {
+                  obj[columns[i].name] = [obj[columns[i].name], record[i]];
+                }
+              } else {
+                obj[columns[i].name] = record[i];
+              }
+            }
+            if (raw === true || info2 === true) {
+              const extRecord = Object.assign(
+                { record: obj },
+                raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {},
+                info2 === true ? { info: this.__infoRecord() } : {}
+              );
+              const err = this.__push(
+                objname === void 0 ? extRecord : [obj[objname], extRecord],
+                push
+              );
+              if (err) {
+                return err;
+              }
+            } else {
+              const err = this.__push(
+                objname === void 0 ? obj : [obj[objname], obj],
+                push
+              );
+              if (err) {
+                return err;
+              }
+            }
+          } else {
+            if (raw === true || info2 === true) {
+              const extRecord = Object.assign(
+                { record },
+                raw === true ? { raw: this.state.rawBuffer.toString(encoding) } : {},
+                info2 === true ? { info: this.__infoRecord() } : {}
+              );
+              const err = this.__push(
+                objname === void 0 ? extRecord : [record[objname], extRecord],
+                push
+              );
+              if (err) {
+                return err;
+              }
+            } else {
+              const err = this.__push(
+                objname === void 0 ? record : [record[objname], record],
+                push
+              );
+              if (err) {
+                return err;
+              }
+            }
+          }
+        }
+        this.__resetRecord();
+      },
+      __firstLineToColumns: function(record) {
+        const { firstLineToHeaders } = this.state;
+        try {
+          const headers = firstLineToHeaders === void 0 ? record : firstLineToHeaders.call(null, record);
+          if (!Array.isArray(headers)) {
+            return this.__error(
+              new CsvError("CSV_INVALID_COLUMN_MAPPING", [
+                "Invalid Column Mapping:",
+                "expect an array from column function,",
+                `got ${JSON.stringify(headers)}`
+              ], this.options, this.__infoField(), {
+                headers
+              })
+            );
+          }
+          const normalizedHeaders = normalize_columns_array(headers);
+          this.state.expectedRecordLength = normalizedHeaders.length;
+          this.options.columns = normalizedHeaders;
+          this.__resetRecord();
+          return;
+        } catch (err) {
+          return err;
+        }
+      },
+      __resetRecord: function() {
+        if (this.options.raw === true) {
+          this.state.rawBuffer.reset();
+        }
+        this.state.error = void 0;
+        this.state.record = [];
+        this.state.record_length = 0;
+      },
+      __onField: function() {
+        const { cast, encoding, rtrim, max_record_size } = this.options;
+        const { enabled, wasQuoting } = this.state;
+        if (enabled === false) {
+          return this.__resetField();
+        }
+        let field = this.state.field.toString(encoding);
+        if (rtrim === true && wasQuoting === false) {
+          field = field.trimRight();
+        }
+        if (cast === true) {
+          const [err, f] = this.__cast(field);
+          if (err !== void 0) return err;
+          field = f;
+        }
+        this.state.record.push(field);
+        if (max_record_size !== 0 && typeof field === "string") {
+          this.state.record_length += field.length;
+        }
+        this.__resetField();
+      },
+      __resetField: function() {
+        this.state.field.reset();
+        this.state.wasQuoting = false;
+      },
+      __push: function(record, push) {
+        const { on_record } = this.options;
+        if (on_record !== void 0) {
+          const info2 = this.__infoRecord();
+          try {
+            record = on_record.call(null, record, info2);
+          } catch (err) {
+            return err;
+          }
+          if (record === void 0 || record === null) {
+            return;
+          }
+        }
+        push(record);
+      },
+      // Return a tuple with the error and the casted value
+      __cast: function(field) {
+        const { columns, relax_column_count } = this.options;
+        const isColumns = Array.isArray(columns);
+        if (isColumns === true && relax_column_count && this.options.columns.length <= this.state.record.length) {
+          return [void 0, void 0];
+        }
+        if (this.state.castField !== null) {
+          try {
+            const info2 = this.__infoField();
+            return [void 0, this.state.castField.call(null, field, info2)];
+          } catch (err) {
+            return [err];
+          }
+        }
+        if (this.__isFloat(field)) {
+          return [void 0, parseFloat(field)];
+        } else if (this.options.cast_date !== false) {
+          const info2 = this.__infoField();
+          return [void 0, this.options.cast_date.call(null, field, info2)];
+        }
+        return [void 0, field];
+      },
+      // Helper to test if a character is a space or a line delimiter
+      __isCharTrimable: function(buf, pos) {
+        const isTrim = (buf2, pos2) => {
+          const { timchars } = this.state;
+          loop1: for (let i = 0; i < timchars.length; i++) {
+            const timchar = timchars[i];
+            for (let j = 0; j < timchar.length; j++) {
+              if (timchar[j] !== buf2[pos2 + j]) continue loop1;
+            }
+            return timchar.length;
+          }
+          return 0;
+        };
+        return isTrim(buf, pos);
+      },
+      // Keep it in case we implement the `cast_int` option
+      // __isInt(value){
+      //   // return Number.isInteger(parseInt(value))
+      //   // return !isNaN( parseInt( obj ) );
+      //   return /^(\-|\+)?[1-9][0-9]*$/.test(value)
+      // }
+      __isFloat: function(value) {
+        return value - parseFloat(value) + 1 >= 0;
+      },
+      __compareBytes: function(sourceBuf, targetBuf, targetPos, firstByte) {
+        if (sourceBuf[0] !== firstByte) return 0;
+        const sourceLength = sourceBuf.length;
+        for (let i = 1; i < sourceLength; i++) {
+          if (sourceBuf[i] !== targetBuf[targetPos + i]) return 0;
+        }
+        return sourceLength;
+      },
+      __isDelimiter: function(buf, pos, chr) {
+        const { delimiter, ignore_last_delimiters } = this.options;
+        if (ignore_last_delimiters === true && this.state.record.length === this.options.columns.length - 1) {
+          return 0;
+        } else if (ignore_last_delimiters !== false && typeof ignore_last_delimiters === "number" && this.state.record.length === ignore_last_delimiters - 1) {
+          return 0;
+        }
+        loop1: for (let i = 0; i < delimiter.length; i++) {
+          const del = delimiter[i];
+          if (del[0] === chr) {
+            for (let j = 1; j < del.length; j++) {
+              if (del[j] !== buf[pos + j]) continue loop1;
+            }
+            return del.length;
+          }
+        }
+        return 0;
+      },
+      __isRecordDelimiter: function(chr, buf, pos) {
+        const { record_delimiter } = this.options;
+        const recordDelimiterLength = record_delimiter.length;
+        loop1: for (let i = 0; i < recordDelimiterLength; i++) {
+          const rd = record_delimiter[i];
+          const rdLength = rd.length;
+          if (rd[0] !== chr) {
+            continue;
+          }
+          for (let j = 1; j < rdLength; j++) {
+            if (rd[j] !== buf[pos + j]) {
+              continue loop1;
+            }
+          }
+          return rd.length;
+        }
+        return 0;
+      },
+      __isEscape: function(buf, pos, chr) {
+        const { escape } = this.options;
+        if (escape === null) return false;
+        const l = escape.length;
+        if (escape[0] === chr) {
+          for (let i = 0; i < l; i++) {
+            if (escape[i] !== buf[pos + i]) {
+              return false;
+            }
+          }
+          return true;
+        }
+        return false;
+      },
+      __isQuote: function(buf, pos) {
+        const { quote } = this.options;
+        if (quote === null) return false;
+        const l = quote.length;
+        for (let i = 0; i < l; i++) {
+          if (quote[i] !== buf[pos + i]) {
+            return false;
+          }
+        }
+        return true;
+      },
+      __autoDiscoverRecordDelimiter: function(buf, pos) {
+        const { encoding } = this.options;
+        const rds = [
+          // Important, the windows line ending must be before mac os 9
+          Buffer2.from("\r\n", encoding),
+          Buffer2.from("\n", encoding),
+          Buffer2.from("\r", encoding)
+        ];
+        loop: for (let i = 0; i < rds.length; i++) {
+          const l = rds[i].length;
+          for (let j = 0; j < l; j++) {
+            if (rds[i][j] !== buf[pos + j]) {
+              continue loop;
+            }
+          }
+          this.options.record_delimiter.push(rds[i]);
+          this.state.recordDelimiterMaxLength = rds[i].length;
+          return rds[i].length;
+        }
+        return 0;
+      },
+      __error: function(msg) {
+        const { encoding, raw, skip_records_with_error } = this.options;
+        const err = typeof msg === "string" ? new Error(msg) : msg;
+        if (skip_records_with_error) {
+          this.state.recordHasError = true;
+          if (this.options.on_skip !== void 0) {
+            this.options.on_skip(err, raw ? this.state.rawBuffer.toString(encoding) : void 0);
+          }
+          return void 0;
+        } else {
+          return err;
+        }
+      },
+      __infoDataSet: function() {
+        return {
+          ...this.info,
+          columns: this.options.columns
+        };
+      },
+      __infoRecord: function() {
+        const { columns, raw, encoding } = this.options;
+        return {
+          ...this.__infoDataSet(),
+          error: this.state.error,
+          header: columns === true,
+          index: this.state.record.length,
+          raw: raw ? this.state.rawBuffer.toString(encoding) : void 0
+        };
+      },
+      __infoField: function() {
+        const { columns } = this.options;
+        const isColumns = Array.isArray(columns);
+        return {
+          ...this.__infoRecord(),
+          column: isColumns === true ? columns.length > this.state.record.length ? columns[this.state.record.length].name : null : this.state.record.length,
+          quoting: this.state.wasQuoting
+        };
+      }
+    };
+  };
+  var Parser = class extends Transform {
+    constructor(opts = {}) {
+      super({ ...{ readableObjectMode: true }, ...opts, encoding: null });
+      this.api = transform({ on_skip: (err, chunk) => {
+        this.emit("skip", err, chunk);
+      }, ...opts });
+      this.state = this.api.state;
+      this.options = this.api.options;
+      this.info = this.api.info;
+    }
+    // Implementation of `Transform._transform`
+    _transform(buf, _, callback) {
+      if (this.state.stop === true) {
+        return;
+      }
+      const err = this.api.parse(buf, false, (record) => {
+        this.push(record);
+      }, () => {
+        this.push(null);
+        this.end();
+        this.on("end", this.destroy);
+      });
+      if (err !== void 0) {
+        this.state.stop = true;
+      }
+      callback(err);
+    }
+    // Implementation of `Transform._flush`
+    _flush(callback) {
+      if (this.state.stop === true) {
+        return;
+      }
+      const err = this.api.parse(void 0, true, (record) => {
+        this.push(record);
+      }, () => {
+        this.push(null);
+        this.on("end", this.destroy);
+      });
+      callback(err);
+    }
+  };
+  var parse = function() {
+    let data, options, callback;
+    for (const i in arguments) {
+      const argument = arguments[i];
+      const type = typeof argument;
+      if (data === void 0 && (typeof argument === "string" || isBuffer(argument))) {
+        data = argument;
+      } else if (options === void 0 && is_object(argument)) {
+        options = argument;
+      } else if (callback === void 0 && type === "function") {
+        callback = argument;
+      } else {
+        throw new CsvError("CSV_INVALID_ARGUMENT", [
+          "Invalid argument:",
+          `got ${JSON.stringify(argument)} at index ${i}`
+        ], options || {});
+      }
+    }
+    const parser = new Parser(options);
+    if (callback) {
+      const records = options === void 0 || options.objname === void 0 ? [] : {};
+      parser.on("readable", function() {
+        let record;
+        while ((record = this.read()) !== null) {
+          if (options === void 0 || options.objname === void 0) {
+            records.push(record);
+          } else {
+            records[record[0]] = record[1];
+          }
+        }
+      });
+      parser.on("error", function(err) {
+        callback(err, void 0, parser.api.__infoDataSet());
+      });
+      parser.on("end", function() {
+        callback(void 0, records, parser.api.__infoDataSet());
+      });
+    }
+    if (data !== void 0) {
+      const writer = function() {
+        parser.write(data);
+        parser.end();
+      };
+      if (typeof setImmediate === "function") {
+        setImmediate(writer);
+      } else {
+        setTimeout(writer, 0);
+      }
+    }
+    return parser;
+  };
+
+  // src/utils.ts
+  var PSYCHDS_IGNORE_FILENAME = ".psychds-ignore";
+  var PSYCHDS_IGNORE_CONTENT = "**/raw/\n.psychds-ignore\n";
+  function saveTextToFile(textstr, filename) {
+    const blobToSave = new Blob([textstr], {
+      type: "text/plain"
+    });
+    let blobURL = "";
+    if (typeof window.webkitURL !== "undefined") {
+      blobURL = window.webkitURL.createObjectURL(blobToSave);
+    } else {
+      blobURL = window.URL.createObjectURL(blobToSave);
+    }
+    const link = document.createElement("a");
+    link.id = "jspsych-download-as-text-link";
+    link.style.display = "none";
+    link.download = filename;
+    link.href = blobURL;
+    link.click();
+  }
+  function tryParseJSON(value) {
+    try {
+      return JSON.parse(value);
+    } catch {
+      return null;
+    }
+  }
+  function unwrapTrials(data) {
+    const parsed = typeof data === "string" ? JSON.parse(data) : data;
+    if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
+      const keys2 = Object.keys(parsed);
+      if (keys2.length === 1 && keys2[0] === "trials" && Array.isArray(parsed.trials)) {
+        return parsed.trials;
+      }
+    }
+    return parsed;
+  }
+  function parseJsonData(content, options = {}, stats) {
+    if (content.charCodeAt(0) === 65279) content = content.slice(1);
+    const whole = tryParseJSON(content);
+    if (whole !== null) return unwrapTrials(whole);
+    const lines = content.split(/\r?\n/);
+    const out = [];
+    let parsedAny = false;
+    let recordIndex = 0;
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i].trim();
+      if (!line) continue;
+      let value;
+      try {
+        value = JSON.parse(line);
+      } catch {
+        throw new Error(
+          `Could not parse data as JSON or JSON-Lines: line ${i + 1} is not valid JSON.`
+        );
+      }
+      parsedAny = true;
+      const observations = Array.isArray(value) ? value : [value];
+      if (options.tagSourceRecordId) {
+        for (const obs of observations) {
+          if (obs !== null && typeof obs === "object" && !Array.isArray(obs) && !("source_record_id" in obs) && !("participant_id" in obs)) {
+            obs.source_record_id = recordIndex;
+            if (stats) stats.synthesizedSourceRecordId = true;
+          }
+        }
+      }
+      out.push(...observations);
+      recordIndex++;
+    }
+    if (!parsedAny) {
+      throw new Error("Could not parse data: input is empty or not valid JSON/JSON-Lines.");
+    }
+    return out;
+  }
+  var SYSTEM_COLUMNS = /* @__PURE__ */ new Set([
+    "trial_type",
+    "trial_index",
+    "time_elapsed",
+    "extension_type",
+    "extension_version"
+  ]);
+  function analyzeJoinKeys(parsedData, keys2) {
+    if (parsedData.length === 0) {
+      return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+    }
+    const compositeKeys = parsedData.map(
+      (row) => keys2.map((k) => String(row[k] ?? "")).join("\0")
+    );
+    const keyCount = /* @__PURE__ */ new Map();
+    for (const ck of compositeKeys) keyCount.set(ck, (keyCount.get(ck) ?? 0) + 1);
+    const duplicateCount = [...keyCount.values()].reduce((n, c) => n + (c > 1 ? c - 1 : 0), 0);
+    const isUnique = duplicateCount === 0;
+    const duplicateValues = [];
+    for (let i = 0; i < parsedData.length && duplicateValues.length < 5; i++) {
+      if ((keyCount.get(compositeKeys[i]) ?? 0) > 1) {
+        const vals = keys2.reduce((acc, k) => {
+          acc[k] = parsedData[i][k];
+          return acc;
+        }, {});
+        if (!duplicateValues.some((v) => JSON.stringify(v) === JSON.stringify(vals))) {
+          duplicateValues.push(vals);
+        }
+      }
+    }
+    if (isUnique) {
+      return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+    }
+    const keySet = new Set(keys2);
+    const allColumns = /* @__PURE__ */ new Set();
+    for (const row of parsedData) for (const col of Object.keys(row)) allColumns.add(col);
+    const candidateColumns = [...allColumns].filter(
+      (col) => !isUnnamedHeader(col) && !keySet.has(col) && !SYSTEM_COLUMNS.has(col)
+    );
+    const candidates = candidateColumns.map((col) => {
+      const extended = parsedData.map(
+        (row) => [...keys2, col].map((k) => String(row[k] ?? "")).join("\0")
+      );
+      return { column: col, makesUnique: new Set(extended).size === parsedData.length };
+    });
+    if (candidates.some((c) => c.makesUnique)) {
+      return { isUnique, duplicateCount, duplicateValues, candidates, suggestedAdditionalKeys: [] };
+    }
+    const workingKeys = [...keys2];
+    const available = [...candidateColumns];
+    while (available.length > 0) {
+      const current = parsedData.map(
+        (row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0")
+      );
+      if (new Set(current).size === parsedData.length) break;
+      let bestCol = null;
+      let bestCount = new Set(current).size;
+      for (const col of available) {
+        const test = parsedData.map(
+          (row) => [...workingKeys, col].map((k) => String(row[k] ?? "")).join("\0")
+        );
+        const count = new Set(test).size;
+        if (count > bestCount) {
+          bestCount = count;
+          bestCol = col;
+        }
+      }
+      if (bestCol === null) break;
+      workingKeys.push(bestCol);
+      available.splice(available.indexOf(bestCol), 1);
+    }
+    const added = workingKeys.slice(keys2.length);
+    const greedyIsUnique = new Set(
+      parsedData.map((row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0"))
+    ).size === parsedData.length;
+    return {
+      isUnique,
+      duplicateCount,
+      duplicateValues,
+      candidates,
+      suggestedAdditionalKeys: added.length > 0 && greedyIsUnique ? added : null
+    };
+  }
+  var PSYCH_DS_FILENAME_RE = /^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\.(csv|tsv)$/;
+  function isValidPsychDSDataFilename(name) {
+    return PSYCH_DS_FILENAME_RE.test(name);
+  }
+  function toPsychDSValue(name, fallback = "value") {
+    const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
+    if (parts.length === 0) return fallback;
+    return parts[0] + parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
+  }
+  function deriveFallbackBase(stem) {
+    return `subject-${toPsychDSValue(stem, "file")}`;
+  }
+  function deriveArrayFilename(parentBase, columnName) {
+    return `${parentBase}_measure-${toPsychDSValue(columnName, "col")}_data.csv`;
+  }
+  function objectsToCSV(rows, priorityCols = ["trial_index", "element_index"]) {
+    if (rows.length === 0) return "";
+    const allKeys = /* @__PURE__ */ new Set();
+    for (const row of rows) {
+      for (const key of Object.keys(row)) allKeys.add(key);
+    }
+    const otherCols = [...allKeys].filter((k) => !priorityCols.includes(k));
+    const headers = [...priorityCols.filter((c) => allKeys.has(c)), ...otherCols];
+    const escape = (val) => {
+      if (val === null || val === void 0) return "";
+      const str = typeof val === "object" ? JSON.stringify(val) : String(val);
+      return str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r") ? `"${str.replace(/"/g, '""')}"` : str;
+    };
+    const lines = [headers.join(",")];
+    for (const row of rows) {
+      lines.push(headers.map((h) => escape(row[h])).join(","));
+    }
+    return lines.join("\r\n");
+  }
+  function disambiguateArrayFilename(base, used) {
+    if (!used.has(base)) return base;
+    const suffix = "_data.csv";
+    const root = base.endsWith(suffix) ? base.slice(0, -suffix.length) : base.replace(/\.csv$/i, "");
+    let n = 2;
+    let candidate = `${root}${n}${suffix}`;
+    while (used.has(candidate)) {
+      n += 1;
+      candidate = `${root}${n}${suffix}`;
+    }
+    return candidate;
+  }
+  var isUnnamedHeader = (key) => key.trim() === "";
+  function hasUnnamedColumns(rows) {
+    return rows.some((row) => Object.keys(row).some(isUnnamedHeader));
+  }
+  function stripUnnamedColumns(rows) {
+    const unnamed = /* @__PURE__ */ new Set();
+    for (const row of rows) {
+      for (const key of Object.keys(row)) {
+        if (isUnnamedHeader(key)) unnamed.add(key);
+      }
+    }
+    if (unnamed.size > 0) {
+      for (const row of rows) {
+        for (const key of unnamed) delete row[key];
+      }
+    }
+    return { rows, dropped: [...unnamed] };
+  }
+  function buildPsychDSDataFiles(args) {
+    const {
+      base,
+      mainRows,
+      mainContent,
+      extractedArrays = /* @__PURE__ */ new Map(),
+      extractedObjects = /* @__PURE__ */ new Map(),
+      joinKeys = ["trial_index"],
+      usedArrayFilenames = /* @__PURE__ */ new Set()
+    } = args;
+    const out = [];
+    const reserve = (name) => {
+      if (!isValidPsychDSDataFilename(name)) {
+        throw new Error(`Refusing to write non-Psych-DS-compliant data filename "${name}".`);
+      }
+      usedArrayFilenames.add(name);
+      return name;
+    };
+    const mainName = reserve(disambiguateArrayFilename(`${base}_data.csv`, usedArrayFilenames));
+    const { rows: cleanedMainRows, dropped: droppedMain } = stripUnnamedColumns(mainRows);
+    out.push({
+      filename: mainName,
+      content: mainContent !== void 0 && droppedMain.length === 0 ? mainContent : objectsToCSV(cleanedMainRows, ["trial_index"]),
+      kind: "main"
+    });
+    const arrayPriority = [...joinKeys, "element_index"];
+    for (const [colName, rows] of extractedArrays) {
+      const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+      out.push({ filename: name, content: objectsToCSV(rows, arrayPriority), kind: "array" });
+    }
+    for (const [colName, rows] of extractedObjects) {
+      const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+      out.push({ filename: name, content: objectsToCSV(rows, joinKeys), kind: "object" });
+    }
+    return out;
+  }
+  async function parseCSV(input) {
+    if (!parse) {
+      throw new Error("Parser module not loaded");
+    }
+    return new Promise((resolve, reject) => {
+      parse(input, {
+        columns: true,
+        // Treat the first row as headers
+        delimiter: ",",
+        // Specify the delimiter (e.g., comma)
+        bom: true
+        // Strip a leading UTF-8 BOM so the first header name isn't corrupted (e.g. "Participant_ID")
+      }, (err, records) => {
+        if (err) {
+          reject(err);
+        } else {
+          resolve(records);
+        }
+      });
+    });
+  }
+
+  // src/VariablesMap.ts
+  var VariablesMap = class _VariablesMap {
+    /**
+     *  Creates the VariablesMap by initialising an empty variable map. The jsPsych system
+     * variables (trial_type, trial_index, time_elapsed, extension_*) are NOT seeded here — they
+     * are registered lazily when their column is actually observed in the data (see
+     * {@link registerSystemVariable}). Seeding them unconditionally produced orphan
+     * variableMeasured entries (e.g. time_elapsed) for datasets that omit those columns, which
+     * fails Psych-DS validation (VARIABLE_MISSING_FROM_CSV_COLUMNS).
+     *
+     * @constructor
+     */
+    constructor() {
+      this.generateDefaultVariables();
+    }
+    /**
+     * The fixed jsPsych definition for a system column, or null if `name` is not a known system
+     * variable. Returns a fresh object on each call so callers never share/mutate one template.
+     */
+    static systemVariableTemplate(name) {
+      switch (name) {
+        case "trial_type":
+          return {
+            "@type": "PropertyValue",
+            name: "trial_type",
+            description: { default: "unknown", jsPsych: "The name of the plugin used to run the trial." },
+            value: "string"
+          };
+        case "trial_index":
+          return {
+            "@type": "PropertyValue",
+            name: "trial_index",
+            description: { default: "unknown", jsPsych: "The index of the current trial across the whole experiment." },
+            value: "number"
+          };
+        case "time_elapsed":
+          return {
+            "@type": "PropertyValue",
+            name: "time_elapsed",
+            description: {
+              default: "unknown",
+              jsPsych: "The number of milliseconds between the start of the experiment and when the trial ended."
+            },
+            value: "number"
+          };
+        case "extension_type":
+          return {
+            "@type": "PropertyValue",
+            name: "extension_type",
+            description: { default: "unknown", jsPsych: "The name(s) of the extension(s) used in the trial." },
+            value: "string"
+          };
+        case "extension_version":
+          return {
+            "@type": "PropertyValue",
+            name: "extension_version",
+            description: { default: "unknown", jsPsych: "The version(s) of the extension(s) used in the trial." },
+            value: "number"
+          };
+        default:
+          return null;
+      }
+    }
+    /**
+     * Lazily registers the default jsPsych definition for a system column the first time it is
+     * observed in the data. No-op (returns false) when `name` is not a known system variable or
+     * is already present; returns true when a new variable was registered. This is what keeps a
+     * system variable out of variableMeasured unless the data actually contains that column.
+     *
+     * @param {string} name - The column / system-variable name.
+     * @returns {boolean} - True if a variable was registered, false otherwise.
+     */
+    registerSystemVariable(name) {
+      if (this.containsVariable(name)) return false;
+      const template = _VariablesMap.systemVariableTemplate(name);
+      if (!template) return false;
+      this.setVariable(template);
+      return true;
+    }
+    /**
+     * Initialises the variable map. System variables are registered lazily (see the constructor
+     * and {@link registerSystemVariable}), so this just resets the map to empty.
+     */
+    generateDefaultVariables() {
+      this.variables = {};
+    }
+    /**
+     * Returns a list of the variables instead of an object according to the Psych-DS format.
+     *
+     * @returns {{}[]} - The list of variables represented as objects.
+     */
+    getList() {
+      var var_list = [];
+      for (const key of Object.keys(this.variables)) {
+        const variable = this.variables[key];
+        variable["description"] = this.collapseDescription(variable["description"]);
+        var_list.push(variable);
+      }
+      return var_list;
+    }
+    /**
+     * Collapses an internal { pluginType: description } map into a single schema.org-valid
+     * Text value. Descriptions are stored per-plugin and only ever hold multiple keys when the
+     * texts genuinely differ (identical texts are merged upstream in updateDescription). Psych-DS /
+     * schema.org require `description` to be Text, so an object value triggers an OBJECT_TYPE_MISSING
+     * validator warning — this folds everything down to a string.
+     *
+     * @private
+     * @param {*} description - The description value (a { pluginType: text } map, or already a string).
+     * @returns {string} - A single Text description.
+     */
+    collapseDescription(description) {
+      if (typeof description !== "object" || description === null) {
+        return description;
+      }
+      if (Object.keys(description).length === 0) {
+        console.error("Empty description");
+        return "unknown";
+      }
+      if (Object.keys(description).length > 1 && "default" in description) {
+        delete description["default"];
+      }
+      for (const descKey of Object.keys(description)) {
+        if (description[descKey] === "unknown" && Object.keys(description).length > 1) {
+          delete description[descKey];
+        }
+      }
+      return Object.values(description).join(" | ");
+    }
+    /**
+     * Allows user to set a variable and includes all the fields that are possible according to
+     * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable.
+     * Can also be used to overwrite existing variables if they have the same name.
+     *
+     * @param {VariableFields} variable - The fields of the variable that is being created.
+     */
+    setVariable(variable) {
+      if (!variable.name) {
+        console.warn("Name field is missing. Variable not added.", variable);
+        return;
+      }
+      this.variables[variable.name] = variable;
+      const unexpectedFields = Object.keys(variable).filter(
+        (key) => ![
+          "@type",
+          "name",
+          "description",
+          "value",
+          "identifier",
+          "minValue",
+          "maxValue",
+          "levels",
+          "levelsOrdered",
+          "na",
+          "naValue",
+          "alternateName",
+          "privacy"
+        ].includes(key)
+      );
+      if (unexpectedFields.length > 0) {
+        console.warn(
+          `Unexpected fields (${unexpectedFields.join(
+            ", "
+          )}) detected and included in the variable object.`
+        );
+      }
+    }
+    /**
+     * Allows you to get information for a single variable returning empty dict if it doesn't exist.
+     * Allows you to update fields but not recommended in favor of updateVariable.
+     *
+     * @param {string} name
+     * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist
+     */
+    getVariable(name) {
+      return this.variables[name] || {};
+    }
+    /**
+     * Checks if variable exists in VariablesMap.
+     *
+     * @param {string} name - Name of variable
+     * @returns {boolean} - True if exists, false if doesn't.
+     */
+    containsVariable(name) {
+      return name in this.variables;
+    }
+    /**
+     * Method that gets a list of the names of variables.
+     *
+     * @returns {string[]} - String list containing names of existing variables.
+     */
+    getVariableNames() {
+      var var_list = [];
+      for (const key of Object.keys(this.variables)) {
+        var_list.push(this.variables[key]["name"]);
+      }
+      return var_list;
+    }
+    /**
+     * Allows you to update a variable or add a value in the case of updating values. In other situations will
+     * replace the existing value with the new value. Has special cases and logic for levels and names making it
+     * easier to update variable values.
+     *
+     *
+     * @param {string} var_name - Name of variable to be updated.
+     * @param {string} field_name - Specific field to be updated.
+     * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType.
+     */
+    updateVariable(var_name, field_name, added_value) {
+      const updated_var = this.getVariable(var_name);
+      if (Object.keys(updated_var).length === 0) {
+        console.error(`Variable "${var_name}" does not exist.`);
+        return;
+      }
+      if (field_name === "levels") {
+        this.updateLevels(updated_var, added_value);
+      } else if (field_name === "minValue" || field_name === "maxValue") {
+        this.updateMinMax(updated_var, added_value, field_name);
+      } else if (field_name === "description") {
+        this.updateDescription(updated_var, added_value);
+      } else if (field_name === "name") {
+        this.updateName(updated_var, added_value);
+      } else {
+        updated_var[field_name] = added_value;
+      }
+    }
+    /**
+     * Logic that handles updates to levels field by creating new array if necessary, otherwise
+     * pushing the value if it doesn't already exist. Levels can only be added to with strings.
+     *
+     * @private
+     * @param {*} updated_var - The variable object to be updated.
+     * @param {*} added_value - The value being added to the levels field.
+     */
+    updateLevels(updated_var, added_value) {
+      if (typeof added_value === "object")
+        return;
+      const MAX_LENGTH = 50;
+      if (added_value.length > MAX_LENGTH) {
+        added_value = added_value.substring(0, MAX_LENGTH) + "...";
+      }
+      if (!Array.isArray(updated_var["levels"])) {
+        updated_var["levels"] = [];
+      }
+      if (!updated_var["levels"].includes(added_value)) {
+        updated_var["levels"].push(added_value);
+      }
+    }
+    /**
+     * Logic to update the min and max for the specific value.
+     *
+     * @private
+     * @param {*} updated_var - The variable object to be updated.
+     * @param {*} added_value - The value that is being checked against current min/max.
+     * @param {*} field_name - The name of field that is being checked (min or max).
+     */
+    updateMinMax(updated_var, added_value, field_name) {
+      if (!("minValue" in updated_var) || !("maxValue" in updated_var)) {
+        updated_var["maxValue"] = updated_var["minValue"] = added_value;
+        return;
+      }
+      if (field_name === "minValue" && updated_var["minValue"] > added_value) {
+        updated_var["minValue"] = added_value;
+      } else if (field_name === "maxValue" && updated_var["maxValue"] < added_value) {
+        updated_var["maxValue"] = added_value;
+      }
+    }
+    /**
+     * Logic for updating description field that checks to see value already exists. If it does,
+     * appends the pluginType to the current key and pushes that along with the value. Creates
+     * map if it does not exist.
+     *
+     * @private
+     * @param {*} updated_var - The variable to be updated.
+     * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field.
+     */
+    updateDescription(updated_var, added_value) {
+      const add_key = Object.keys(added_value)[0];
+      const add_value = Object.values(added_value)[0];
+      if (add_key === "undefined" || add_value === "undefined") {
+        console.error("New value is passed in bad format", added_value);
+        return;
+      }
+      var exists = false;
+      if (typeof updated_var["description"] !== "object") {
+        const existing = updated_var["description"];
+        updated_var["description"] = typeof existing === "string" && existing && existing !== "unknown" ? { default: existing } : {};
+      }
+      Object.entries(updated_var["description"]).forEach(([key, value]) => {
+        if (value === add_value) {
+          if (!key.includes(add_key)) {
+            delete updated_var["description"][key];
+            updated_var["description"][key + ", " + add_key] = add_value;
+          }
+          exists = true;
+        }
+      });
+      if (!exists) Object.assign(updated_var["description"], added_value);
+    }
+    /**
+     * Logic for updating name. Needs to retain all the old values while creating a new reference in the map
+     * while keeping the same perspe
+     *
+     * @private
+     * @param {*} updated_var
+     * @param {*} added_value
+     */
+    updateName(updated_var, added_value) {
+      const old_name = updated_var["name"];
+      updated_var["name"] = added_value;
+      delete this.variables[old_name];
+      this.setVariable(updated_var);
+    }
+    /**
+     * Allows you to delete a variable by key/name. Returns console error if not found.
+     *
+     * @param {string} var_name - Name of variable to be deleted.
+     */
+    deleteVariable(var_name) {
+      if (var_name in this.variables) {
+        delete this.variables[var_name];
+      } else {
+        console.error(`Variable "${var_name}" does not exist.`);
+      }
+    }
+  };
+
+  // src/index.ts
+  var JsPsychMetadata = class {
+    /**
+     * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context
+     *  allowing it to access the screen printing information.
+     *
+     * @constructor
+     * @param {JsPsych} JsPsych
+     */
+    constructor(verbose) {
+      /**
+       * Initializes a set that contains the variable fields that are to be ignored, so can help with later 
+       * logic when generating data.
+       *
+       * @private
+       * @type {*}
+       */
+      this.ignored_variables = new Set(SYSTEM_COLUMNS);
+      /**
+       * Verbose mode that is used in by the tools that call this to print fetching messages and 
+       * reading messages.
+       *
+       * @private
+       * @type {boolean}
+       */
+      this.verbose = false;
+      this.extractedArrays = /* @__PURE__ */ new Map();
+      // Plain (non-array) object columns expanded by expandObjectFields. One row per trial,
+      // keyed by the same arrayJoinKeys as extractedArrays, with a column for every dotted
+      // descendant variable (leaf scalars, intermediate object nodes, and nested-array parents).
+      // The CLI writes these as separate Psych-DS CSVs so those dotted names map to real columns.
+      this.extractedObjects = /* @__PURE__ */ new Map();
+      this.arrayJoinKeys = ["trial_index"];
+      this.mixedColumns = /* @__PURE__ */ new Set();
+      this.metadata = {};
+      this.setMetadataField("name", "title");
+      this.setMetadataField("schemaVersion", "Psych-DS 0.4.0");
+      this.setMetadataField("@context", "https://schema.org");
+      this.setMetadataField("@type", "Dataset");
+      this.setMetadataField("description", "Dataset generated using JsPsych");
+      this.authors = new AuthorsMap();
+      this.variables = new VariablesMap();
+      this.pluginCache = new PluginCache();
+      this.verbose = verbose;
+    }
+    /**
+     * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields.
+     *
+     * @param {string} key - Metadata field name
+     * @param {*} value - Data associated with the field
+     */
+    setMetadataField(key, value) {
+      this.metadata[key] = value;
+    }
+    /**
+     * Simple get that accesses the data associated with a field.
+     *
+     * @param {string} key - Field name
+     * @returns {*} - Data associated with the field
+     */
+    getMetadataField(key) {
+      return this.metadata[key];
+    }
+    /**
+     * Checks if the metadata field exists in the metadata.
+     *
+     * @param {string} key - Key of metadata being checked.
+     * @returns {*} - Boolean
+     */
+    containsMetadataField(key) {
+      return key in this.metadata;
+    }
+    /**
+     * Deletes a metadata from the metadata if it exists. 
+     *
+     * @param {string} key - Name of field to be deleted
+     */
+    deleteMetadataField(key) {
+      if (key in this.metadata) {
+        delete this.metadata[key];
+      } else {
+        console.error(`Metadata "${key}" does not exist.`);
+      }
+    }
+    /**
+     * Returns the final Metadata in a single javascript object. Bundles together the author and variables
+     * together in a list rather than object compliant with Psych-DS standards. Seems that javascript get
+     * are implictly called.
+     *
+     * @returns {{}} - Final Metadata object
+     */
+    getMetadata() {
+      const res = this.metadata;
+      res["author"] = this.authors.getList();
+      res["variableMeasured"] = this.variables.getList();
+      return res;
+    }
+    getUserMetadataFields() {
+      const res = {};
+      const ignored_fields = /* @__PURE__ */ new Set(["schemaVersion", "@type", "@context", "author", "variableMeasured"]);
+      for (const key in this.metadata) {
+        if (!ignored_fields.has(key)) {
+          res[key] = this.metadata[key];
+        }
+      }
+      return res;
+    }
+    /**
+     * Returns the variable fields while excluding the authors and variables.`
+     *
+     * @returns {{}} - Final Metadata object
+     */
+    getMetadataFields() {
+      const res = this.metadata;
+      delete res["author"];
+      delete res["variableMeasured"];
+      return res;
+    }
+    /**
+     * Method that creates an author. This method can also be used to overwrite existing authors
+     * with the same name in order to update fields.
+     *
+     * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+     */
+    setAuthor(fields) {
+      this.authors.setAuthor(fields);
+    }
+    /**
+     * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+     *
+     * @param {string} name - Name of author to be used as key.
+     * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+     */
+    getAuthor(name) {
+      return this.authors.getAuthor(name);
+    }
+    /**
+     * Returns a list of the authors defined in the metadata.
+     *
+     * @returns {(string | AuthorFields)[]} - Authors
+     */
+    getAuthorList() {
+      return this.authors.getList();
+    }
+    /**
+     * Deletes an author from the authorsField.
+     *
+     * @param {string} name - Name of author to be deleted.
+     */
+    deleteAuthor(name) {
+      this.authors.deleteAuthor(name);
+    }
+    /**
+     * Method that creates a variable. This method can also be used to overwrite variables with the same name
+     * as a way to update fields.
+     *
+     * @param {{
+     *     @type?: string;
+     *     name: string; // required
+     *     description?: string | {};
+     *     value?: string; // string, boolean, or number
+     *     identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
+     *     minValue?: number;
+     *     maxValue?: number;
+     *     levels?: string[] | []; // technically property values in the other one but not sure how to format it
+     *     levelsOrdered?: boolean;
+     *     na?: boolean;
+     *     naValue?: string;
+     *     alternateName?: string;
+     *     privacy?: string;
+     *   }} fields - Fields associated with the current Psych-DS standard.
+     */
+    setVariable(variable) {
+      this.variables.setVariable(variable);
+    }
+    /**
+     * Allows you to access a variable's information by using the name of the variable. Can
+     * be used to update fields within a variable, but suggest using updateVariable() to prevent errors.
+     *
+     * @param {string} name - Name of variable to be accessed
+     * @returns {{}} - Returns object of fields
+     */
+    getVariable(name) {
+      return this.variables.getVariable(name);
+    }
+    /**
+     * Returns a list of the variables defined in the metadata.
+     *
+     * @returns {{}[]} - Authors
+     */
+    getVariableList() {
+      return this.variables.getList();
+    }
+    /**
+     * Allows you to check if the name of the variable exists in variablesMap.
+     *
+     * @param {string} name - Name of variable
+     * @returns {boolean} - Does variable exist in variables
+     */
+    containsVariable(name) {
+      return this.variables.containsVariable(name);
+    }
+    /**
+     * Allows you to update a variable or add a value in the case of updating values. In other situations will
+     * replace the existing value with the new value.
+     *
+     * @param {string} var_name - Name of variable to be updated.
+     * @param {string} field_name - Name of field to be updated.
+     * @param {(string | boolean | number | {})} added_value - Value to be used in the update.
+     */
+    updateVariable(var_name, field_name, added_value) {
+      this.variables.updateVariable(var_name, field_name, added_value);
+    }
+    /**
+     * Allows you to delete a variable by key/name.
+     *
+     * @param {string} var_name - Name of variable to be deleted.
+     */
+    deleteVariable(var_name) {
+      this.variables.deleteVariable(var_name);
+    }
+    /**
+     * Gets a list of all the variable names.
+     *
+     * @returns {string[]} - List of variable string names.
+     */
+    getVariableNames() {
+      return this.variables.getVariableNames();
+    }
+    /**
+     * Returns accumulated array-column data keyed by column name.
+     * Each entry is a list of rows with join key columns, element_index, and the element's own fields.
+     * Used by the CLI to write Psych-DS compliant separate CSV files.
+     */
+    getExtractedArrays() {
+      return this.extractedArrays;
+    }
+    /**
+     * Returns accumulated plain-object-column data keyed by the top-level column name.
+     * Each entry is one row per trial: the join key columns plus a column for every dotted
+     * descendant variable expanded from that object (matching the names in variableMeasured).
+     * Used by the CLI to write a separate Psych-DS CSV per object column, so those dotted
+     * sub-variables resolve to real columns. No element_index (one row per trial, not per element).
+     */
+    getExtractedObjects() {
+      return this.extractedObjects;
+    }
+    /**
+     * Returns the join key columns used in the most recent generate() call.
+     * The CLI uses this to order columns correctly in extracted array CSVs.
+     */
+    getArrayJoinKeys() {
+      return [...this.arrayJoinKeys];
+    }
+    warnJoinKeyUniqueness(analysis) {
+      const keyStr = this.arrayJoinKeys.join(", ");
+      const exampleStr = analysis.duplicateValues.slice(0, 3).map((v) => Object.entries(v).map(([k, val]) => `${k}=${val}`).join(", ")).join("; ");
+      let msg = `[jspsych-metadata] Join key (${keyStr}) is not unique in this dataset
+  (${analysis.duplicateCount} duplicate rows; e.g. ${exampleStr})
+`;
+      if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length === 0) {
+        const sufficient = analysis.candidates.filter((c) => c.makesUnique).map((c) => c.column);
+        const example = JSON.stringify([sufficient[0], ...this.arrayJoinKeys]);
+        msg += `  Sufficient fix: add one of these columns to arrayJoinKeys:
+    ${sufficient.join(", ")}
+  Pass { arrayJoinKeys: ${example} } as the options argument to generate().`;
+      } else if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length > 0) {
+        const combined = JSON.stringify([...analysis.suggestedAdditionalKeys, ...this.arrayJoinKeys]);
+        msg += `  No single column makes rows unique. Suggested combination:
+    ${analysis.suggestedAdditionalKeys.join(" + ")}
+  Pass { arrayJoinKeys: ${combined} } as the options argument to generate().`;
+      } else {
+        msg += `  No combination of available columns was found to make rows unique.
+  Your data may contain genuinely duplicate rows.
+  Extracted array CSVs will have non-unique join keys.`;
+      }
+      console.warn(msg);
+    }
+    /**
+     * Method that allows you to display metadata at the end of an experiment.
+     *
+     * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling.
+     */
+    displayMetadata(display_element) {
+      const elementId = "jspsych-metadata-display";
+      const metadata_string = JSON.stringify(this.getMetadata(), null, 2);
+      display_element.innerHTML += `

Metadata

`;
+      document.getElementById(elementId).textContent += metadata_string;
+    }
+    /**
+     * Method that begins a download for the dataset_description.json at the end of experiment.
+     * Allows you to download the metadat.
+     */
+    localSave() {
+      let data_string = JSON.stringify(this.getMetadata());
+      saveTextToFile(data_string, "dataset_description.json");
+    }
+    /**
+     * This method loads the metadata into the metadata object. This takes in the"dataset_description.json" string content 
+     * and first parses it as an object. This then loads in all the fields, authors and variables into the metadata object by calling all the 
+     * relevant methods that overwrites the default data.
+     *
+     * @param {string} stringMetadata - String version of the metadata to be loaded from "dataset_description.json".
+     */
+    loadMetadata(stringMetadata) {
+      const meta = JSON.parse(stringMetadata);
+      for (const field_key in meta) {
+        if (field_key === "variableMeasured") {
+          for (const variable of meta[field_key]) {
+            this.setVariable(variable);
+          }
+        } else if (field_key === "author") {
+          for (const author of meta[field_key]) {
+            this.setAuthor(author);
+          }
+        } else {
+          this.setMetadataField(field_key, meta[field_key]);
+        }
+      }
+    }
+    /**
+     * Generates observations based on the input data and processes optional metadata. This is the
+     * outer wrapper function that should called and handles the logic of reading individual observations.
+     *
+     * This method accepts data as a JSON string, a CSV string, or an already-parsed array of
+     * observation objects. A string is parsed according to `ext`; an array is consumed as-is.
+     * Each observation is processed asynchronously via `generateObservation`. Optionally, metadata
+     * options can be provided as an object, and each key-value pair is processed by `processMetadata`.
+     *
+     * NOTE: when `data` is a pre-parsed array it is consumed in place and MUTATED — unnamed
+     * (blank-header) columns are deleted from the row objects. Callers that need the rows to stay
+     * pristine must pass a copy. This lets a caller parse a file once and share the rows with
+     * generate() instead of having generate() re-parse the same content.
+     *
+     * @async
+     * @param {Array|String} data - Observations to generate from: a pre-parsed array (consumed as-is and mutated in place), a JSON string, or a CSV string.
+     * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
+     * @param {'json'|'csv'} [ext='json'] - Format of a string `data`; ignored when `data` is already an array.
+     * @param {Object} [options={}] - arrayJoinKeys / suppressJoinKeyWarning, plus synthesizedSourceRecordId for pre-parsed callers that tagged a synthetic source_record_id themselves.
+     */
+    async generate(data, metadata = {}, ext = "json", options = {}) {
+      this.extractedArrays = /* @__PURE__ */ new Map();
+      this.extractedObjects = /* @__PURE__ */ new Map();
+      this.arrayJoinKeys = options.arrayJoinKeys ?? ["trial_index"];
+      var parsed_data;
+      let synthesizedSourceRecordId = options.synthesizedSourceRecordId ?? false;
+      if (Array.isArray(data)) {
+        parsed_data = data;
+      } else if (ext === "csv") {
+        parsed_data = await parseCSV(data);
+      } else if (ext === "json") {
+        const parseStats = {};
+        parsed_data = parseJsonData(data, { tagSourceRecordId: true }, parseStats);
+        synthesizedSourceRecordId = parseStats.synthesizedSourceRecordId === true;
+      }
+      if (!Array.isArray(parsed_data)) {
+        throw new Error("Parsed data is not in correct format: Expected an array of observations");
+      }
+      const { dropped } = stripUnnamedColumns(parsed_data);
+      if (dropped.length > 0) {
+        console.warn(
+          `Dropped ${dropped.length} unnamed column${dropped.length > 1 ? "s" : ""} from the data \u2014 Psych-DS requires every column to have a name (usually a row-index column added by R's write.csv). Excluded from variableMeasured.`
+        );
+      }
+      const rows = parsed_data;
+      const hasColumn = (col) => ext === "json" && rows.some((row) => row && typeof row === "object" && col in row);
+      const idColumn = hasColumn("source_record_id") ? "source_record_id" : hasColumn("participant_id") ? "participant_id" : void 0;
+      if (idColumn && !this.arrayJoinKeys.includes(idColumn)) {
+        this.arrayJoinKeys = [idColumn, ...this.arrayJoinKeys];
+      }
+      const analysis = analyzeJoinKeys(parsed_data, this.arrayJoinKeys);
+      if (!analysis.isUnique && !options.suppressJoinKeyWarning) this.warnJoinKeyUniqueness(analysis);
+      for (const observation of parsed_data) {
+        await this.generateObservation(observation);
+      }
+      if (synthesizedSourceRecordId && this.containsVariable("source_record_id")) {
+        const existing = this.getVariable("source_record_id");
+        this.setVariable({
+          ...existing,
+          description: { default: "Synthetic source-record identifier (0-based), assigned one per source record (one JSON-Lines line, which is usually but not always one participant) because the raw data carried no identifier column. NOT a real subject ID from the experiment \u2014 it only orders/links records as they appeared in the source file, and serves as a join key connecting each trial to its extracted array/object rows." }
+        });
+      }
+      await this.updateMetadata(metadata);
+    }
+    /**
+     * This function iterates through the entire row of data stepping through one column at a time.
+     * It is designed to only be accessed through calling generate on an entire data file. 
+     * Searching for plugin, plugin version, extension, extension it then calls the 
+     * helper methods that process the individual row of data. There is limited error chcking and 
+     * type conversion from csv due to the way that csv data is represented as strings.
+     * This method also handles extensions, declaring them if necessary and iterate through each.
+     * This method also skips generating descriptions the variables that should the same for 
+     * all variables and instead updates their fields. 
+     *
+     * @private
+     * @async
+     * @param {*} observation Dictionary that represent one row of data
+     * @returns {*}
+     */
+    async generateObservation(observation) {
+      const version2 = observation["plugin_version"] ? observation["plugin_version"] : null;
+      const pluginType = observation["trial_type"];
+      const extensionType = observation["extension_type"];
+      const extensionVersion = observation["extension_version"];
+      const joinValues = this.arrayJoinKeys.reduce((acc, k) => {
+        acc[k] = observation[k];
+        return acc;
+      }, {});
+      for (const variable in observation) {
+        var value = observation[variable];
+        var type = typeof value;
+        if (!this.containsVariable(variable)) {
+          if (this.ignored_variables.has(variable)) {
+            this.variables.registerSystemVariable(variable);
+          } else {
+            this.setVariable({
+              "@type": "PropertyValue",
+              name: variable,
+              description: { default: "unknown" },
+              value: "unknown"
+            });
+          }
+        }
+        if (value === null || value === void 0 || value === "" || value === "null") {
+          continue;
+        }
+        if (type === "string") {
+          const asNumber = Number(value);
+          if (value.trim() !== "" && Number.isFinite(asNumber)) {
+            type = "number";
+            value = asNumber;
+          } else if (value.startsWith("{") || value.startsWith("[")) {
+            const parsed = tryParseJSON(value);
+            if (parsed !== null) {
+              value = parsed;
+              type = Array.isArray(parsed) ? "array" : "object";
+            }
+          }
+        }
+        if (this.ignored_variables.has(variable)) {
+          this.updateFields(variable, value, type);
+        } else {
+          if (type === "object" && value !== null && !Array.isArray(value)) {
+            const objectRow = { ...joinValues };
+            await this.expandObjectFields(variable, value, pluginType, version2, joinValues, objectRow);
+            const existingObjects = this.extractedObjects.get(variable) ?? [];
+            existingObjects.push(objectRow);
+            this.extractedObjects.set(variable, existingObjects);
+          } else if (type === "array" || type === "object" && Array.isArray(value)) {
+            await this.generateMetadata(variable, value, pluginType, version2);
+            const existingVar = this.containsVariable(variable) ? this.getVariable(variable) : null;
+            const existingType = existingVar?.value;
+            if (existingType !== "string" && existingType !== "number" && existingType !== "boolean") {
+              this.updateVariable(variable, "value", "array");
+            }
+            await this.accumulateArrayColumn(variable, value, joinValues, pluginType, version2);
+          } else {
+            await this.generateMetadata(variable, value, pluginType, version2);
+          }
+          if (extensionType) {
+            await Promise.all(
+              extensionType.map(async (ext, index) => {
+                if (ext && extensionVersion[index])
+                  await this.generateMetadata(variable, value, ext, extensionVersion[index], true);
+              })
+            );
+          }
+        }
+      }
+    }
+    /**
+     * Iterates through one single datapoint which can be thought of as one row-column pair. 
+     * This method keeps in mind the versionType or pluginType and uses this to generate the 
+     * metadata. 
+     *
+     * @private
+     * @async
+     * @param {*} variable - The column name
+     * @param {*} value - The value at the row-column mapping that is being used to update fields
+     * @param {*} pluginType - The type of the plugin that is used for the fetching (can also be extension if extension?=true)
+     * @param {*} version - The version of the plugin that is not necessary but is used post v8 to ensure accurate fetching
+     * @param {?*} [extension] - This boolean determines whether is a extension to change fetching
+     * @returns {*}
+     */
+    async generateMetadata(variable, value, pluginType, version2, extension) {
+      const type = typeof value;
+      if (!this.containsVariable(variable)) {
+        const new_var = {
+          "@type": "PropertyValue",
+          name: variable,
+          description: { default: "unknown" },
+          value: type
+        };
+        this.setVariable(new_var);
+      } else {
+        const existing = this.getVariable(variable);
+        if (existing.value === "unknown") this.updateVariable(variable, "value", type);
+      }
+      if (pluginType) {
+        const pluginInfo = await this.getPluginInfo(pluginType, variable, version2, extension);
+        const description = pluginInfo["description"];
+        const new_description = description ? { [pluginType]: description } : { [pluginType]: "unknown" };
+        this.updateVariable(variable, "description", new_description);
+      }
+      this.updateFields(variable, value, type);
+    }
+    /**
+     * This calls an update to the individual fields of the metadata, updating levels and 
+     * minValue and maxValue depeneding on the variable type.
+     *
+     * @private
+     * @param {*} variable - The column of the data and name of variable
+     * @param {*} value - The datapoint 
+     * @param {*} type - The type of the datapoint
+     */
+    updateFields(variable, value, type) {
+      if (type === "boolean") return;
+      const existing = this.getVariable(variable);
+      if (type === "number") {
+        if (Array.isArray(existing.levels)) {
+          if (!this.mixedColumns.has(variable)) {
+            this.mixedColumns.add(variable);
+            console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+          }
+          this.updateVariable(variable, "levels", String(value));
+          return;
+        }
+        this.updateVariable(variable, "minValue", value);
+        this.updateVariable(variable, "maxValue", value);
+        return;
+      }
+      if (type !== "object") {
+        if ("minValue" in existing || "maxValue" in existing) {
+          if (!this.mixedColumns.has(variable)) {
+            this.mixedColumns.add(variable);
+            console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+          }
+          if ("minValue" in existing) this.updateVariable(variable, "levels", String(existing.minValue));
+          if ("maxValue" in existing && existing.maxValue !== existing.minValue) {
+            this.updateVariable(variable, "levels", String(existing.maxValue));
+          }
+          delete existing.minValue;
+          delete existing.maxValue;
+          this.updateVariable(variable, "value", "string");
+        }
+        if (existing.value === "boolean" && (value === "true" || value === "false")) {
+          return;
+        }
+        this.updateVariable(variable, "levels", value);
+      }
+    }
+    /**
+     * Iterates through the entire metadata options object by calling processMetadata() to act upon each of the 
+     * individual fields at one time. 
+     *
+     * @async
+     * @param {*} metadata - Metadata options that contains all the metadata according to Psych-DS formatting. 
+     */
+    async updateMetadata(metadata) {
+      for (const key in metadata) {
+        await this.processMetadata(metadata, key);
+      }
+    }
+    /**
+     * This is the method that processes each individual element of the metadata options to be updated. This can be called through generate or outside of it, 
+     * and this processes each element. 
+     *
+     * @private
+     * @param {*} metadata - An object that contains all of the metadata. This is used to access the value. 
+     * @param {*} key - String key that denotes what key-value mapping is being iterated upon. 
+     */
+    processMetadata(metadata, key) {
+      const value = metadata[key];
+      if (key === "variables") {
+        if (typeof value !== "object" || value === null) {
+          console.warn("Variable object is either null or incorrect type");
+          return;
+        }
+        for (let variable_key in value) {
+          if (!this.containsVariable(variable_key)) {
+            console.warn("Metadata does not contain variable:", variable_key);
+            continue;
+          }
+          const variable_parameters = value[variable_key];
+          if (typeof variable_parameters !== "object" || variable_parameters === null) {
+            console.warn(
+              "Parameters of variable:",
+              variable_key,
+              "is either null or incorrect type. The value",
+              variable_parameters,
+              "is either null or not an object."
+            );
+            continue;
+          }
+          for (const parameter in variable_parameters) {
+            const parameter_value = variable_parameters[parameter];
+            this.updateVariable(variable_key, parameter, parameter_value);
+            if (parameter === "value" && parameter_value === "boolean") {
+              this.applyBooleanOverride(variable_key);
+            }
+            if (parameter === "name") variable_key = parameter_value;
+          }
+        }
+      } else if (key === "author") {
+        if (typeof value !== "object" || value === null) {
+          console.warn("Author object is not correct type");
+          return;
+        }
+        for (const author_key in value) {
+          const author = value[author_key];
+          if (typeof author !== "string" && !("name" in author)) author["name"] = author_key;
+          this.setAuthor(author);
+        }
+      } else this.setMetadataField(key, value);
+    }
+    /**
+     * Applies a user-chosen `value:"boolean"` override to an already-populated variable.
+     * Warns when the values detected from the data don't map cleanly to boolean logic
+     * (anything other than true/false/0/1, case-insensitive), then drops the detected
+     * levels/min/max so the variable matches how genuine booleans are recorded (no levels).
+     */
+    applyBooleanOverride(variableName) {
+      const existing = this.getVariable(variableName);
+      const isBooleanLike = (v) => {
+        const s = String(v).trim().toLowerCase();
+        return s === "true" || s === "false" || s === "0" || s === "1";
+      };
+      const offenders = /* @__PURE__ */ new Set();
+      if (Array.isArray(existing.levels)) {
+        for (const level of existing.levels) if (!isBooleanLike(level)) offenders.add(String(level));
+      }
+      if (typeof existing.minValue === "number" && !isBooleanLike(existing.minValue)) offenders.add(String(existing.minValue));
+      if (typeof existing.maxValue === "number" && !isBooleanLike(existing.maxValue)) offenders.add(String(existing.maxValue));
+      if (offenders.size > 0) {
+        const sample = [...offenders].slice(0, 10).join(", ");
+        const more = offenders.size > 10 ? `, \u2026(+${offenders.size - 10} more)` : "";
+        console.warn(
+          `Variable "${variableName}" was set to value:"boolean", but the detected values don't map cleanly to true/false: ${sample}${more}. Double-check this is the intended type.`
+        );
+      }
+      delete existing.levels;
+      delete existing.minValue;
+      delete existing.maxValue;
+    }
+    /**
+     * Registers the keys of a plain JSON object as dotted sub-variables
+     * (e.g. response.Q0, response.Q1) and registers the parent with value: "object".
+     *
+     * Recurses into nested plain objects so structures more than one level deep are
+     * fully expanded (e.g. response.address.city). Nested arrays are registered with
+     * value: "array" (typeof [] === "object", so the inferred type must be overridden)
+     * and, when they hold objects, extracted into a separate CSV keyed by their dotted
+     * column name — mirroring how top-level array columns are handled.
+     *
+     * @param joinValues - The current row's join key values, prepended to every
+     *   extracted nested-array row so the sub-table can be rejoined to the main data.
+     */
+    async expandObjectFields(parentName, obj, pluginType, version2, joinValues, row) {
+      await this.generateMetadata(parentName, obj, pluginType, version2);
+      for (const key of Object.keys(obj)) {
+        const childName = `${parentName}.${key}`;
+        const childValue = obj[key];
+        if (row) row[childName] = childValue;
+        if (childValue !== null && typeof childValue === "object" && !Array.isArray(childValue)) {
+          await this.expandObjectFields(childName, childValue, pluginType, version2, joinValues, row);
+        } else if (Array.isArray(childValue)) {
+          await this.generateMetadata(childName, childValue, pluginType, version2);
+          this.updateVariable(childName, "value", "array");
+          await this.accumulateArrayColumn(childName, childValue, joinValues, pluginType, version2);
+        } else {
+          await this.generateMetadata(childName, childValue, pluginType, version2);
+        }
+      }
+    }
+    /**
+     * Accumulates the object elements of an array column into `extractedArrays` for
+     * separate Psych-DS CSV output, keyed by the column's (possibly dotted) name.
+     * Each emitted row is the join key values, an `element_index`, then the element's
+     * fields under DOTTED names (`columnName.field`) so they don't collide with top-level
+     * columns or with fields of other array columns. Every emitted column is registered in
+     * variableMeasured so the sidecar CSV has no columns missing from the metadata.
+     *
+     * Element fields recurse (see expandElementFields): a nested plain object is expanded
+     * into deeper dotted columns in the SAME row; a nested array is extracted into its own
+     * grandchild CSV, joinable via `${columnName}.element_index` (this element's position)
+     * carried alongside the existing join keys.
+     *
+     * Null / primitive top-level array elements are skipped; arrays with no object elements
+     * produce no rows.
+     */
+    async accumulateArrayColumn(columnName, arr, joinValues, pluginType, version2) {
+      const elements = [];
+      arr.forEach((element, index) => {
+        if (element !== null && element !== void 0) elements.push({ element, index });
+      });
+      if (elements.length === 0) return;
+      if (!this.containsVariable("element_index")) {
+        this.setVariable({
+          "@type": "PropertyValue",
+          name: "element_index",
+          description: { default: "Position of this element within its source array column (0-based)." },
+          value: "number"
+        });
+      }
+      for (const joinKey of Object.keys(joinValues)) {
+        if (!this.containsVariable(joinKey)) {
+          this.setVariable({
+            "@type": "PropertyValue",
+            name: joinKey,
+            description: { default: "Join key referencing the position of an enclosing array element (0-based index)." },
+            value: "number"
+          });
+        }
+      }
+      const existing = this.extractedArrays.get(columnName) ?? [];
+      for (const { element, index } of elements) {
+        const row = { ...joinValues, element_index: index };
+        const nestedJoin = { ...joinValues, [`${columnName}.element_index`]: index };
+        if (typeof element === "object" && !Array.isArray(element)) {
+          await this.expandElementFields(columnName, element, row, nestedJoin, pluginType, version2);
+        } else {
+          const valueName = `${columnName}.value`;
+          row[valueName] = element;
+          if (Array.isArray(element)) {
+            await this.registerNodeVariable(valueName, element, "array", pluginType, version2);
+            await this.accumulateArrayColumn(valueName, element, nestedJoin, pluginType, version2);
+          } else {
+            await this.registerScalarField(valueName, element, pluginType, version2);
+          }
+        }
+        existing.push(row);
+      }
+      this.extractedArrays.set(columnName, existing);
+    }
+    /**
+     * Recursively records one array element's fields into `row` under dotted names. Scalars become
+     * columns with type + min/max/levels tracking; nested plain objects are expanded into the SAME
+     * row (deeper dotted columns); nested arrays are extracted into their own grandchild CSV via
+     * accumulateArrayColumn (keyed by `nestedJoin`). Object/array nodes are also kept as a single
+     * dotted JSON column so their own name is represented as a column too.
+     */
+    async expandElementFields(prefix, obj, row, nestedJoin, pluginType, version2) {
+      for (const key of Object.keys(obj)) {
+        const name = `${prefix}.${key}`;
+        const value = obj[key];
+        row[name] = value;
+        if (value !== null && typeof value === "object" && !Array.isArray(value)) {
+          await this.registerNodeVariable(name, value, "object", pluginType, version2);
+          await this.expandElementFields(name, value, row, nestedJoin, pluginType, version2);
+        } else if (Array.isArray(value)) {
+          await this.registerNodeVariable(name, value, "array", pluginType, version2);
+          await this.accumulateArrayColumn(name, value, nestedJoin, pluginType, version2);
+        } else {
+          await this.registerScalarField(name, value, pluginType, version2);
+        }
+      }
+    }
+    /** Registers an object/array node variable once (with its plugin description, if any). */
+    async registerNodeVariable(name, value, type, pluginType, version2) {
+      if (this.containsVariable(name) && this.getVariable(name).value !== "unknown") return;
+      await this.generateMetadata(name, value, pluginType, version2);
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+      } else {
+        this.updateVariable(name, "value", type);
+      }
+    }
+    /**
+     * Registers one scalar array-element field under its dotted name (so the sidecar column is
+     * represented in variableMeasured), then folds later values into min/max/levels. Empty values
+     * still declare the column (placeholder) without polluting min/max/levels.
+     */
+    async registerScalarField(name, value, pluginType, version2) {
+      if (value === null || value === void 0 || value === "" || value === "null") {
+        if (!this.containsVariable(name)) {
+          this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: "unknown" });
+        }
+        return;
+      }
+      const type = typeof value;
+      const needsRegister = !this.containsVariable(name) || this.getVariable(name).value === "unknown";
+      if (needsRegister) {
+        await this.generateMetadata(name, value, pluginType, version2);
+        if (!this.containsVariable(name)) {
+          this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+          this.updateFields(name, value, type);
+        }
+      } else {
+        this.updateFields(name, value, type);
+      }
+    }
+    /**
+     * Gets the description of a variable in a plugin by fetching the source code of the plugin
+     * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+     * to extract the description for the variable (present as JSDoc); caches the result for future use.
+     *
+     * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+     * @param {string} variableName - The name of the variable for which information is to be fetched.
+     * @param {string} version - The version of the plugin or extension
+     * @param {string} extension - Boolean indicating if pluginType refers to extension
+     * @returns {Promise} The description of the plugin variable if found, otherwise null.
+     * @throws Will throw an error if the fetch operation fails.
+     */
+    async getPluginInfo(pluginType, variableName, version2, extension) {
+      return this.pluginCache.getPluginInfo(pluginType, variableName, version2, this.verbose, extension);
+    }
+  };
+  return __toCommonJS(index_exports);
+})();
+this.JsPsychMetadata = this.JsPsychMetadata.default || this.JsPsychMetadata;
diff --git a/functions/metadata/dist/index.js b/functions/metadata/dist/index.js
new file mode 100644
index 0000000..8f3d63f
--- /dev/null
+++ b/functions/metadata/dist/index.js
@@ -0,0 +1,1722 @@
+// src/AuthorsMap.ts
+var AuthorsMap = class {
+  /**
+   * Creates an empty instance of authors map. Doesn't generate default metadata because
+   * can't assume anything about the authors.
+   *
+   * @constructor
+   */
+  constructor() {
+    this.authors = {};
+  }
+  /**
+   * Returns the final list format of the authors according to Psych-DS standards.
+   *
+   * @returns {(AuthorFields | string)[]} - List of authors
+   */
+  getList() {
+    const author_list = [];
+    for (const key of Object.keys(this.authors)) {
+      author_list.push(this.authors[key]);
+    }
+    return author_list;
+  }
+  /**
+   * Method that creates an author. This method can also be used to overwrite existing authors
+   * with the same name in order to update fields.
+   *
+   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+   */
+  setAuthor(author) {
+    if (typeof author === "string") {
+      this.authors[author] = author;
+      return;
+    }
+    if (!author.name) {
+      console.warn("Name field is missing. Author not added.");
+      return;
+    }
+    const { name, ...rest } = author;
+    if (Object.keys(rest).length == 0) {
+      this.authors[name] = name;
+    } else {
+      const newAuthor = { name, ...rest };
+      this.authors[name] = newAuthor;
+      const unexpectedFields = Object.keys(author).filter(
+        (key) => !["@type", "name", "givenName", "familyName", "identifier"].includes(key)
+      );
+      if (unexpectedFields.length > 0) {
+        console.warn(
+          `Unexpected fields (${unexpectedFields.join(
+            ", "
+          )}) detected and included in the author object.`
+        );
+      }
+    }
+  }
+  /**
+   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+   *
+   * @param {string} name - Name of author to be used as key.
+   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+   */
+  getAuthor(name) {
+    if (name in this.authors) {
+      return this.authors[name];
+    } else {
+      console.warn("Author (", name, ") not found.");
+      return {};
+    }
+  }
+  /**
+   * Deletes the author if it exists, printing out warning if doesn't exist. 
+   *
+   * @param {string} author_name - Name of author to be deleted
+   */
+  deleteAuthor(author_name) {
+    if (author_name in this.authors) {
+      delete this.authors[author_name];
+    } else {
+      console.error(`Author "${author_name}" does not exist.`);
+    }
+  }
+};
+
+// src/PluginCache.ts
+var PluginCache = class {
+  constructor() {
+    this.pluginFields = {};
+  }
+  /**
+   * Gets the description of a variable in a plugin by fetching the source code of the plugin
+   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+   * to extract the description for the variable (present as JSDoc); caches the result for future use.
+   *
+   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+   * @param {string} variableName - The name of the variable for which information is to be fetched.
+   * @param {string} version - The name of the variable for which information is to be fetched. 
+   * @param {boolean} verbose - Indicates whether should run with verbose mode
+   * @param {boolean} [extension] - An optional flag to indicate if an extension should be used.
+   * @returns {Promise} The description of the plugin variable if found, otherwise null.
+   * @throws Will throw an error if the fetch operation fails.
+   */
+  async getPluginInfo(pluginType, variableName, version, verbose, extension) {
+    if (!(pluginType in this.pluginFields)) {
+      const fields = await this.generatePluginFields(pluginType, version, verbose, extension);
+      this.pluginFields[pluginType] = fields;
+    }
+    if (variableName in this.pluginFields[pluginType])
+      return this.pluginFields[pluginType][variableName];
+    else
+      return {
+        description: "unknown",
+        type: "unknown"
+      };
+  }
+  /**
+   * Method that handles the generation of the fields and calls helpers methods that 
+   * fetch and parse the plugin data.
+   *
+   * @private
+   * @async
+   * @param {string} pluginType - Name of plugin or extension to fetch.
+   * @param {string} version - String version to fetch
+   * @param {boolean} verbose - Boolean indicating verbose mode
+   * @param {?boolean} [extension] - Optional flag if pluginType is extension
+   * @returns {unknown}
+   */
+  async generatePluginFields(pluginType, version, verbose, extension) {
+    const script = await this.fetchScript(pluginType, version, verbose, extension);
+    if (script !== void 0 && script !== null && script !== "") {
+      try {
+        return this.parseJavadocString(script);
+      } catch (err) {
+        console.warn("* Error parsing", pluginType, err);
+        return {};
+      }
+    } else {
+      return {};
+    }
+  }
+  /**
+   * The method that generates the unpkg links based on whether extension vs plugin and the 
+   * specific type.
+   *
+   * @private
+   * @param {string} pluginType - Name of plugin or extension to fetch
+   * @param {string} version - String version used
+   * @param {?boolean} [extension] - Optional flag if pluginType is extension
+   * @returns {string}
+   */
+  generateUnpkg(pluginType, version, extension) {
+    if (extension) {
+      if (version) {
+        return `https://unpkg.com/@jspsych/extension-${pluginType}@${version}/src/index.ts`;
+      } else return `https://unpkg.com/@jspsych/extension-${pluginType}/src/index.ts`;
+    }
+    if (version) {
+      return `https://unpkg.com/@jspsych/plugin-${pluginType}@${version}/src/index.ts`;
+    } else return `https://unpkg.com/@jspsych/plugin-${pluginType}/src/index.ts`;
+  }
+  /**
+   * Fetches the actual script text content from unpkg. Calls the method to generate the link 
+   * and then handles error checking and fetching.
+   *
+   * @private
+   * @async
+   * @param {string} pluginType - The plugin or extension name to be fetched
+   * @param {string} version - The string version of the plugin
+   * @param {boolean} verbose - Boolean indicating verbose mode
+   * @param {?boolean} [extension] - Whether pluginType is extension
+   * @returns {unknown}
+   */
+  async fetchScript(pluginType, version, verbose, extension) {
+    const unpkgUrl = this.generateUnpkg(pluginType, version, extension);
+    if (verbose) console.log("-> fetching information for [", pluginType, "] from ->", unpkgUrl);
+    try {
+      const response = await fetch(unpkgUrl);
+      if (!response.ok) {
+        console.warn(`Plugin source not found for: ${pluginType} (HTTP ${response.status}). Descriptions will default to "unknown".`);
+        return void 0;
+      }
+      const scriptContent = await response.text();
+      return scriptContent;
+    } catch (error) {
+      console.error(
+        `Plugin fetching failed for:`,
+        pluginType,
+        "with error",
+        error,
+        "Note: if you are using a plugin not supported the main JsPsych branch this will always fail."
+      );
+      return void 0;
+    }
+  }
+  /**
+   * Extracts the content of the top-level `data: { ... }` block from a jsPsych plugin source
+   * file using brace counting. This is more robust than a regex approach because the data block
+   * ends with `},` (not `};`), and plugin sources contain deeply nested objects that would
+   * cause a lazy regex to stop at the wrong closing brace.
+   *
+   * Known limitations (acceptable for current jsPsych plugin sources):
+   * - Matches the first `data:` property in the file; a plugin with a `data:` field inside its
+   *   `parameters` block before the top-level `info.data` block would extract the wrong object.
+   * - Brace counting treats every `{`/`}` as structural; braces inside string literals or JSDoc
+   *   comments (e.g. `/** e.g. {foo: 1} *\/`) would throw off the counter.
+   *
+   * @private
+   * @param {string} script - Full plugin source text.
+   * @returns {string | null} Content between the outer braces of the data block, or null if not found.
+   */
+  extractDataBlock(script) {
+    const dataStart = script.search(/\bdata:\s*\{/);
+    if (dataStart === -1) return null;
+    const braceStart = script.indexOf("{", dataStart);
+    if (braceStart === -1) return null;
+    const braceEnd = this.findMatchingBrace(script, braceStart);
+    if (braceEnd === -1) return null;
+    return script.substring(braceStart + 1, braceEnd);
+  }
+  /**
+   * Parses JSDoc comments and variable blocks from the data section of a jsPsych plugin source.
+   *
+   * @private
+   * @param {string} script - The script text content of the fetching.
+   * @returns {{}}
+   */
+  parseJavadocString(script) {
+    const dataBlock = this.extractDataBlock(script);
+    if (!dataBlock) return {};
+    return this.extractJsdocFields(dataBlock);
+  }
+  /**
+   * Extracts JSDoc-annotated fields from a data block string. Uses brace counting to find
+   * each variable's true closing brace, then recursively processes any `nested:` sub-object
+   * so that nested parameter descriptions are also captured.
+   *
+   * @private
+   * @param {string} block - Content of a data or nested block (without outer braces).
+   * @returns {Record}
+   */
+  extractJsdocFields(block) {
+    const result = {};
+    const varStartRegex = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(\w+):\s*\{/g;
+    const propRegex = /(\w+):\s*([^,\s{}]+)/g;
+    let match;
+    while ((match = varStartRegex.exec(block)) !== null) {
+      const description = match[1].replace(/^[ \t]*\*[ \t]?/gm, "").trim().replace(/\s+/g, " ");
+      const varName = match[2];
+      const braceStart = match.index + match[0].length - 1;
+      const braceEnd = this.findMatchingBrace(block, braceStart);
+      if (braceEnd === -1) continue;
+      varStartRegex.lastIndex = braceEnd + 1;
+      const varContent = block.substring(braceStart + 1, braceEnd);
+      const propsObj = {};
+      let propMatch;
+      propRegex.lastIndex = 0;
+      while ((propMatch = propRegex.exec(varContent)) !== null) {
+        propsObj[propMatch[1]] = propMatch[2];
+      }
+      result[varName] = { description, ...propsObj };
+      const nestedSearch = /\bnested:\s*\{/.exec(varContent);
+      if (nestedSearch) {
+        const nestedBraceStart = varContent.indexOf("{", nestedSearch.index);
+        const nestedBraceEnd = this.findMatchingBrace(varContent, nestedBraceStart);
+        if (nestedBraceEnd !== -1) {
+          Object.assign(result, this.extractJsdocFields(varContent.substring(nestedBraceStart + 1, nestedBraceEnd)));
+        }
+      }
+    }
+    return result;
+  }
+  /**
+   * Returns the index of the `}` that closes the `{` at `startIndex`, using brace counting.
+   * Returns -1 if the source is unbalanced (no matching closing brace found).
+   *
+   * @private
+   * @param {string} str - String to search.
+   * @param {number} startIndex - Index of the opening `{`.
+   * @returns {number}
+   */
+  findMatchingBrace(str, startIndex) {
+    let depth = 0;
+    for (let i = startIndex; i < str.length; i++) {
+      if (str[i] === "{") depth++;
+      else if (str[i] === "}" && --depth === 0) return i;
+    }
+    return -1;
+  }
+};
+
+// src/utils.ts
+import { parse } from "csv-parse";
+var PSYCHDS_IGNORE_FILENAME = ".psychds-ignore";
+var PSYCHDS_IGNORE_CONTENT = "**/raw/\n.psychds-ignore\n";
+function saveTextToFile(textstr, filename) {
+  const blobToSave = new Blob([textstr], {
+    type: "text/plain"
+  });
+  let blobURL = "";
+  if (typeof window.webkitURL !== "undefined") {
+    blobURL = window.webkitURL.createObjectURL(blobToSave);
+  } else {
+    blobURL = window.URL.createObjectURL(blobToSave);
+  }
+  const link = document.createElement("a");
+  link.id = "jspsych-download-as-text-link";
+  link.style.display = "none";
+  link.download = filename;
+  link.href = blobURL;
+  link.click();
+}
+function tryParseJSON(value) {
+  try {
+    return JSON.parse(value);
+  } catch {
+    return null;
+  }
+}
+function unwrapTrials(data) {
+  const parsed = typeof data === "string" ? JSON.parse(data) : data;
+  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
+    const keys = Object.keys(parsed);
+    if (keys.length === 1 && keys[0] === "trials" && Array.isArray(parsed.trials)) {
+      return parsed.trials;
+    }
+  }
+  return parsed;
+}
+function parseJsonData(content, options = {}, stats) {
+  if (content.charCodeAt(0) === 65279) content = content.slice(1);
+  const whole = tryParseJSON(content);
+  if (whole !== null) return unwrapTrials(whole);
+  const lines = content.split(/\r?\n/);
+  const out = [];
+  let parsedAny = false;
+  let recordIndex = 0;
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim();
+    if (!line) continue;
+    let value;
+    try {
+      value = JSON.parse(line);
+    } catch {
+      throw new Error(
+        `Could not parse data as JSON or JSON-Lines: line ${i + 1} is not valid JSON.`
+      );
+    }
+    parsedAny = true;
+    const observations = Array.isArray(value) ? value : [value];
+    if (options.tagSourceRecordId) {
+      for (const obs of observations) {
+        if (obs !== null && typeof obs === "object" && !Array.isArray(obs) && !("source_record_id" in obs) && !("participant_id" in obs)) {
+          obs.source_record_id = recordIndex;
+          if (stats) stats.synthesizedSourceRecordId = true;
+        }
+      }
+    }
+    out.push(...observations);
+    recordIndex++;
+  }
+  if (!parsedAny) {
+    throw new Error("Could not parse data: input is empty or not valid JSON/JSON-Lines.");
+  }
+  return out;
+}
+var SYSTEM_COLUMNS = /* @__PURE__ */ new Set([
+  "trial_type",
+  "trial_index",
+  "time_elapsed",
+  "extension_type",
+  "extension_version"
+]);
+function analyzeJoinKeys(parsedData, keys) {
+  if (parsedData.length === 0) {
+    return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+  }
+  const compositeKeys = parsedData.map(
+    (row) => keys.map((k) => String(row[k] ?? "")).join("\0")
+  );
+  const keyCount = /* @__PURE__ */ new Map();
+  for (const ck of compositeKeys) keyCount.set(ck, (keyCount.get(ck) ?? 0) + 1);
+  const duplicateCount = [...keyCount.values()].reduce((n, c) => n + (c > 1 ? c - 1 : 0), 0);
+  const isUnique = duplicateCount === 0;
+  const duplicateValues = [];
+  for (let i = 0; i < parsedData.length && duplicateValues.length < 5; i++) {
+    if ((keyCount.get(compositeKeys[i]) ?? 0) > 1) {
+      const vals = keys.reduce((acc, k) => {
+        acc[k] = parsedData[i][k];
+        return acc;
+      }, {});
+      if (!duplicateValues.some((v) => JSON.stringify(v) === JSON.stringify(vals))) {
+        duplicateValues.push(vals);
+      }
+    }
+  }
+  if (isUnique) {
+    return { isUnique: true, duplicateCount: 0, duplicateValues: [], candidates: [], suggestedAdditionalKeys: null };
+  }
+  const keySet = new Set(keys);
+  const allColumns = /* @__PURE__ */ new Set();
+  for (const row of parsedData) for (const col of Object.keys(row)) allColumns.add(col);
+  const candidateColumns = [...allColumns].filter(
+    (col) => !isUnnamedHeader(col) && !keySet.has(col) && !SYSTEM_COLUMNS.has(col)
+  );
+  const candidates = candidateColumns.map((col) => {
+    const extended = parsedData.map(
+      (row) => [...keys, col].map((k) => String(row[k] ?? "")).join("\0")
+    );
+    return { column: col, makesUnique: new Set(extended).size === parsedData.length };
+  });
+  if (candidates.some((c) => c.makesUnique)) {
+    return { isUnique, duplicateCount, duplicateValues, candidates, suggestedAdditionalKeys: [] };
+  }
+  const workingKeys = [...keys];
+  const available = [...candidateColumns];
+  while (available.length > 0) {
+    const current = parsedData.map(
+      (row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0")
+    );
+    if (new Set(current).size === parsedData.length) break;
+    let bestCol = null;
+    let bestCount = new Set(current).size;
+    for (const col of available) {
+      const test = parsedData.map(
+        (row) => [...workingKeys, col].map((k) => String(row[k] ?? "")).join("\0")
+      );
+      const count = new Set(test).size;
+      if (count > bestCount) {
+        bestCount = count;
+        bestCol = col;
+      }
+    }
+    if (bestCol === null) break;
+    workingKeys.push(bestCol);
+    available.splice(available.indexOf(bestCol), 1);
+  }
+  const added = workingKeys.slice(keys.length);
+  const greedyIsUnique = new Set(
+    parsedData.map((row) => workingKeys.map((k) => String(row[k] ?? "")).join("\0"))
+  ).size === parsedData.length;
+  return {
+    isUnique,
+    duplicateCount,
+    duplicateValues,
+    candidates,
+    suggestedAdditionalKeys: added.length > 0 && greedyIsUnique ? added : null
+  };
+}
+var PSYCH_DS_FILENAME_RE = /^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\.(csv|tsv)$/;
+function isValidPsychDSDataFilename(name) {
+  return PSYCH_DS_FILENAME_RE.test(name);
+}
+function toPsychDSValue(name, fallback = "value") {
+  const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
+  if (parts.length === 0) return fallback;
+  return parts[0] + parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
+}
+function deriveFallbackBase(stem) {
+  return `subject-${toPsychDSValue(stem, "file")}`;
+}
+function deriveArrayFilename(parentBase, columnName) {
+  return `${parentBase}_measure-${toPsychDSValue(columnName, "col")}_data.csv`;
+}
+function objectsToCSV(rows, priorityCols = ["trial_index", "element_index"]) {
+  if (rows.length === 0) return "";
+  const allKeys = /* @__PURE__ */ new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row)) allKeys.add(key);
+  }
+  const otherCols = [...allKeys].filter((k) => !priorityCols.includes(k));
+  const headers = [...priorityCols.filter((c) => allKeys.has(c)), ...otherCols];
+  const escape = (val) => {
+    if (val === null || val === void 0) return "";
+    const str = typeof val === "object" ? JSON.stringify(val) : String(val);
+    return str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r") ? `"${str.replace(/"/g, '""')}"` : str;
+  };
+  const lines = [headers.join(",")];
+  for (const row of rows) {
+    lines.push(headers.map((h) => escape(row[h])).join(","));
+  }
+  return lines.join("\r\n");
+}
+function disambiguateArrayFilename(base, used) {
+  if (!used.has(base)) return base;
+  const suffix = "_data.csv";
+  const root = base.endsWith(suffix) ? base.slice(0, -suffix.length) : base.replace(/\.csv$/i, "");
+  let n = 2;
+  let candidate = `${root}${n}${suffix}`;
+  while (used.has(candidate)) {
+    n += 1;
+    candidate = `${root}${n}${suffix}`;
+  }
+  return candidate;
+}
+var isUnnamedHeader = (key) => key.trim() === "";
+function hasUnnamedColumns(rows) {
+  return rows.some((row) => Object.keys(row).some(isUnnamedHeader));
+}
+function stripUnnamedColumns(rows) {
+  const unnamed = /* @__PURE__ */ new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row)) {
+      if (isUnnamedHeader(key)) unnamed.add(key);
+    }
+  }
+  if (unnamed.size > 0) {
+    for (const row of rows) {
+      for (const key of unnamed) delete row[key];
+    }
+  }
+  return { rows, dropped: [...unnamed] };
+}
+function buildPsychDSDataFiles(args) {
+  const {
+    base,
+    mainRows,
+    mainContent,
+    extractedArrays = /* @__PURE__ */ new Map(),
+    extractedObjects = /* @__PURE__ */ new Map(),
+    joinKeys = ["trial_index"],
+    usedArrayFilenames = /* @__PURE__ */ new Set()
+  } = args;
+  const out = [];
+  const reserve = (name) => {
+    if (!isValidPsychDSDataFilename(name)) {
+      throw new Error(`Refusing to write non-Psych-DS-compliant data filename "${name}".`);
+    }
+    usedArrayFilenames.add(name);
+    return name;
+  };
+  const mainName = reserve(disambiguateArrayFilename(`${base}_data.csv`, usedArrayFilenames));
+  const { rows: cleanedMainRows, dropped: droppedMain } = stripUnnamedColumns(mainRows);
+  out.push({
+    filename: mainName,
+    content: mainContent !== void 0 && droppedMain.length === 0 ? mainContent : objectsToCSV(cleanedMainRows, ["trial_index"]),
+    kind: "main"
+  });
+  const arrayPriority = [...joinKeys, "element_index"];
+  for (const [colName, rows] of extractedArrays) {
+    const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+    out.push({ filename: name, content: objectsToCSV(rows, arrayPriority), kind: "array" });
+  }
+  for (const [colName, rows] of extractedObjects) {
+    const name = reserve(disambiguateArrayFilename(deriveArrayFilename(base, colName), usedArrayFilenames));
+    out.push({ filename: name, content: objectsToCSV(rows, joinKeys), kind: "object" });
+  }
+  return out;
+}
+async function parseCSV(input) {
+  if (!parse) {
+    throw new Error("Parser module not loaded");
+  }
+  return new Promise((resolve, reject) => {
+    parse(input, {
+      columns: true,
+      // Treat the first row as headers
+      delimiter: ",",
+      // Specify the delimiter (e.g., comma)
+      bom: true
+      // Strip a leading UTF-8 BOM so the first header name isn't corrupted (e.g. "Participant_ID")
+    }, (err, records) => {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(records);
+      }
+    });
+  });
+}
+
+// src/VariablesMap.ts
+var VariablesMap = class _VariablesMap {
+  /**
+   *  Creates the VariablesMap by initialising an empty variable map. The jsPsych system
+   * variables (trial_type, trial_index, time_elapsed, extension_*) are NOT seeded here — they
+   * are registered lazily when their column is actually observed in the data (see
+   * {@link registerSystemVariable}). Seeding them unconditionally produced orphan
+   * variableMeasured entries (e.g. time_elapsed) for datasets that omit those columns, which
+   * fails Psych-DS validation (VARIABLE_MISSING_FROM_CSV_COLUMNS).
+   *
+   * @constructor
+   */
+  constructor() {
+    this.generateDefaultVariables();
+  }
+  /**
+   * The fixed jsPsych definition for a system column, or null if `name` is not a known system
+   * variable. Returns a fresh object on each call so callers never share/mutate one template.
+   */
+  static systemVariableTemplate(name) {
+    switch (name) {
+      case "trial_type":
+        return {
+          "@type": "PropertyValue",
+          name: "trial_type",
+          description: { default: "unknown", jsPsych: "The name of the plugin used to run the trial." },
+          value: "string"
+        };
+      case "trial_index":
+        return {
+          "@type": "PropertyValue",
+          name: "trial_index",
+          description: { default: "unknown", jsPsych: "The index of the current trial across the whole experiment." },
+          value: "number"
+        };
+      case "time_elapsed":
+        return {
+          "@type": "PropertyValue",
+          name: "time_elapsed",
+          description: {
+            default: "unknown",
+            jsPsych: "The number of milliseconds between the start of the experiment and when the trial ended."
+          },
+          value: "number"
+        };
+      case "extension_type":
+        return {
+          "@type": "PropertyValue",
+          name: "extension_type",
+          description: { default: "unknown", jsPsych: "The name(s) of the extension(s) used in the trial." },
+          value: "string"
+        };
+      case "extension_version":
+        return {
+          "@type": "PropertyValue",
+          name: "extension_version",
+          description: { default: "unknown", jsPsych: "The version(s) of the extension(s) used in the trial." },
+          value: "number"
+        };
+      default:
+        return null;
+    }
+  }
+  /**
+   * Lazily registers the default jsPsych definition for a system column the first time it is
+   * observed in the data. No-op (returns false) when `name` is not a known system variable or
+   * is already present; returns true when a new variable was registered. This is what keeps a
+   * system variable out of variableMeasured unless the data actually contains that column.
+   *
+   * @param {string} name - The column / system-variable name.
+   * @returns {boolean} - True if a variable was registered, false otherwise.
+   */
+  registerSystemVariable(name) {
+    if (this.containsVariable(name)) return false;
+    const template = _VariablesMap.systemVariableTemplate(name);
+    if (!template) return false;
+    this.setVariable(template);
+    return true;
+  }
+  /**
+   * Initialises the variable map. System variables are registered lazily (see the constructor
+   * and {@link registerSystemVariable}), so this just resets the map to empty.
+   */
+  generateDefaultVariables() {
+    this.variables = {};
+  }
+  /**
+   * Returns a list of the variables instead of an object according to the Psych-DS format.
+   *
+   * @returns {{}[]} - The list of variables represented as objects.
+   */
+  getList() {
+    var var_list = [];
+    for (const key of Object.keys(this.variables)) {
+      const variable = this.variables[key];
+      variable["description"] = this.collapseDescription(variable["description"]);
+      var_list.push(variable);
+    }
+    return var_list;
+  }
+  /**
+   * Collapses an internal { pluginType: description } map into a single schema.org-valid
+   * Text value. Descriptions are stored per-plugin and only ever hold multiple keys when the
+   * texts genuinely differ (identical texts are merged upstream in updateDescription). Psych-DS /
+   * schema.org require `description` to be Text, so an object value triggers an OBJECT_TYPE_MISSING
+   * validator warning — this folds everything down to a string.
+   *
+   * @private
+   * @param {*} description - The description value (a { pluginType: text } map, or already a string).
+   * @returns {string} - A single Text description.
+   */
+  collapseDescription(description) {
+    if (typeof description !== "object" || description === null) {
+      return description;
+    }
+    if (Object.keys(description).length === 0) {
+      console.error("Empty description");
+      return "unknown";
+    }
+    if (Object.keys(description).length > 1 && "default" in description) {
+      delete description["default"];
+    }
+    for (const descKey of Object.keys(description)) {
+      if (description[descKey] === "unknown" && Object.keys(description).length > 1) {
+        delete description[descKey];
+      }
+    }
+    return Object.values(description).join(" | ");
+  }
+  /**
+   * Allows user to set a variable and includes all the fields that are possible according to
+   * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable.
+   * Can also be used to overwrite existing variables if they have the same name.
+   *
+   * @param {VariableFields} variable - The fields of the variable that is being created.
+   */
+  setVariable(variable) {
+    if (!variable.name) {
+      console.warn("Name field is missing. Variable not added.", variable);
+      return;
+    }
+    this.variables[variable.name] = variable;
+    const unexpectedFields = Object.keys(variable).filter(
+      (key) => ![
+        "@type",
+        "name",
+        "description",
+        "value",
+        "identifier",
+        "minValue",
+        "maxValue",
+        "levels",
+        "levelsOrdered",
+        "na",
+        "naValue",
+        "alternateName",
+        "privacy"
+      ].includes(key)
+    );
+    if (unexpectedFields.length > 0) {
+      console.warn(
+        `Unexpected fields (${unexpectedFields.join(
+          ", "
+        )}) detected and included in the variable object.`
+      );
+    }
+  }
+  /**
+   * Allows you to get information for a single variable returning empty dict if it doesn't exist.
+   * Allows you to update fields but not recommended in favor of updateVariable.
+   *
+   * @param {string} name
+   * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist
+   */
+  getVariable(name) {
+    return this.variables[name] || {};
+  }
+  /**
+   * Checks if variable exists in VariablesMap.
+   *
+   * @param {string} name - Name of variable
+   * @returns {boolean} - True if exists, false if doesn't.
+   */
+  containsVariable(name) {
+    return name in this.variables;
+  }
+  /**
+   * Method that gets a list of the names of variables.
+   *
+   * @returns {string[]} - String list containing names of existing variables.
+   */
+  getVariableNames() {
+    var var_list = [];
+    for (const key of Object.keys(this.variables)) {
+      var_list.push(this.variables[key]["name"]);
+    }
+    return var_list;
+  }
+  /**
+   * Allows you to update a variable or add a value in the case of updating values. In other situations will
+   * replace the existing value with the new value. Has special cases and logic for levels and names making it
+   * easier to update variable values.
+   *
+   *
+   * @param {string} var_name - Name of variable to be updated.
+   * @param {string} field_name - Specific field to be updated.
+   * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType.
+   */
+  updateVariable(var_name, field_name, added_value) {
+    const updated_var = this.getVariable(var_name);
+    if (Object.keys(updated_var).length === 0) {
+      console.error(`Variable "${var_name}" does not exist.`);
+      return;
+    }
+    if (field_name === "levels") {
+      this.updateLevels(updated_var, added_value);
+    } else if (field_name === "minValue" || field_name === "maxValue") {
+      this.updateMinMax(updated_var, added_value, field_name);
+    } else if (field_name === "description") {
+      this.updateDescription(updated_var, added_value);
+    } else if (field_name === "name") {
+      this.updateName(updated_var, added_value);
+    } else {
+      updated_var[field_name] = added_value;
+    }
+  }
+  /**
+   * Logic that handles updates to levels field by creating new array if necessary, otherwise
+   * pushing the value if it doesn't already exist. Levels can only be added to with strings.
+   *
+   * @private
+   * @param {*} updated_var - The variable object to be updated.
+   * @param {*} added_value - The value being added to the levels field.
+   */
+  updateLevels(updated_var, added_value) {
+    if (typeof added_value === "object")
+      return;
+    const MAX_LENGTH = 50;
+    if (added_value.length > MAX_LENGTH) {
+      added_value = added_value.substring(0, MAX_LENGTH) + "...";
+    }
+    if (!Array.isArray(updated_var["levels"])) {
+      updated_var["levels"] = [];
+    }
+    if (!updated_var["levels"].includes(added_value)) {
+      updated_var["levels"].push(added_value);
+    }
+  }
+  /**
+   * Logic to update the min and max for the specific value.
+   *
+   * @private
+   * @param {*} updated_var - The variable object to be updated.
+   * @param {*} added_value - The value that is being checked against current min/max.
+   * @param {*} field_name - The name of field that is being checked (min or max).
+   */
+  updateMinMax(updated_var, added_value, field_name) {
+    if (!("minValue" in updated_var) || !("maxValue" in updated_var)) {
+      updated_var["maxValue"] = updated_var["minValue"] = added_value;
+      return;
+    }
+    if (field_name === "minValue" && updated_var["minValue"] > added_value) {
+      updated_var["minValue"] = added_value;
+    } else if (field_name === "maxValue" && updated_var["maxValue"] < added_value) {
+      updated_var["maxValue"] = added_value;
+    }
+  }
+  /**
+   * Logic for updating description field that checks to see value already exists. If it does,
+   * appends the pluginType to the current key and pushes that along with the value. Creates
+   * map if it does not exist.
+   *
+   * @private
+   * @param {*} updated_var - The variable to be updated.
+   * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field.
+   */
+  updateDescription(updated_var, added_value) {
+    const add_key = Object.keys(added_value)[0];
+    const add_value = Object.values(added_value)[0];
+    if (add_key === "undefined" || add_value === "undefined") {
+      console.error("New value is passed in bad format", added_value);
+      return;
+    }
+    var exists = false;
+    if (typeof updated_var["description"] !== "object") {
+      const existing = updated_var["description"];
+      updated_var["description"] = typeof existing === "string" && existing && existing !== "unknown" ? { default: existing } : {};
+    }
+    Object.entries(updated_var["description"]).forEach(([key, value]) => {
+      if (value === add_value) {
+        if (!key.includes(add_key)) {
+          delete updated_var["description"][key];
+          updated_var["description"][key + ", " + add_key] = add_value;
+        }
+        exists = true;
+      }
+    });
+    if (!exists) Object.assign(updated_var["description"], added_value);
+  }
+  /**
+   * Logic for updating name. Needs to retain all the old values while creating a new reference in the map
+   * while keeping the same perspe
+   *
+   * @private
+   * @param {*} updated_var
+   * @param {*} added_value
+   */
+  updateName(updated_var, added_value) {
+    const old_name = updated_var["name"];
+    updated_var["name"] = added_value;
+    delete this.variables[old_name];
+    this.setVariable(updated_var);
+  }
+  /**
+   * Allows you to delete a variable by key/name. Returns console error if not found.
+   *
+   * @param {string} var_name - Name of variable to be deleted.
+   */
+  deleteVariable(var_name) {
+    if (var_name in this.variables) {
+      delete this.variables[var_name];
+    } else {
+      console.error(`Variable "${var_name}" does not exist.`);
+    }
+  }
+};
+
+// src/index.ts
+var JsPsychMetadata = class {
+  /**
+   * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context
+   *  allowing it to access the screen printing information.
+   *
+   * @constructor
+   * @param {JsPsych} JsPsych
+   */
+  constructor(verbose) {
+    /**
+     * Initializes a set that contains the variable fields that are to be ignored, so can help with later 
+     * logic when generating data.
+     *
+     * @private
+     * @type {*}
+     */
+    this.ignored_variables = new Set(SYSTEM_COLUMNS);
+    /**
+     * Verbose mode that is used in by the tools that call this to print fetching messages and 
+     * reading messages.
+     *
+     * @private
+     * @type {boolean}
+     */
+    this.verbose = false;
+    this.extractedArrays = /* @__PURE__ */ new Map();
+    // Plain (non-array) object columns expanded by expandObjectFields. One row per trial,
+    // keyed by the same arrayJoinKeys as extractedArrays, with a column for every dotted
+    // descendant variable (leaf scalars, intermediate object nodes, and nested-array parents).
+    // The CLI writes these as separate Psych-DS CSVs so those dotted names map to real columns.
+    this.extractedObjects = /* @__PURE__ */ new Map();
+    this.arrayJoinKeys = ["trial_index"];
+    this.mixedColumns = /* @__PURE__ */ new Set();
+    this.metadata = {};
+    this.setMetadataField("name", "title");
+    this.setMetadataField("schemaVersion", "Psych-DS 0.4.0");
+    this.setMetadataField("@context", "https://schema.org");
+    this.setMetadataField("@type", "Dataset");
+    this.setMetadataField("description", "Dataset generated using JsPsych");
+    this.authors = new AuthorsMap();
+    this.variables = new VariablesMap();
+    this.pluginCache = new PluginCache();
+    this.verbose = verbose;
+  }
+  /**
+   * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields.
+   *
+   * @param {string} key - Metadata field name
+   * @param {*} value - Data associated with the field
+   */
+  setMetadataField(key, value) {
+    this.metadata[key] = value;
+  }
+  /**
+   * Simple get that accesses the data associated with a field.
+   *
+   * @param {string} key - Field name
+   * @returns {*} - Data associated with the field
+   */
+  getMetadataField(key) {
+    return this.metadata[key];
+  }
+  /**
+   * Checks if the metadata field exists in the metadata.
+   *
+   * @param {string} key - Key of metadata being checked.
+   * @returns {*} - Boolean
+   */
+  containsMetadataField(key) {
+    return key in this.metadata;
+  }
+  /**
+   * Deletes a metadata from the metadata if it exists. 
+   *
+   * @param {string} key - Name of field to be deleted
+   */
+  deleteMetadataField(key) {
+    if (key in this.metadata) {
+      delete this.metadata[key];
+    } else {
+      console.error(`Metadata "${key}" does not exist.`);
+    }
+  }
+  /**
+   * Returns the final Metadata in a single javascript object. Bundles together the author and variables
+   * together in a list rather than object compliant with Psych-DS standards. Seems that javascript get
+   * are implictly called.
+   *
+   * @returns {{}} - Final Metadata object
+   */
+  getMetadata() {
+    const res = this.metadata;
+    res["author"] = this.authors.getList();
+    res["variableMeasured"] = this.variables.getList();
+    return res;
+  }
+  getUserMetadataFields() {
+    const res = {};
+    const ignored_fields = /* @__PURE__ */ new Set(["schemaVersion", "@type", "@context", "author", "variableMeasured"]);
+    for (const key in this.metadata) {
+      if (!ignored_fields.has(key)) {
+        res[key] = this.metadata[key];
+      }
+    }
+    return res;
+  }
+  /**
+   * Returns the variable fields while excluding the authors and variables.`
+   *
+   * @returns {{}} - Final Metadata object
+   */
+  getMetadataFields() {
+    const res = this.metadata;
+    delete res["author"];
+    delete res["variableMeasured"];
+    return res;
+  }
+  /**
+   * Method that creates an author. This method can also be used to overwrite existing authors
+   * with the same name in order to update fields.
+   *
+   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
+   */
+  setAuthor(fields) {
+    this.authors.setAuthor(fields);
+  }
+  /**
+   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
+   *
+   * @param {string} name - Name of author to be used as key.
+   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
+   */
+  getAuthor(name) {
+    return this.authors.getAuthor(name);
+  }
+  /**
+   * Returns a list of the authors defined in the metadata.
+   *
+   * @returns {(string | AuthorFields)[]} - Authors
+   */
+  getAuthorList() {
+    return this.authors.getList();
+  }
+  /**
+   * Deletes an author from the authorsField.
+   *
+   * @param {string} name - Name of author to be deleted.
+   */
+  deleteAuthor(name) {
+    this.authors.deleteAuthor(name);
+  }
+  /**
+   * Method that creates a variable. This method can also be used to overwrite variables with the same name
+   * as a way to update fields.
+   *
+   * @param {{
+   *     @type?: string;
+   *     name: string; // required
+   *     description?: string | {};
+   *     value?: string; // string, boolean, or number
+   *     identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
+   *     minValue?: number;
+   *     maxValue?: number;
+   *     levels?: string[] | []; // technically property values in the other one but not sure how to format it
+   *     levelsOrdered?: boolean;
+   *     na?: boolean;
+   *     naValue?: string;
+   *     alternateName?: string;
+   *     privacy?: string;
+   *   }} fields - Fields associated with the current Psych-DS standard.
+   */
+  setVariable(variable) {
+    this.variables.setVariable(variable);
+  }
+  /**
+   * Allows you to access a variable's information by using the name of the variable. Can
+   * be used to update fields within a variable, but suggest using updateVariable() to prevent errors.
+   *
+   * @param {string} name - Name of variable to be accessed
+   * @returns {{}} - Returns object of fields
+   */
+  getVariable(name) {
+    return this.variables.getVariable(name);
+  }
+  /**
+   * Returns a list of the variables defined in the metadata.
+   *
+   * @returns {{}[]} - Authors
+   */
+  getVariableList() {
+    return this.variables.getList();
+  }
+  /**
+   * Allows you to check if the name of the variable exists in variablesMap.
+   *
+   * @param {string} name - Name of variable
+   * @returns {boolean} - Does variable exist in variables
+   */
+  containsVariable(name) {
+    return this.variables.containsVariable(name);
+  }
+  /**
+   * Allows you to update a variable or add a value in the case of updating values. In other situations will
+   * replace the existing value with the new value.
+   *
+   * @param {string} var_name - Name of variable to be updated.
+   * @param {string} field_name - Name of field to be updated.
+   * @param {(string | boolean | number | {})} added_value - Value to be used in the update.
+   */
+  updateVariable(var_name, field_name, added_value) {
+    this.variables.updateVariable(var_name, field_name, added_value);
+  }
+  /**
+   * Allows you to delete a variable by key/name.
+   *
+   * @param {string} var_name - Name of variable to be deleted.
+   */
+  deleteVariable(var_name) {
+    this.variables.deleteVariable(var_name);
+  }
+  /**
+   * Gets a list of all the variable names.
+   *
+   * @returns {string[]} - List of variable string names.
+   */
+  getVariableNames() {
+    return this.variables.getVariableNames();
+  }
+  /**
+   * Returns accumulated array-column data keyed by column name.
+   * Each entry is a list of rows with join key columns, element_index, and the element's own fields.
+   * Used by the CLI to write Psych-DS compliant separate CSV files.
+   */
+  getExtractedArrays() {
+    return this.extractedArrays;
+  }
+  /**
+   * Returns accumulated plain-object-column data keyed by the top-level column name.
+   * Each entry is one row per trial: the join key columns plus a column for every dotted
+   * descendant variable expanded from that object (matching the names in variableMeasured).
+   * Used by the CLI to write a separate Psych-DS CSV per object column, so those dotted
+   * sub-variables resolve to real columns. No element_index (one row per trial, not per element).
+   */
+  getExtractedObjects() {
+    return this.extractedObjects;
+  }
+  /**
+   * Returns the join key columns used in the most recent generate() call.
+   * The CLI uses this to order columns correctly in extracted array CSVs.
+   */
+  getArrayJoinKeys() {
+    return [...this.arrayJoinKeys];
+  }
+  warnJoinKeyUniqueness(analysis) {
+    const keyStr = this.arrayJoinKeys.join(", ");
+    const exampleStr = analysis.duplicateValues.slice(0, 3).map((v) => Object.entries(v).map(([k, val]) => `${k}=${val}`).join(", ")).join("; ");
+    let msg = `[jspsych-metadata] Join key (${keyStr}) is not unique in this dataset
+  (${analysis.duplicateCount} duplicate rows; e.g. ${exampleStr})
+`;
+    if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length === 0) {
+      const sufficient = analysis.candidates.filter((c) => c.makesUnique).map((c) => c.column);
+      const example = JSON.stringify([sufficient[0], ...this.arrayJoinKeys]);
+      msg += `  Sufficient fix: add one of these columns to arrayJoinKeys:
+    ${sufficient.join(", ")}
+  Pass { arrayJoinKeys: ${example} } as the options argument to generate().`;
+    } else if (analysis.suggestedAdditionalKeys !== null && analysis.suggestedAdditionalKeys.length > 0) {
+      const combined = JSON.stringify([...analysis.suggestedAdditionalKeys, ...this.arrayJoinKeys]);
+      msg += `  No single column makes rows unique. Suggested combination:
+    ${analysis.suggestedAdditionalKeys.join(" + ")}
+  Pass { arrayJoinKeys: ${combined} } as the options argument to generate().`;
+    } else {
+      msg += `  No combination of available columns was found to make rows unique.
+  Your data may contain genuinely duplicate rows.
+  Extracted array CSVs will have non-unique join keys.`;
+    }
+    console.warn(msg);
+  }
+  /**
+   * Method that allows you to display metadata at the end of an experiment.
+   *
+   * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling.
+   */
+  displayMetadata(display_element) {
+    const elementId = "jspsych-metadata-display";
+    const metadata_string = JSON.stringify(this.getMetadata(), null, 2);
+    display_element.innerHTML += `

Metadata

`;
+    document.getElementById(elementId).textContent += metadata_string;
+  }
+  /**
+   * Method that begins a download for the dataset_description.json at the end of experiment.
+   * Allows you to download the metadat.
+   */
+  localSave() {
+    let data_string = JSON.stringify(this.getMetadata());
+    saveTextToFile(data_string, "dataset_description.json");
+  }
+  /**
+   * This method loads the metadata into the metadata object. This takes in the"dataset_description.json" string content 
+   * and first parses it as an object. This then loads in all the fields, authors and variables into the metadata object by calling all the 
+   * relevant methods that overwrites the default data.
+   *
+   * @param {string} stringMetadata - String version of the metadata to be loaded from "dataset_description.json".
+   */
+  loadMetadata(stringMetadata) {
+    const meta = JSON.parse(stringMetadata);
+    for (const field_key in meta) {
+      if (field_key === "variableMeasured") {
+        for (const variable of meta[field_key]) {
+          this.setVariable(variable);
+        }
+      } else if (field_key === "author") {
+        for (const author of meta[field_key]) {
+          this.setAuthor(author);
+        }
+      } else {
+        this.setMetadataField(field_key, meta[field_key]);
+      }
+    }
+  }
+  /**
+   * Generates observations based on the input data and processes optional metadata. This is the
+   * outer wrapper function that should called and handles the logic of reading individual observations.
+   *
+   * This method accepts data as a JSON string, a CSV string, or an already-parsed array of
+   * observation objects. A string is parsed according to `ext`; an array is consumed as-is.
+   * Each observation is processed asynchronously via `generateObservation`. Optionally, metadata
+   * options can be provided as an object, and each key-value pair is processed by `processMetadata`.
+   *
+   * NOTE: when `data` is a pre-parsed array it is consumed in place and MUTATED — unnamed
+   * (blank-header) columns are deleted from the row objects. Callers that need the rows to stay
+   * pristine must pass a copy. This lets a caller parse a file once and share the rows with
+   * generate() instead of having generate() re-parse the same content.
+   *
+   * @async
+   * @param {Array|String} data - Observations to generate from: a pre-parsed array (consumed as-is and mutated in place), a JSON string, or a CSV string.
+   * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
+   * @param {'json'|'csv'} [ext='json'] - Format of a string `data`; ignored when `data` is already an array.
+   * @param {Object} [options={}] - arrayJoinKeys / suppressJoinKeyWarning, plus synthesizedSourceRecordId for pre-parsed callers that tagged a synthetic source_record_id themselves.
+   */
+  async generate(data, metadata = {}, ext = "json", options = {}) {
+    this.extractedArrays = /* @__PURE__ */ new Map();
+    this.extractedObjects = /* @__PURE__ */ new Map();
+    this.arrayJoinKeys = options.arrayJoinKeys ?? ["trial_index"];
+    var parsed_data;
+    let synthesizedSourceRecordId = options.synthesizedSourceRecordId ?? false;
+    if (Array.isArray(data)) {
+      parsed_data = data;
+    } else if (ext === "csv") {
+      parsed_data = await parseCSV(data);
+    } else if (ext === "json") {
+      const parseStats = {};
+      parsed_data = parseJsonData(data, { tagSourceRecordId: true }, parseStats);
+      synthesizedSourceRecordId = parseStats.synthesizedSourceRecordId === true;
+    }
+    if (!Array.isArray(parsed_data)) {
+      throw new Error("Parsed data is not in correct format: Expected an array of observations");
+    }
+    const { dropped } = stripUnnamedColumns(parsed_data);
+    if (dropped.length > 0) {
+      console.warn(
+        `Dropped ${dropped.length} unnamed column${dropped.length > 1 ? "s" : ""} from the data \u2014 Psych-DS requires every column to have a name (usually a row-index column added by R's write.csv). Excluded from variableMeasured.`
+      );
+    }
+    const rows = parsed_data;
+    const hasColumn = (col) => ext === "json" && rows.some((row) => row && typeof row === "object" && col in row);
+    const idColumn = hasColumn("source_record_id") ? "source_record_id" : hasColumn("participant_id") ? "participant_id" : void 0;
+    if (idColumn && !this.arrayJoinKeys.includes(idColumn)) {
+      this.arrayJoinKeys = [idColumn, ...this.arrayJoinKeys];
+    }
+    const analysis = analyzeJoinKeys(parsed_data, this.arrayJoinKeys);
+    if (!analysis.isUnique && !options.suppressJoinKeyWarning) this.warnJoinKeyUniqueness(analysis);
+    for (const observation of parsed_data) {
+      await this.generateObservation(observation);
+    }
+    if (synthesizedSourceRecordId && this.containsVariable("source_record_id")) {
+      const existing = this.getVariable("source_record_id");
+      this.setVariable({
+        ...existing,
+        description: { default: "Synthetic source-record identifier (0-based), assigned one per source record (one JSON-Lines line, which is usually but not always one participant) because the raw data carried no identifier column. NOT a real subject ID from the experiment \u2014 it only orders/links records as they appeared in the source file, and serves as a join key connecting each trial to its extracted array/object rows." }
+      });
+    }
+    await this.updateMetadata(metadata);
+  }
+  /**
+   * This function iterates through the entire row of data stepping through one column at a time.
+   * It is designed to only be accessed through calling generate on an entire data file. 
+   * Searching for plugin, plugin version, extension, extension it then calls the 
+   * helper methods that process the individual row of data. There is limited error chcking and 
+   * type conversion from csv due to the way that csv data is represented as strings.
+   * This method also handles extensions, declaring them if necessary and iterate through each.
+   * This method also skips generating descriptions the variables that should the same for 
+   * all variables and instead updates their fields. 
+   *
+   * @private
+   * @async
+   * @param {*} observation Dictionary that represent one row of data
+   * @returns {*}
+   */
+  async generateObservation(observation) {
+    const version = observation["plugin_version"] ? observation["plugin_version"] : null;
+    const pluginType = observation["trial_type"];
+    const extensionType = observation["extension_type"];
+    const extensionVersion = observation["extension_version"];
+    const joinValues = this.arrayJoinKeys.reduce((acc, k) => {
+      acc[k] = observation[k];
+      return acc;
+    }, {});
+    for (const variable in observation) {
+      var value = observation[variable];
+      var type = typeof value;
+      if (!this.containsVariable(variable)) {
+        if (this.ignored_variables.has(variable)) {
+          this.variables.registerSystemVariable(variable);
+        } else {
+          this.setVariable({
+            "@type": "PropertyValue",
+            name: variable,
+            description: { default: "unknown" },
+            value: "unknown"
+          });
+        }
+      }
+      if (value === null || value === void 0 || value === "" || value === "null") {
+        continue;
+      }
+      if (type === "string") {
+        const asNumber = Number(value);
+        if (value.trim() !== "" && Number.isFinite(asNumber)) {
+          type = "number";
+          value = asNumber;
+        } else if (value.startsWith("{") || value.startsWith("[")) {
+          const parsed = tryParseJSON(value);
+          if (parsed !== null) {
+            value = parsed;
+            type = Array.isArray(parsed) ? "array" : "object";
+          }
+        }
+      }
+      if (this.ignored_variables.has(variable)) {
+        this.updateFields(variable, value, type);
+      } else {
+        if (type === "object" && value !== null && !Array.isArray(value)) {
+          const objectRow = { ...joinValues };
+          await this.expandObjectFields(variable, value, pluginType, version, joinValues, objectRow);
+          const existingObjects = this.extractedObjects.get(variable) ?? [];
+          existingObjects.push(objectRow);
+          this.extractedObjects.set(variable, existingObjects);
+        } else if (type === "array" || type === "object" && Array.isArray(value)) {
+          await this.generateMetadata(variable, value, pluginType, version);
+          const existingVar = this.containsVariable(variable) ? this.getVariable(variable) : null;
+          const existingType = existingVar?.value;
+          if (existingType !== "string" && existingType !== "number" && existingType !== "boolean") {
+            this.updateVariable(variable, "value", "array");
+          }
+          await this.accumulateArrayColumn(variable, value, joinValues, pluginType, version);
+        } else {
+          await this.generateMetadata(variable, value, pluginType, version);
+        }
+        if (extensionType) {
+          await Promise.all(
+            extensionType.map(async (ext, index) => {
+              if (ext && extensionVersion[index])
+                await this.generateMetadata(variable, value, ext, extensionVersion[index], true);
+            })
+          );
+        }
+      }
+    }
+  }
+  /**
+   * Iterates through one single datapoint which can be thought of as one row-column pair. 
+   * This method keeps in mind the versionType or pluginType and uses this to generate the 
+   * metadata. 
+   *
+   * @private
+   * @async
+   * @param {*} variable - The column name
+   * @param {*} value - The value at the row-column mapping that is being used to update fields
+   * @param {*} pluginType - The type of the plugin that is used for the fetching (can also be extension if extension?=true)
+   * @param {*} version - The version of the plugin that is not necessary but is used post v8 to ensure accurate fetching
+   * @param {?*} [extension] - This boolean determines whether is a extension to change fetching
+   * @returns {*}
+   */
+  async generateMetadata(variable, value, pluginType, version, extension) {
+    const type = typeof value;
+    if (!this.containsVariable(variable)) {
+      const new_var = {
+        "@type": "PropertyValue",
+        name: variable,
+        description: { default: "unknown" },
+        value: type
+      };
+      this.setVariable(new_var);
+    } else {
+      const existing = this.getVariable(variable);
+      if (existing.value === "unknown") this.updateVariable(variable, "value", type);
+    }
+    if (pluginType) {
+      const pluginInfo = await this.getPluginInfo(pluginType, variable, version, extension);
+      const description = pluginInfo["description"];
+      const new_description = description ? { [pluginType]: description } : { [pluginType]: "unknown" };
+      this.updateVariable(variable, "description", new_description);
+    }
+    this.updateFields(variable, value, type);
+  }
+  /**
+   * This calls an update to the individual fields of the metadata, updating levels and 
+   * minValue and maxValue depeneding on the variable type.
+   *
+   * @private
+   * @param {*} variable - The column of the data and name of variable
+   * @param {*} value - The datapoint 
+   * @param {*} type - The type of the datapoint
+   */
+  updateFields(variable, value, type) {
+    if (type === "boolean") return;
+    const existing = this.getVariable(variable);
+    if (type === "number") {
+      if (Array.isArray(existing.levels)) {
+        if (!this.mixedColumns.has(variable)) {
+          this.mixedColumns.add(variable);
+          console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+        }
+        this.updateVariable(variable, "levels", String(value));
+        return;
+      }
+      this.updateVariable(variable, "minValue", value);
+      this.updateVariable(variable, "maxValue", value);
+      return;
+    }
+    if (type !== "object") {
+      if ("minValue" in existing || "maxValue" in existing) {
+        if (!this.mixedColumns.has(variable)) {
+          this.mixedColumns.add(variable);
+          console.warn(`Variable "${variable}" has mixed numeric and non-numeric values; treating as categorical.`);
+        }
+        if ("minValue" in existing) this.updateVariable(variable, "levels", String(existing.minValue));
+        if ("maxValue" in existing && existing.maxValue !== existing.minValue) {
+          this.updateVariable(variable, "levels", String(existing.maxValue));
+        }
+        delete existing.minValue;
+        delete existing.maxValue;
+        this.updateVariable(variable, "value", "string");
+      }
+      if (existing.value === "boolean" && (value === "true" || value === "false")) {
+        return;
+      }
+      this.updateVariable(variable, "levels", value);
+    }
+  }
+  /**
+   * Iterates through the entire metadata options object by calling processMetadata() to act upon each of the 
+   * individual fields at one time. 
+   *
+   * @async
+   * @param {*} metadata - Metadata options that contains all the metadata according to Psych-DS formatting. 
+   */
+  async updateMetadata(metadata) {
+    for (const key in metadata) {
+      await this.processMetadata(metadata, key);
+    }
+  }
+  /**
+   * This is the method that processes each individual element of the metadata options to be updated. This can be called through generate or outside of it, 
+   * and this processes each element. 
+   *
+   * @private
+   * @param {*} metadata - An object that contains all of the metadata. This is used to access the value. 
+   * @param {*} key - String key that denotes what key-value mapping is being iterated upon. 
+   */
+  processMetadata(metadata, key) {
+    const value = metadata[key];
+    if (key === "variables") {
+      if (typeof value !== "object" || value === null) {
+        console.warn("Variable object is either null or incorrect type");
+        return;
+      }
+      for (let variable_key in value) {
+        if (!this.containsVariable(variable_key)) {
+          console.warn("Metadata does not contain variable:", variable_key);
+          continue;
+        }
+        const variable_parameters = value[variable_key];
+        if (typeof variable_parameters !== "object" || variable_parameters === null) {
+          console.warn(
+            "Parameters of variable:",
+            variable_key,
+            "is either null or incorrect type. The value",
+            variable_parameters,
+            "is either null or not an object."
+          );
+          continue;
+        }
+        for (const parameter in variable_parameters) {
+          const parameter_value = variable_parameters[parameter];
+          this.updateVariable(variable_key, parameter, parameter_value);
+          if (parameter === "value" && parameter_value === "boolean") {
+            this.applyBooleanOverride(variable_key);
+          }
+          if (parameter === "name") variable_key = parameter_value;
+        }
+      }
+    } else if (key === "author") {
+      if (typeof value !== "object" || value === null) {
+        console.warn("Author object is not correct type");
+        return;
+      }
+      for (const author_key in value) {
+        const author = value[author_key];
+        if (typeof author !== "string" && !("name" in author)) author["name"] = author_key;
+        this.setAuthor(author);
+      }
+    } else this.setMetadataField(key, value);
+  }
+  /**
+   * Applies a user-chosen `value:"boolean"` override to an already-populated variable.
+   * Warns when the values detected from the data don't map cleanly to boolean logic
+   * (anything other than true/false/0/1, case-insensitive), then drops the detected
+   * levels/min/max so the variable matches how genuine booleans are recorded (no levels).
+   */
+  applyBooleanOverride(variableName) {
+    const existing = this.getVariable(variableName);
+    const isBooleanLike = (v) => {
+      const s = String(v).trim().toLowerCase();
+      return s === "true" || s === "false" || s === "0" || s === "1";
+    };
+    const offenders = /* @__PURE__ */ new Set();
+    if (Array.isArray(existing.levels)) {
+      for (const level of existing.levels) if (!isBooleanLike(level)) offenders.add(String(level));
+    }
+    if (typeof existing.minValue === "number" && !isBooleanLike(existing.minValue)) offenders.add(String(existing.minValue));
+    if (typeof existing.maxValue === "number" && !isBooleanLike(existing.maxValue)) offenders.add(String(existing.maxValue));
+    if (offenders.size > 0) {
+      const sample = [...offenders].slice(0, 10).join(", ");
+      const more = offenders.size > 10 ? `, \u2026(+${offenders.size - 10} more)` : "";
+      console.warn(
+        `Variable "${variableName}" was set to value:"boolean", but the detected values don't map cleanly to true/false: ${sample}${more}. Double-check this is the intended type.`
+      );
+    }
+    delete existing.levels;
+    delete existing.minValue;
+    delete existing.maxValue;
+  }
+  /**
+   * Registers the keys of a plain JSON object as dotted sub-variables
+   * (e.g. response.Q0, response.Q1) and registers the parent with value: "object".
+   *
+   * Recurses into nested plain objects so structures more than one level deep are
+   * fully expanded (e.g. response.address.city). Nested arrays are registered with
+   * value: "array" (typeof [] === "object", so the inferred type must be overridden)
+   * and, when they hold objects, extracted into a separate CSV keyed by their dotted
+   * column name — mirroring how top-level array columns are handled.
+   *
+   * @param joinValues - The current row's join key values, prepended to every
+   *   extracted nested-array row so the sub-table can be rejoined to the main data.
+   */
+  async expandObjectFields(parentName, obj, pluginType, version, joinValues, row) {
+    await this.generateMetadata(parentName, obj, pluginType, version);
+    for (const key of Object.keys(obj)) {
+      const childName = `${parentName}.${key}`;
+      const childValue = obj[key];
+      if (row) row[childName] = childValue;
+      if (childValue !== null && typeof childValue === "object" && !Array.isArray(childValue)) {
+        await this.expandObjectFields(childName, childValue, pluginType, version, joinValues, row);
+      } else if (Array.isArray(childValue)) {
+        await this.generateMetadata(childName, childValue, pluginType, version);
+        this.updateVariable(childName, "value", "array");
+        await this.accumulateArrayColumn(childName, childValue, joinValues, pluginType, version);
+      } else {
+        await this.generateMetadata(childName, childValue, pluginType, version);
+      }
+    }
+  }
+  /**
+   * Accumulates the object elements of an array column into `extractedArrays` for
+   * separate Psych-DS CSV output, keyed by the column's (possibly dotted) name.
+   * Each emitted row is the join key values, an `element_index`, then the element's
+   * fields under DOTTED names (`columnName.field`) so they don't collide with top-level
+   * columns or with fields of other array columns. Every emitted column is registered in
+   * variableMeasured so the sidecar CSV has no columns missing from the metadata.
+   *
+   * Element fields recurse (see expandElementFields): a nested plain object is expanded
+   * into deeper dotted columns in the SAME row; a nested array is extracted into its own
+   * grandchild CSV, joinable via `${columnName}.element_index` (this element's position)
+   * carried alongside the existing join keys.
+   *
+   * Null / primitive top-level array elements are skipped; arrays with no object elements
+   * produce no rows.
+   */
+  async accumulateArrayColumn(columnName, arr, joinValues, pluginType, version) {
+    const elements = [];
+    arr.forEach((element, index) => {
+      if (element !== null && element !== void 0) elements.push({ element, index });
+    });
+    if (elements.length === 0) return;
+    if (!this.containsVariable("element_index")) {
+      this.setVariable({
+        "@type": "PropertyValue",
+        name: "element_index",
+        description: { default: "Position of this element within its source array column (0-based)." },
+        value: "number"
+      });
+    }
+    for (const joinKey of Object.keys(joinValues)) {
+      if (!this.containsVariable(joinKey)) {
+        this.setVariable({
+          "@type": "PropertyValue",
+          name: joinKey,
+          description: { default: "Join key referencing the position of an enclosing array element (0-based index)." },
+          value: "number"
+        });
+      }
+    }
+    const existing = this.extractedArrays.get(columnName) ?? [];
+    for (const { element, index } of elements) {
+      const row = { ...joinValues, element_index: index };
+      const nestedJoin = { ...joinValues, [`${columnName}.element_index`]: index };
+      if (typeof element === "object" && !Array.isArray(element)) {
+        await this.expandElementFields(columnName, element, row, nestedJoin, pluginType, version);
+      } else {
+        const valueName = `${columnName}.value`;
+        row[valueName] = element;
+        if (Array.isArray(element)) {
+          await this.registerNodeVariable(valueName, element, "array", pluginType, version);
+          await this.accumulateArrayColumn(valueName, element, nestedJoin, pluginType, version);
+        } else {
+          await this.registerScalarField(valueName, element, pluginType, version);
+        }
+      }
+      existing.push(row);
+    }
+    this.extractedArrays.set(columnName, existing);
+  }
+  /**
+   * Recursively records one array element's fields into `row` under dotted names. Scalars become
+   * columns with type + min/max/levels tracking; nested plain objects are expanded into the SAME
+   * row (deeper dotted columns); nested arrays are extracted into their own grandchild CSV via
+   * accumulateArrayColumn (keyed by `nestedJoin`). Object/array nodes are also kept as a single
+   * dotted JSON column so their own name is represented as a column too.
+   */
+  async expandElementFields(prefix, obj, row, nestedJoin, pluginType, version) {
+    for (const key of Object.keys(obj)) {
+      const name = `${prefix}.${key}`;
+      const value = obj[key];
+      row[name] = value;
+      if (value !== null && typeof value === "object" && !Array.isArray(value)) {
+        await this.registerNodeVariable(name, value, "object", pluginType, version);
+        await this.expandElementFields(name, value, row, nestedJoin, pluginType, version);
+      } else if (Array.isArray(value)) {
+        await this.registerNodeVariable(name, value, "array", pluginType, version);
+        await this.accumulateArrayColumn(name, value, nestedJoin, pluginType, version);
+      } else {
+        await this.registerScalarField(name, value, pluginType, version);
+      }
+    }
+  }
+  /** Registers an object/array node variable once (with its plugin description, if any). */
+  async registerNodeVariable(name, value, type, pluginType, version) {
+    if (this.containsVariable(name) && this.getVariable(name).value !== "unknown") return;
+    await this.generateMetadata(name, value, pluginType, version);
+    if (!this.containsVariable(name)) {
+      this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+    } else {
+      this.updateVariable(name, "value", type);
+    }
+  }
+  /**
+   * Registers one scalar array-element field under its dotted name (so the sidecar column is
+   * represented in variableMeasured), then folds later values into min/max/levels. Empty values
+   * still declare the column (placeholder) without polluting min/max/levels.
+   */
+  async registerScalarField(name, value, pluginType, version) {
+    if (value === null || value === void 0 || value === "" || value === "null") {
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: "unknown" });
+      }
+      return;
+    }
+    const type = typeof value;
+    const needsRegister = !this.containsVariable(name) || this.getVariable(name).value === "unknown";
+    if (needsRegister) {
+      await this.generateMetadata(name, value, pluginType, version);
+      if (!this.containsVariable(name)) {
+        this.setVariable({ "@type": "PropertyValue", name, description: { default: "unknown" }, value: type });
+        this.updateFields(name, value, type);
+      }
+    } else {
+      this.updateFields(name, value, type);
+    }
+  }
+  /**
+   * Gets the description of a variable in a plugin by fetching the source code of the plugin
+   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
+   * to extract the description for the variable (present as JSDoc); caches the result for future use.
+   *
+   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
+   * @param {string} variableName - The name of the variable for which information is to be fetched.
+   * @param {string} version - The version of the plugin or extension
+   * @param {string} extension - Boolean indicating if pluginType refers to extension
+   * @returns {Promise} The description of the plugin variable if found, otherwise null.
+   * @throws Will throw an error if the fetch operation fails.
+   */
+  async getPluginInfo(pluginType, variableName, version, extension) {
+    return this.pluginCache.getPluginInfo(pluginType, variableName, version, this.verbose, extension);
+  }
+};
+export {
+  PSYCHDS_IGNORE_CONTENT,
+  PSYCHDS_IGNORE_FILENAME,
+  analyzeJoinKeys,
+  buildPsychDSDataFiles,
+  JsPsychMetadata as default,
+  deriveArrayFilename,
+  deriveFallbackBase,
+  disambiguateArrayFilename,
+  hasUnnamedColumns,
+  isValidPsychDSDataFilename,
+  objectsToCSV,
+  parseCSV,
+  parseJsonData,
+  stripUnnamedColumns,
+  toPsychDSValue,
+  unwrapTrials
+};
diff --git a/functions/metadata/dist/utils.d.ts b/functions/metadata/dist/utils.d.ts
new file mode 100644
index 0000000..0b4e2cd
--- /dev/null
+++ b/functions/metadata/dist/utils.d.ts
@@ -0,0 +1,188 @@
+export declare const PSYCHDS_IGNORE_FILENAME = ".psychds-ignore";
+export declare const PSYCHDS_IGNORE_CONTENT = "**/raw/\n.psychds-ignore\n";
+export declare function saveTextToFile(textstr: string, filename: string): void;
+export declare function JSON2CSV(objArray: any): string;
+export declare function tryParseJSON(value: string): any | null;
+/**
+ * Some jsPsych exports (e.g. from OSF) wrap the trials array as { "trials": [...] }
+ * instead of a bare array. Accepts the raw JSON string (or an already-parsed value)
+ * and returns the unwrapped trials array ONLY when the input is exactly that wrapper —
+ * an object whose single key is `trials` and whose value is an array. Otherwise returns
+ * the parsed value unchanged so the caller's existing Array.isArray gate keeps its
+ * current behavior (the library throws on a non-array; the CLI/frontend skip non-array JSON).
+ *
+ * The single-key check is deliberate: this supports the known wrapper shape, it does not
+ * treat `trials` as a magic key. A future export like { trials: [...], meta: {...} } is
+ * left untouched rather than silently discarding its top-level metadata.
+ *
+ * Folded into parseJsonData's whole-document fast path so every data parse site (generate(),
+ * the CLI pipeline, the frontend uploader) gets wrapper support through the one shared parser;
+ * also exported for direct use and testing.
+ */
+export declare function unwrapTrials(data: string | unknown): unknown;
+/**
+ * Parses experiment data that is either a single JSON document (the standard jsPsych
+ * export — one array of trials, possibly pretty-printed) or JSON-Lines: one JSON value
+ * per line, as JATOS and several labs export it (typically one participant's trial
+ * array per line). Returns a flat array of observations in both cases.
+ *
+ * A well-formed single document is returned as-is (arrays untouched, so existing
+ * single-array callers see no change), except an exact { "trials": [...] } wrapper is
+ * unwrapped to its array via {@link unwrapTrials}. Only when whole-string parsing fails do
+ * we fall back to line-by-line parsing, flattening any per-line arrays into one observation
+ * stream. Throws a descriptive error when the input is neither valid JSON nor valid JSONL.
+ *
+ * When `tagSourceRecordId` is set, `stats.synthesizedSourceRecordId` is set to true iff a
+ * source_record_id was actually stamped onto at least one row (i.e. the data did not already
+ * carry a source_record_id or a real participant_id). Callers use this to describe the column
+ * honestly — a synthesized id marks the source record/line, not a real subject identifier, and
+ * must not be presented as one.
+ */
+export declare function parseJsonData(content: string, options?: {
+    tagSourceRecordId?: boolean;
+}, stats?: {
+    synthesizedSourceRecordId?: boolean;
+}): any;
+/** System columns excluded from join-key candidate detection; also used to initialise ignored_variables in JsPsychMetadata. */
+export declare const SYSTEM_COLUMNS: Set;
+export interface JoinKeyAnalysis {
+    isUnique: boolean;
+    duplicateCount: number;
+    /** Up to 5 example key-value maps for rows that share a composite key. */
+    duplicateValues: Array>;
+    /** All non-system, non-selected columns, categorised by whether adding them alone achieves uniqueness. */
+    candidates: Array<{
+        column: string;
+        makesUnique: boolean;
+    }>;
+    /**
+     * null  — data is already unique, no action needed.
+     * []    — at least one single candidate column is sufficient; the user should pick from candidates.
+     * [...] — no single column is sufficient; greedy result of columns to add together.
+     */
+    suggestedAdditionalKeys: string[] | null;
+}
+export declare function analyzeJoinKeys(parsedData: Array>, keys: string[]): JoinKeyAnalysis;
+/** True if `name` is a fully Psych-DS-compliant data filename. */
+export declare function isValidPsychDSDataFilename(name: string): boolean;
+/**
+ * Coerces an arbitrary string into a Psych-DS *value* segment ([a-zA-Z0-9]+).
+ * Runs of non-alphanumeric characters are treated as word boundaries: removed
+ * and the next word capitalised, yielding camelCase so meaning is preserved
+ * (e.g. "mouse_tracking" → "mouseTracking", "RT (ms)" → "RTMs").
+ * Returns `fallback` when the input has no alphanumeric characters.
+ */
+export declare function toPsychDSValue(name: string, fallback?: string): string;
+/**
+ * Builds a Psych-DS-compliant filename *base* (the keyword-value sequence before
+ * `_data.csv`) from an arbitrary file stem, with no interactive input. Used by
+ * callers that lack a user-supplied/normalized base (e.g. the browser flow): the
+ * stem becomes the value of the official `subject` keyword, coerced to a valid
+ * value segment via {@link toPsychDSValue} (e.g. "sub01" → "subject-sub01",
+ * "subject 1.json".replace stem "subject 1" → "subject-subject1"). `subject` is an
+ * official Psych-DS keyword, so the resulting main datafile avoids the validator's
+ * unofficial-keyword warning. The result always satisfies
+ * {@link isValidPsychDSDataFilename} once `_data.csv` is appended.
+ */
+export declare function deriveFallbackBase(stem: string): string;
+/**
+ * Derives the Psych-DS filename for an extracted-array CSV from its parent
+ * file's already-normalized base plus the column name:
+ *   base "subject-subject1" + column "mouse_tracking"
+ *     → "subject-subject1_measure-mouseTracking_data.csv"
+ */
+export declare function deriveArrayFilename(parentBase: string, columnName: string): string;
+/**
+ * Serialises an array of objects to RFC 4180 CSV. Nested objects/arrays in a
+ * cell are serialised as JSON strings so no data is lost. Priority columns
+ * (trial_index, element_index by default) are placed first; remaining columns
+ * follow in the order they first appear across all rows.
+ */
+export declare function objectsToCSV(rows: Array>, priorityCols?: string[]): string;
+/**
+ * Returns a filename not already present in `used`. If `base` is free it is
+ * returned as-is; otherwise a counter is appended before the `_data.csv`
+ * suffix (e.g. foo_measure-bar_data.csv → foo_measure-bar2_data.csv) until a
+ * free name is found. The counter has no separator — a hyphen or underscore
+ * would create an invalid Psych-DS keyword-value pair.
+ *
+ * KEEP IN SYNC: the CLI's resolveCollisions (packages/cli/src/rename.ts) applies
+ * the same no-separator counter to its rename preview (this one writes, that one
+ * previews — different input shapes keep them separate implementations). If the
+ * counter convention ever changes, both must change together or previewed and
+ * written names will diverge.
+ */
+export declare function disambiguateArrayFilename(base: string, used: Set): string;
+/**
+ * True when any row carries an {@link isUnnamedHeader unnamed} column. Lets a caller decide,
+ * *before* `generate()` mutates the rows in place, whether a CSV source can be written back
+ * byte-for-byte (no unnamed columns) or must be re-serialised from the cleaned rows. Kept as a
+ * shared predicate so the CLI and browser conversion paths share one definition of "unnamed"
+ * with {@link stripUnnamedColumns}, rather than each re-implementing the header scan.
+ */
+export declare function hasUnnamedColumns(rows: Array>): boolean;
+/**
+ * Removes columns whose name is empty or whitespace-only from every row, in place,
+ * and reports which names were dropped. R's `write.csv` (with the default
+ * `row.names = TRUE`) prepends an unnamed row-index column, which surfaces as an
+ * empty-string ("") header. Such a column can never be represented in a Psych-DS
+ * `variableMeasured` entry (a name is required), so leaving it in produces a dataset
+ * that fails validation with CSV_COLUMN_MISSING_FROM_METADATA. Dropping it up front —
+ * once, rather than warning per row — keeps the generated metadata and the written
+ * CSV consistent. Returns the same `rows` reference for convenient chaining.
+ */
+export declare function stripUnnamedColumns(rows: Array>): {
+    rows: Array>;
+    dropped: string[];
+};
+/** A single converted Psych-DS output file produced by {@link buildPsychDSDataFiles}. */
+export interface PsychDSDataFile {
+    /** Psych-DS-compliant filename, relative to the `data/` directory. */
+    filename: string;
+    /** RFC-4180 CSV contents. */
+    content: string;
+    /** Which source the rows came from: the main table, an array column, or an object column. */
+    kind: 'main' | 'array' | 'object';
+}
+export interface BuildPsychDSDataFilesArgs {
+    /** Compliant filename base (keyword-value sequence before `_data.csv`), e.g. "id-sub01". */
+    base: string;
+    /**
+     * Parsed rows of the main data file. Serialised to CSV unless `mainContent` is given and
+     * no unnamed columns are dropped. Always supply this (parse CSV inputs too) so unnamed
+     * row-index columns can be detected and stripped.
+     */
+    mainRows: Array>;
+    /**
+     * Pre-rendered CSV for the main file, used verbatim instead of serialising `mainRows` —
+     * but only when no unnamed columns are dropped. Pass this when the source is already CSV
+     * so a clean file keeps its exact bytes (column order, quoting); a file with an unnamed
+     * column is re-serialised from the cleaned `mainRows` instead.
+     */
+    mainContent?: string;
+    /** Array-column rows keyed by column name (from `JsPsychMetadata.getExtractedArrays`). */
+    extractedArrays?: Map>>;
+    /** Object-column rows keyed by column name (from `JsPsychMetadata.getExtractedObjects`). */
+    extractedObjects?: Map>>;
+    /** Join keys used when extracting nested columns (from `JsPsychMetadata.getArrayJoinKeys`). */
+    joinKeys?: string[];
+    /**
+     * Set of already-used output filenames, shared across all files in a dataset so names are
+     * disambiguated against the whole `data/` directory. Mutated: every name returned is added.
+     */
+    usedArrayFilenames?: Set;
+}
+/**
+ * Turns one parsed data file (plus any nested array/object columns extracted during
+ * `JsPsychMetadata.generate`) into its set of Psych-DS CSV outputs. Pure and
+ * filesystem-agnostic: the caller decides where the returned contents go (the CLI writes
+ * them to disk, the browser puts them in a file tree / zip). Mirrors the conversion the CLI
+ * performs inline so both share one implementation.
+ *
+ * The main table becomes `${base}_data.csv`; each extracted array/object column becomes a
+ * sidecar named via {@link deriveArrayFilename}, disambiguated against `usedArrayFilenames`.
+ * Throws if a resolved name isn't Psych-DS-compliant (an invalid `base` reaching here is a
+ * programming error — callers derive `base` with {@link deriveFallbackBase} or a validated plan).
+ */
+export declare function buildPsychDSDataFiles(args: BuildPsychDSDataFilesArgs): PsychDSDataFile[];
+export declare function parseCSV(input: any): Promise;
diff --git a/functions/metadata/jest.config.cjs b/functions/metadata/jest.config.cjs
deleted file mode 100644
index 6ac19d5..0000000
--- a/functions/metadata/jest.config.cjs
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname);
diff --git a/functions/metadata/package-lock.json b/functions/metadata/package-lock.json
deleted file mode 100644
index 9cc03b2..0000000
--- a/functions/metadata/package-lock.json
+++ /dev/null
@@ -1,13796 +0,0 @@
-{
-  "name": "@jspsych/metadata",
-  "version": "0.0.1",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "@jspsych/metadata",
-      "version": "0.0.1",
-      "license": "MIT",
-      "devDependencies": {
-        "@jspsych/config": "^3.2.2",
-        "@jspsych/test-utils": "^1.1.2",
-        "@types/jest": "^29.5.12",
-        "ts-jest": "^29.1.4"
-      },
-      "peerDependencies": {
-        "jspsych": "^8.0.0"
-      }
-    },
-    "node_modules/@ampproject/remapping": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
-      "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "@jridgewell/gen-mapping": "^0.3.5",
-        "@jridgewell/trace-mapping": "^0.3.24"
-      },
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/@babel/code-frame": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
-      "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-validator-identifier": "^7.27.1",
-        "js-tokens": "^4.0.0",
-        "picocolors": "^1.1.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/compat-data": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
-      "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/core": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
-      "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.27.1",
-        "@babel/generator": "^7.28.0",
-        "@babel/helper-compilation-targets": "^7.27.2",
-        "@babel/helper-module-transforms": "^7.27.3",
-        "@babel/helpers": "^7.27.6",
-        "@babel/parser": "^7.28.0",
-        "@babel/template": "^7.27.2",
-        "@babel/traverse": "^7.28.0",
-        "@babel/types": "^7.28.0",
-        "convert-source-map": "^2.0.0",
-        "debug": "^4.1.0",
-        "gensync": "^1.0.0-beta.2",
-        "json5": "^2.2.3",
-        "semver": "^6.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/babel"
-      }
-    },
-    "node_modules/@babel/generator": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
-      "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/parser": "^7.28.0",
-        "@babel/types": "^7.28.0",
-        "@jridgewell/gen-mapping": "^0.3.12",
-        "@jridgewell/trace-mapping": "^0.3.28",
-        "jsesc": "^3.0.2"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-annotate-as-pure": {
-      "version": "7.27.3",
-      "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
-      "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.27.3"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.27.2",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
-      "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/compat-data": "^7.27.2",
-        "@babel/helper-validator-option": "^7.27.1",
-        "browserslist": "^4.24.0",
-        "lru-cache": "^5.1.1",
-        "semver": "^6.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-create-class-features-plugin": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
-      "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-annotate-as-pure": "^7.27.1",
-        "@babel/helper-member-expression-to-functions": "^7.27.1",
-        "@babel/helper-optimise-call-expression": "^7.27.1",
-        "@babel/helper-replace-supers": "^7.27.1",
-        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
-        "@babel/traverse": "^7.27.1",
-        "semver": "^6.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/@babel/helper-globals": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
-      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-member-expression-to-functions": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
-      "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/traverse": "^7.27.1",
-        "@babel/types": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-module-imports": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
-      "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/traverse": "^7.27.1",
-        "@babel/types": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-module-transforms": {
-      "version": "7.27.3",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
-      "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-module-imports": "^7.27.1",
-        "@babel/helper-validator-identifier": "^7.27.1",
-        "@babel/traverse": "^7.27.3"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/@babel/helper-optimise-call-expression": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
-      "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-plugin-utils": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
-      "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-replace-supers": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
-      "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-member-expression-to-functions": "^7.27.1",
-        "@babel/helper-optimise-call-expression": "^7.27.1",
-        "@babel/traverse": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
-      "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/traverse": "^7.27.1",
-        "@babel/types": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-string-parser": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
-      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
-      "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-validator-option": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
-      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helpers": {
-      "version": "7.27.6",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
-      "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/template": "^7.27.2",
-        "@babel/types": "^7.27.6"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/parser": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
-      "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.28.0"
-      },
-      "bin": {
-        "parser": "bin/babel-parser.js"
-      },
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-async-generators": {
-      "version": "7.8.4",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
-      "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-bigint": {
-      "version": "7.8.3",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
-      "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-class-properties": {
-      "version": "7.12.13",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
-      "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.12.13"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-class-static-block": {
-      "version": "7.14.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
-      "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.14.5"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-flow": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz",
-      "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-import-attributes": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
-      "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-import-meta": {
-      "version": "7.10.4",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
-      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.10.4"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-json-strings": {
-      "version": "7.8.3",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
-      "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-jsx": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
-      "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
-      "version": "7.10.4",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
-      "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.10.4"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
-      "version": "7.8.3",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
-      "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-numeric-separator": {
-      "version": "7.10.4",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
-      "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.10.4"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-object-rest-spread": {
-      "version": "7.8.3",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
-      "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-optional-catch-binding": {
-      "version": "7.8.3",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
-      "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-optional-chaining": {
-      "version": "7.8.3",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
-      "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.8.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-private-property-in-object": {
-      "version": "7.14.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
-      "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.14.5"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-top-level-await": {
-      "version": "7.14.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
-      "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.14.5"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-syntax-typescript": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
-      "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-class-properties": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
-      "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-create-class-features-plugin": "^7.27.1",
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-flow-strip-types": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz",
-      "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1",
-        "@babel/plugin-syntax-flow": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-modules-commonjs": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz",
-      "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-module-transforms": "^7.27.1",
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
-      "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-optional-chaining": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
-      "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1",
-        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-private-methods": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz",
-      "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-create-class-features-plugin": "^7.27.1",
-        "@babel/helper-plugin-utils": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-typescript": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
-      "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-annotate-as-pure": "^7.27.3",
-        "@babel/helper-create-class-features-plugin": "^7.27.1",
-        "@babel/helper-plugin-utils": "^7.27.1",
-        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
-        "@babel/plugin-syntax-typescript": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/preset-flow": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz",
-      "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1",
-        "@babel/helper-validator-option": "^7.27.1",
-        "@babel/plugin-transform-flow-strip-types": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/preset-typescript": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
-      "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.27.1",
-        "@babel/helper-validator-option": "^7.27.1",
-        "@babel/plugin-syntax-jsx": "^7.27.1",
-        "@babel/plugin-transform-modules-commonjs": "^7.27.1",
-        "@babel/plugin-transform-typescript": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/register": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.27.1.tgz",
-      "integrity": "sha512-K13lQpoV54LATKkzBpBAEu1GGSIRzxR9f4IN4V8DCDgiUMo2UDGagEZr3lPeVNJPLkWUi5JE4hCHKneVTwQlYQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "clone-deep": "^4.0.1",
-        "find-cache-dir": "^2.0.0",
-        "make-dir": "^2.1.0",
-        "pirates": "^4.0.6",
-        "source-map-support": "^0.5.16"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/register/node_modules/make-dir": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
-      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "pify": "^4.0.1",
-        "semver": "^5.6.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/@babel/register/node_modules/semver": {
-      "version": "5.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
-      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver"
-      }
-    },
-    "node_modules/@babel/register/node_modules/source-map-support": {
-      "version": "0.5.21",
-      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
-      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "buffer-from": "^1.0.0",
-        "source-map": "^0.6.0"
-      }
-    },
-    "node_modules/@babel/template": {
-      "version": "7.27.2",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
-      "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@babel/parser": "^7.27.2",
-        "@babel/types": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/traverse": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
-      "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@babel/generator": "^7.28.0",
-        "@babel/helper-globals": "^7.28.0",
-        "@babel/parser": "^7.28.0",
-        "@babel/template": "^7.27.2",
-        "@babel/types": "^7.28.0",
-        "debug": "^4.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/types": {
-      "version": "7.28.1",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
-      "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-string-parser": "^7.27.1",
-        "@babel/helper-validator-identifier": "^7.27.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@bcoe/v8-coverage": {
-      "version": "0.2.3",
-      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
-      "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@citation-js/core": {
-      "version": "0.7.18",
-      "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz",
-      "integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "@citation-js/name": "^0.4.2",
-        "fetch-ponyfill": "^7.1.0",
-        "sync-fetch": "^0.4.1"
-      },
-      "engines": {
-        "node": ">=16.0.0"
-      }
-    },
-    "node_modules/@citation-js/date": {
-      "version": "0.5.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/date/-/date-0.5.1.tgz",
-      "integrity": "sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/@citation-js/name": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/@citation-js/name/-/name-0.4.2.tgz",
-      "integrity": "sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/@citation-js/plugin-bibtex": {
-      "version": "0.7.18",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-bibtex/-/plugin-bibtex-0.7.18.tgz",
-      "integrity": "sha512-TdsZSMpgpfcx2NMPu0KiulEoecllwT5EtRUzAJl2pDsdPD1tUqqbyj/NBi0l8fwNy1r7WwAqSFGiqGPjQWpFdg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "@citation-js/name": "^0.4.2",
-        "moo": "^0.5.1"
-      },
-      "engines": {
-        "node": ">=16.0.0"
-      },
-      "peerDependencies": {
-        "@citation-js/core": "^0.7.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-cff": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-cff/-/plugin-cff-0.6.1.tgz",
-      "integrity": "sha512-tLjTgsfzNOdQWGn5mNc2NAaydHnlRucSERoyAXLN7u0BQBfp7j5zwdxCmxcQD/N7hH3fpDKMG+qDzbqpJuKyNA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "@citation-js/plugin-yaml": "^0.6.1"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-csl": {
-      "version": "0.7.18",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.18.tgz",
-      "integrity": "sha512-cJcOdEZurmtIxNj0d4cOERHpVQJB/mN3YPSDNqfI/xTFRN3bWDpFAsaqubPtMO2ZPpoDS+ZGIP1kggbwCfMmlA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "citeproc": "^2.4.6"
-      },
-      "engines": {
-        "node": ">=16.0.0"
-      },
-      "peerDependencies": {
-        "@citation-js/core": "^0.7.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-github": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-github/-/plugin-github-0.6.1.tgz",
-      "integrity": "sha512-1ZeSgQ5AoYsa8n2acVooUeRk76oA8rLszYNBjzj5z6MPa11BZlQJ9O+Gy4tHjlImvsENLbLPx5f8/V1VHXaCfQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "@citation-js/name": "^0.4.2"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-npm": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-npm/-/plugin-npm-0.6.1.tgz",
-      "integrity": "sha512-rojJA+l/p2KBpDoY+8n0YfNyQO1Aw03fQR5BN+gXD1LNAP1V+8wqvdPsaHnzPsrhrd4ZXDR7ch/Nk0yynPkJ3Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "@citation-js/name": "^0.4.2"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-software-formats": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-software-formats/-/plugin-software-formats-0.6.1.tgz",
-      "integrity": "sha512-BDF9rqi56K0hoTgYTVANCFVRSbWKC9V06Uap7oa8SjqCTgnHJAy8t/F3NxsyYPPG+zmRsLW9VNbcIsJOl0eu/w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/plugin-cff": "^0.6.1",
-        "@citation-js/plugin-github": "^0.6.1",
-        "@citation-js/plugin-npm": "^0.6.1",
-        "@citation-js/plugin-yaml": "^0.6.1",
-        "@citation-js/plugin-zenodo": "^0.6.1"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-yaml": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-yaml/-/plugin-yaml-0.6.1.tgz",
-      "integrity": "sha512-XEVVks1cJTqRbjy+nmthfw/puR6NwRB3fyJWi1tX13UYXlkhP/h45nsv4zjgLLGekdcMHQvhad9MAYunOftGKA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "js-yaml": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@citation-js/plugin-zenodo": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@citation-js/plugin-zenodo/-/plugin-zenodo-0.6.1.tgz",
-      "integrity": "sha512-bUybENHoZqJ6gheUqgkumjI+mu+fA2bg6VoniDmZTb7Qng9iEpi+IWEAR26/vBE0gK0EWrJjczyDW3HCwrhvVw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/date": "^0.5.0",
-        "@citation-js/name": "^0.4.2"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@emnapi/core": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz",
-      "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@emnapi/wasi-threads": "1.0.3",
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz",
-      "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/wasi-threads": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz",
-      "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
-      "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
-      "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
-      "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
-      "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
-      "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
-      "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
-      "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
-      "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
-      "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
-      "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
-      "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
-      "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
-      "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
-      "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
-      "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
-      "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
-      "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
-      "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
-      "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
-      "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
-      "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
-      "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
-      "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
-      "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@gulpjs/messages": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@gulpjs/messages/-/messages-1.1.0.tgz",
-      "integrity": "sha512-Ys9sazDatyTgZVb4xPlDufLweJ/Os2uHWOv+Caxvy2O85JcnT4M3vc73bi8pdLWlv3fdWQz3pdI9tVwo8rQQSg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/@gulpjs/to-absolute-glob": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz",
-      "integrity": "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-negated-glob": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/@inquirer/checkbox": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz",
-      "integrity": "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/figures": "^1.0.5",
-        "@inquirer/type": "^1.5.3",
-        "ansi-escapes": "^4.3.2",
-        "yoctocolors-cjs": "^2.1.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/confirm": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz",
-      "integrity": "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/core": {
-      "version": "9.2.1",
-      "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz",
-      "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/figures": "^1.0.6",
-        "@inquirer/type": "^2.0.0",
-        "@types/mute-stream": "^0.0.4",
-        "@types/node": "^22.5.5",
-        "@types/wrap-ansi": "^3.0.0",
-        "ansi-escapes": "^4.3.2",
-        "cli-width": "^4.1.0",
-        "mute-stream": "^1.0.0",
-        "signal-exit": "^4.1.0",
-        "strip-ansi": "^6.0.1",
-        "wrap-ansi": "^6.2.0",
-        "yoctocolors-cjs": "^2.1.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/core/node_modules/@inquirer/type": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz",
-      "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mute-stream": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/editor": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-2.2.0.tgz",
-      "integrity": "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3",
-        "external-editor": "^3.1.0"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/expand": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-2.3.0.tgz",
-      "integrity": "sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3",
-        "yoctocolors-cjs": "^2.1.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/figures": {
-      "version": "1.0.12",
-      "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz",
-      "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/input": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz",
-      "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/number": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-1.1.0.tgz",
-      "integrity": "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/password": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-2.2.0.tgz",
-      "integrity": "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3",
-        "ansi-escapes": "^4.3.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/prompts": {
-      "version": "5.5.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.5.0.tgz",
-      "integrity": "sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/checkbox": "^2.5.0",
-        "@inquirer/confirm": "^3.2.0",
-        "@inquirer/editor": "^2.2.0",
-        "@inquirer/expand": "^2.3.0",
-        "@inquirer/input": "^2.3.0",
-        "@inquirer/number": "^1.1.0",
-        "@inquirer/password": "^2.2.0",
-        "@inquirer/rawlist": "^2.3.0",
-        "@inquirer/search": "^1.1.0",
-        "@inquirer/select": "^2.5.0"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/rawlist": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-2.3.0.tgz",
-      "integrity": "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/type": "^1.5.3",
-        "yoctocolors-cjs": "^2.1.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/search": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-1.1.0.tgz",
-      "integrity": "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/figures": "^1.0.5",
-        "@inquirer/type": "^1.5.3",
-        "yoctocolors-cjs": "^2.1.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/select": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz",
-      "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/figures": "^1.0.5",
-        "@inquirer/type": "^1.5.3",
-        "ansi-escapes": "^4.3.2",
-        "yoctocolors-cjs": "^2.1.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@inquirer/type": {
-      "version": "1.5.5",
-      "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz",
-      "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mute-stream": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@isaacs/cliui": {
-      "version": "8.0.2",
-      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
-      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "string-width": "^5.1.2",
-        "string-width-cjs": "npm:string-width@^4.2.0",
-        "strip-ansi": "^7.0.1",
-        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
-        "wrap-ansi": "^8.1.0",
-        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
-      "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
-      }
-    },
-    "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
-      "version": "6.2.1",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
-      "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
-      "version": "9.2.2",
-      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@isaacs/cliui/node_modules/string-width": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
-      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "eastasianwidth": "^0.2.0",
-        "emoji-regex": "^9.2.2",
-        "strip-ansi": "^7.0.1"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
-      "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-regex": "^6.0.1"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
-      }
-    },
-    "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
-      "version": "8.1.0",
-      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
-      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^6.1.0",
-        "string-width": "^5.0.1",
-        "strip-ansi": "^7.0.1"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-      }
-    },
-    "node_modules/@istanbuljs/load-nyc-config": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
-      "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "camelcase": "^5.3.1",
-        "find-up": "^4.1.0",
-        "get-package-type": "^0.1.0",
-        "js-yaml": "^3.13.1",
-        "resolve-from": "^5.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
-      "version": "1.0.10",
-      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
-      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "sprintf-js": "~1.0.2"
-      }
-    },
-    "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
-      "version": "3.14.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
-      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "argparse": "^1.0.7",
-        "esprima": "^4.0.0"
-      },
-      "bin": {
-        "js-yaml": "bin/js-yaml.js"
-      }
-    },
-    "node_modules/@istanbuljs/schema": {
-      "version": "0.1.3",
-      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
-      "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/@jest/console": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.4.tgz",
-      "integrity": "sha512-tMLCDvBJBwPqMm4OAiuKm2uF5y5Qe26KgcMn+nrDSWpEW+eeFmqA0iO4zJfL16GP7gE3bUUQ3hIuUJ22AqVRnw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "jest-message-util": "30.0.2",
-        "jest-util": "30.0.2",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/console/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@jest/console/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/console/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/console/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/core": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.4.tgz",
-      "integrity": "sha512-MWScSO9GuU5/HoWjpXAOBs6F/iobvK1XlioelgOM9St7S0Z5WTI9kjCQLPeo4eQRRYusyLW25/J7J5lbFkrYXw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/console": "30.0.4",
-        "@jest/pattern": "30.0.1",
-        "@jest/reporters": "30.0.4",
-        "@jest/test-result": "30.0.4",
-        "@jest/transform": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "ansi-escapes": "^4.3.2",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "exit-x": "^0.2.2",
-        "graceful-fs": "^4.2.11",
-        "jest-changed-files": "30.0.2",
-        "jest-config": "30.0.4",
-        "jest-haste-map": "30.0.2",
-        "jest-message-util": "30.0.2",
-        "jest-regex-util": "30.0.1",
-        "jest-resolve": "30.0.2",
-        "jest-resolve-dependencies": "30.0.4",
-        "jest-runner": "30.0.4",
-        "jest-runtime": "30.0.4",
-        "jest-snapshot": "30.0.4",
-        "jest-util": "30.0.2",
-        "jest-validate": "30.0.2",
-        "jest-watcher": "30.0.4",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jest/core/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@jest/core/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/core/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/core/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/diff-sequences": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
-      "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/environment": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.4.tgz",
-      "integrity": "sha512-5NT+sr7ZOb8wW7C4r7wOKnRQ8zmRWQT2gW4j73IXAKp5/PX1Z8MCStBLQDYfIG3n1Sw0NRfYGdp0iIPVooBAFQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/fake-timers": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "jest-mock": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.4.tgz",
-      "integrity": "sha512-Z/DL7t67LBHSX4UzDyeYKqOxE/n7lbrrgEwWM3dGiH5Dgn35nk+YtgzKudmfIrBI8DRRrKYY5BCo3317HZV1Fw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "expect": "30.0.4",
-        "jest-snapshot": "30.0.4"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect-utils": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
-      "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "jest-get-type": "^29.6.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/@jest/expect-utils": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz",
-      "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/expect": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz",
-      "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/expect-utils": "30.0.4",
-        "@jest/get-type": "30.0.1",
-        "jest-matcher-utils": "30.0.4",
-        "jest-message-util": "30.0.2",
-        "jest-mock": "30.0.2",
-        "jest-util": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/jest-diff": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz",
-      "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/diff-sequences": "30.0.1",
-        "@jest/get-type": "30.0.1",
-        "chalk": "^4.1.2",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/jest-matcher-utils": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz",
-      "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1",
-        "chalk": "^4.1.2",
-        "jest-diff": "30.0.4",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/expect/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/fake-timers": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.4.tgz",
-      "integrity": "sha512-qZ7nxOcL5+gwBO6LErvwVy5k06VsX/deqo2XnVUSTV0TNC9lrg8FC3dARbi+5lmrr5VyX5drragK+xLcOjvjYw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@sinonjs/fake-timers": "^13.0.0",
-        "@types/node": "*",
-        "jest-message-util": "30.0.2",
-        "jest-mock": "30.0.2",
-        "jest-util": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/fake-timers/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@jest/fake-timers/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/fake-timers/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/fake-timers/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/get-type": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz",
-      "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/globals": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.4.tgz",
-      "integrity": "sha512-avyZuxEHF2EUhFF6NEWVdxkRRV6iXXcIES66DLhuLlU7lXhtFG/ySq/a8SRZmEJSsLkNAFX6z6mm8KWyXe9OEA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/environment": "30.0.4",
-        "@jest/expect": "30.0.4",
-        "@jest/types": "30.0.1",
-        "jest-mock": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/pattern": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz",
-      "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/node": "*",
-        "jest-regex-util": "30.0.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/reporters": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.4.tgz",
-      "integrity": "sha512-6ycNmP0JSJEEys1FbIzHtjl9BP0tOZ/KN6iMeAKrdvGmUsa1qfRdlQRUDKJ4P84hJ3xHw1yTqJt4fvPNHhyE+g==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@bcoe/v8-coverage": "^0.2.3",
-        "@jest/console": "30.0.4",
-        "@jest/test-result": "30.0.4",
-        "@jest/transform": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@jridgewell/trace-mapping": "^0.3.25",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "collect-v8-coverage": "^1.0.2",
-        "exit-x": "^0.2.2",
-        "glob": "^10.3.10",
-        "graceful-fs": "^4.2.11",
-        "istanbul-lib-coverage": "^3.0.0",
-        "istanbul-lib-instrument": "^6.0.0",
-        "istanbul-lib-report": "^3.0.0",
-        "istanbul-lib-source-maps": "^5.0.0",
-        "istanbul-reports": "^3.1.3",
-        "jest-message-util": "30.0.2",
-        "jest-util": "30.0.2",
-        "jest-worker": "30.0.2",
-        "slash": "^3.0.0",
-        "string-length": "^4.0.2",
-        "v8-to-istanbul": "^9.0.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/glob": {
-      "version": "10.4.5",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
-      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "foreground-child": "^3.1.0",
-        "jackspeak": "^3.1.2",
-        "minimatch": "^9.0.4",
-        "minipass": "^7.1.2",
-        "package-json-from-dist": "^1.0.0",
-        "path-scurry": "^1.11.1"
-      },
-      "bin": {
-        "glob": "dist/esm/bin.mjs"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/@jest/reporters/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/schemas": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz",
-      "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@sinclair/typebox": "^0.34.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/snapshot-utils": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.4.tgz",
-      "integrity": "sha512-BEpX8M/Y5lG7MI3fmiO+xCnacOrVsnbqVrcDZIT8aSGkKV1w2WwvRQxSWw5SIS8ozg7+h8tSj5EO1Riqqxcdag==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "natural-compare": "^1.4.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/source-map": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz",
-      "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.25",
-        "callsites": "^3.1.0",
-        "graceful-fs": "^4.2.11"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/test-result": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.4.tgz",
-      "integrity": "sha512-Mfpv8kjyKTHqsuu9YugB6z1gcdB3TSSOaKlehtVaiNlClMkEHY+5ZqCY2CrEE3ntpBMlstX/ShDAf84HKWsyIw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/console": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/istanbul-lib-coverage": "^2.0.6",
-        "collect-v8-coverage": "^1.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/test-sequencer": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.4.tgz",
-      "integrity": "sha512-bj6ePmqi4uxAE8EHE0Slmk5uBYd9Vd/PcVt06CsBxzH4bbA8nGsI1YbXl/NH+eii4XRtyrRx+Cikub0x8H4vDg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/test-result": "30.0.4",
-        "graceful-fs": "^4.2.11",
-        "jest-haste-map": "30.0.2",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/transform": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.4.tgz",
-      "integrity": "sha512-atvy4hRph/UxdCIBp+UB2jhEA/jJiUeGZ7QPgBi9jUUKNgi3WEoMXGNG7zbbELG2+88PMabUNCDchmqgJy3ELg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/core": "^7.27.4",
-        "@jest/types": "30.0.1",
-        "@jridgewell/trace-mapping": "^0.3.25",
-        "babel-plugin-istanbul": "^7.0.0",
-        "chalk": "^4.1.2",
-        "convert-source-map": "^2.0.0",
-        "fast-json-stable-stringify": "^2.1.0",
-        "graceful-fs": "^4.2.11",
-        "jest-haste-map": "30.0.2",
-        "jest-regex-util": "30.0.1",
-        "jest-util": "30.0.2",
-        "micromatch": "^4.0.8",
-        "pirates": "^4.0.7",
-        "slash": "^3.0.0",
-        "write-file-atomic": "^5.0.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/transform/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jest/types": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz",
-      "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/pattern": "30.0.1",
-        "@jest/schemas": "30.0.1",
-        "@types/istanbul-lib-coverage": "^2.0.6",
-        "@types/istanbul-reports": "^3.0.4",
-        "@types/node": "*",
-        "@types/yargs": "^17.0.33",
-        "chalk": "^4.1.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/@jridgewell/gen-mapping": {
-      "version": "0.3.12",
-      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
-      "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.0",
-        "@jridgewell/trace-mapping": "^0.3.24"
-      }
-    },
-    "node_modules/@jridgewell/resolve-uri": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
-      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/@jridgewell/sourcemap-codec": {
-      "version": "1.5.4",
-      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
-      "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@jridgewell/trace-mapping": {
-      "version": "0.3.29",
-      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
-      "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/resolve-uri": "^3.1.0",
-        "@jridgewell/sourcemap-codec": "^1.4.14"
-      }
-    },
-    "node_modules/@jspsych/config": {
-      "version": "3.3.2",
-      "resolved": "https://registry.npmjs.org/@jspsych/config/-/config-3.3.2.tgz",
-      "integrity": "sha512-7IYDKJOWEgCnNK4iphGRRXI7Zh+IjO6XD+K+zmfSlfDRyaTFHZzJiBVxGVnIAVBX/MtSLdIAkz9Ll+Ur+wwVeQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@citation-js/core": "^0.7.14",
-        "@citation-js/plugin-bibtex": "^0.7.14",
-        "@citation-js/plugin-csl": "^0.7.14",
-        "@citation-js/plugin-software-formats": "^0.6.1",
-        "@rollup/plugin-commonjs": "26.0.1",
-        "@rollup/plugin-node-resolve": "15.2.3",
-        "@rollup/plugin-replace": "^6.0.1",
-        "@sucrase/jest-plugin": "3.0.0",
-        "@types/gulp": "4.0.17",
-        "@types/jest": "29.5.8",
-        "@types/node": "^22.10.10",
-        "alias-hq": "6.2.4",
-        "app-root-path": "^3.1.0",
-        "esbuild": "0.23.1",
-        "glob": "7.2.3",
-        "gulp": "5.0.0",
-        "gulp-cli": "3.0.0",
-        "gulp-file": "0.4.0",
-        "gulp-rename": "2.0.0",
-        "gulp-replace": "1.1.4",
-        "gulp-zip": "6.0.0",
-        "jest": "29.7.0",
-        "jest-canvas-mock": "2.5.0",
-        "jest-environment-jsdom": "29.7.0",
-        "merge-stream": "2.0.0",
-        "rollup": "^4.22.4",
-        "rollup-plugin-dts": "6.1.1",
-        "rollup-plugin-esbuild": "6.1.1",
-        "rollup-plugin-modify": "^3.0.0",
-        "rollup-plugin-node-externals": "7.1.3",
-        "sucrase": "3.34.0",
-        "tslib": "2.6.2",
-        "typescript": "^5.7.0"
-      },
-      "engines": {
-        "node": ">=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/console": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
-      "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/core": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
-      "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/reporters": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "ansi-escapes": "^4.2.1",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "exit": "^0.1.2",
-        "graceful-fs": "^4.2.9",
-        "jest-changed-files": "^29.7.0",
-        "jest-config": "^29.7.0",
-        "jest-haste-map": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-resolve-dependencies": "^29.7.0",
-        "jest-runner": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "jest-watcher": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "pretty-format": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/core/node_modules/jest-config": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
-      "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/test-sequencer": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-jest": "^29.7.0",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "deepmerge": "^4.2.2",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-circus": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-runner": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "parse-json": "^5.2.0",
-        "pretty-format": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-json-comments": "^3.1.1"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@types/node": "*",
-        "ts-node": ">=9.0.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "ts-node": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/environment": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
-      "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-mock": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/expect": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
-      "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expect": "^29.7.0",
-        "jest-snapshot": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/fake-timers": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
-      "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@sinonjs/fake-timers": "^10.0.2",
-        "@types/node": "*",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/globals": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
-      "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/expect": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "jest-mock": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/reporters": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
-      "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@bcoe/v8-coverage": "^0.2.3",
-        "@jest/console": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "collect-v8-coverage": "^1.0.0",
-        "exit": "^0.1.2",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "istanbul-lib-coverage": "^3.0.0",
-        "istanbul-lib-instrument": "^6.0.0",
-        "istanbul-lib-report": "^3.0.0",
-        "istanbul-lib-source-maps": "^4.0.0",
-        "istanbul-reports": "^3.1.3",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-worker": "^29.7.0",
-        "slash": "^3.0.0",
-        "string-length": "^4.0.1",
-        "strip-ansi": "^6.0.0",
-        "v8-to-istanbul": "^9.0.1"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/source-map": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
-      "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "callsites": "^3.0.0",
-        "graceful-fs": "^4.2.9"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/test-result": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
-      "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "collect-v8-coverage": "^1.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/test-sequencer": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
-      "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/test-result": "^29.7.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/transform": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
-      "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/types": "^29.6.3",
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "babel-plugin-istanbul": "^6.1.1",
-        "chalk": "^4.0.0",
-        "convert-source-map": "^2.0.0",
-        "fast-json-stable-stringify": "^2.1.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "pirates": "^4.0.4",
-        "slash": "^3.0.0",
-        "write-file-atomic": "^4.0.2"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@jest/types": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-      "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "@types/istanbul-reports": "^3.0.0",
-        "@types/node": "*",
-        "@types/yargs": "^17.0.8",
-        "chalk": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@jspsych/config/node_modules/@sinonjs/fake-timers": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
-      "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@sinonjs/commons": "^3.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/@types/jest": {
-      "version": "29.5.8",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
-      "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expect": "^29.0.0",
-        "pretty-format": "^29.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/babel-jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
-      "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/transform": "^29.7.0",
-        "@types/babel__core": "^7.1.14",
-        "babel-plugin-istanbul": "^6.1.1",
-        "babel-preset-jest": "^29.6.3",
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.8.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/babel-plugin-istanbul": {
-      "version": "6.1.1",
-      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
-      "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.0.0",
-        "@istanbuljs/load-nyc-config": "^1.0.0",
-        "@istanbuljs/schema": "^0.1.2",
-        "istanbul-lib-instrument": "^5.0.4",
-        "test-exclude": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
-      "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@babel/core": "^7.12.3",
-        "@babel/parser": "^7.14.7",
-        "@istanbuljs/schema": "^0.1.2",
-        "istanbul-lib-coverage": "^3.2.0",
-        "semver": "^6.3.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/babel-plugin-jest-hoist": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz",
-      "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/template": "^7.3.3",
-        "@babel/types": "^7.3.3",
-        "@types/babel__core": "^7.1.14",
-        "@types/babel__traverse": "^7.0.6"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/babel-preset-jest": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
-      "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "babel-plugin-jest-hoist": "^29.6.3",
-        "babel-preset-current-node-syntax": "^1.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/camelcase": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
-      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/ci-info": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
-      "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/sibiraj-s"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/cjs-module-lexer": {
-      "version": "1.4.3",
-      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
-      "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@jspsych/config/node_modules/cliui": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
-      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "string-width": "^4.2.0",
-        "strip-ansi": "^6.0.1",
-        "wrap-ansi": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/istanbul-lib-source-maps": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
-      "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "debug": "^4.1.1",
-        "istanbul-lib-coverage": "^3.0.0",
-        "source-map": "^0.6.1"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
-      "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/core": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "import-local": "^3.0.2",
-        "jest-cli": "^29.7.0"
-      },
-      "bin": {
-        "jest": "bin/jest.js"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-changed-files": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
-      "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "execa": "^5.0.0",
-        "jest-util": "^29.7.0",
-        "p-limit": "^3.1.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-circus": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
-      "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/expect": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "co": "^4.6.0",
-        "dedent": "^1.0.0",
-        "is-generator-fn": "^2.0.0",
-        "jest-each": "^29.7.0",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "p-limit": "^3.1.0",
-        "pretty-format": "^29.7.0",
-        "pure-rand": "^6.0.0",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-cli": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
-      "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/core": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "create-jest": "^29.7.0",
-        "exit": "^0.1.2",
-        "import-local": "^3.0.2",
-        "jest-config": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "yargs": "^17.3.1"
-      },
-      "bin": {
-        "jest": "bin/jest.js"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-cli/node_modules/jest-config": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
-      "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/test-sequencer": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-jest": "^29.7.0",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "deepmerge": "^4.2.2",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-circus": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-runner": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "parse-json": "^5.2.0",
-        "pretty-format": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-json-comments": "^3.1.1"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@types/node": "*",
-        "ts-node": ">=9.0.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "ts-node": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-docblock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
-      "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "detect-newline": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-each": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
-      "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "jest-get-type": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-environment-node": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
-      "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-haste-map": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz",
-      "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/graceful-fs": "^4.1.3",
-        "@types/node": "*",
-        "anymatch": "^3.0.3",
-        "fb-watchman": "^2.0.0",
-        "graceful-fs": "^4.2.9",
-        "jest-regex-util": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "jest-worker": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "walker": "^1.0.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "^2.3.2"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-leak-detector": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
-      "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-mock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
-      "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-regex-util": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
-      "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-resolve": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
-      "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-pnp-resolver": "^1.2.2",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "resolve": "^1.20.0",
-        "resolve.exports": "^2.0.0",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-resolve-dependencies": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz",
-      "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "jest-regex-util": "^29.6.3",
-        "jest-snapshot": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-runner": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
-      "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/environment": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "emittery": "^0.13.1",
-        "graceful-fs": "^4.2.9",
-        "jest-docblock": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-haste-map": "^29.7.0",
-        "jest-leak-detector": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-resolve": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-watcher": "^29.7.0",
-        "jest-worker": "^29.7.0",
-        "p-limit": "^3.1.0",
-        "source-map-support": "0.5.13"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-runtime": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
-      "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/globals": "^29.7.0",
-        "@jest/source-map": "^29.6.3",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "cjs-module-lexer": "^1.0.0",
-        "collect-v8-coverage": "^1.0.0",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-bom": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-snapshot": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
-      "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@babel/generator": "^7.7.2",
-        "@babel/plugin-syntax-jsx": "^7.7.2",
-        "@babel/plugin-syntax-typescript": "^7.7.2",
-        "@babel/types": "^7.3.3",
-        "@jest/expect-utils": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-preset-current-node-syntax": "^1.0.0",
-        "chalk": "^4.0.0",
-        "expect": "^29.7.0",
-        "graceful-fs": "^4.2.9",
-        "jest-diff": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "natural-compare": "^1.4.0",
-        "pretty-format": "^29.7.0",
-        "semver": "^7.5.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-snapshot/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-validate": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
-      "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "camelcase": "^6.2.0",
-        "chalk": "^4.0.0",
-        "jest-get-type": "^29.6.3",
-        "leven": "^3.1.0",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-watcher": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
-      "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "ansi-escapes": "^4.2.1",
-        "chalk": "^4.0.0",
-        "emittery": "^0.13.1",
-        "jest-util": "^29.7.0",
-        "string-length": "^4.0.1"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/jest-worker": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz",
-      "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "jest-util": "^29.7.0",
-        "merge-stream": "^2.0.0",
-        "supports-color": "^8.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/pure-rand": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
-      "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/dubzzz"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fast-check"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/@jspsych/config/node_modules/signal-exit": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/@jspsych/config/node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/wrap-ansi": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.0.0",
-        "string-width": "^4.1.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/write-file-atomic": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz",
-      "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "imurmurhash": "^0.1.4",
-        "signal-exit": "^3.0.7"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/yargs": {
-      "version": "17.7.2",
-      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
-      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "cliui": "^8.0.1",
-        "escalade": "^3.1.1",
-        "get-caller-file": "^2.0.5",
-        "require-directory": "^2.1.1",
-        "string-width": "^4.2.3",
-        "y18n": "^5.0.5",
-        "yargs-parser": "^21.1.1"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@jspsych/config/node_modules/yargs-parser": {
-      "version": "21.1.1",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
-      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@jspsych/test-utils": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@jspsych/test-utils/-/test-utils-1.2.0.tgz",
-      "integrity": "sha512-3j0n0k6/DLSIz91ngqV/SXfXONnpaBC/McCrXysq6kMS08utzTEX56xKNzEmfY37bQKCijJsBreF6oH9Zss60w==",
-      "dev": true,
-      "license": "MIT",
-      "peerDependencies": {
-        "@types/jest": "*",
-        "jspsych": ">=7.0.0"
-      }
-    },
-    "node_modules/@napi-rs/wasm-runtime": {
-      "version": "0.2.12",
-      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
-      "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@emnapi/core": "^1.4.3",
-        "@emnapi/runtime": "^1.4.3",
-        "@tybys/wasm-util": "^0.10.0"
-      }
-    },
-    "node_modules/@pkgjs/parseargs": {
-      "version": "0.11.0",
-      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
-      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "engines": {
-        "node": ">=14"
-      }
-    },
-    "node_modules/@pkgr/core": {
-      "version": "0.2.7",
-      "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz",
-      "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/pkgr"
-      }
-    },
-    "node_modules/@rollup/plugin-commonjs": {
-      "version": "26.0.1",
-      "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.1.tgz",
-      "integrity": "sha512-UnsKoZK6/aGIH6AdkptXhNvhaqftcjq3zZdT+LY5Ftms6JR06nADcDsYp5hTU9E2lbJUEOhdlY5J4DNTneM+jQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@rollup/pluginutils": "^5.0.1",
-        "commondir": "^1.0.1",
-        "estree-walker": "^2.0.2",
-        "glob": "^10.4.1",
-        "is-reference": "1.2.1",
-        "magic-string": "^0.30.3"
-      },
-      "engines": {
-        "node": ">=16.0.0 || 14 >= 14.17"
-      },
-      "peerDependencies": {
-        "rollup": "^2.68.0||^3.0.0||^4.0.0"
-      },
-      "peerDependenciesMeta": {
-        "rollup": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/@rollup/plugin-commonjs/node_modules/glob": {
-      "version": "10.4.5",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
-      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "foreground-child": "^3.1.0",
-        "jackspeak": "^3.1.2",
-        "minimatch": "^9.0.4",
-        "minipass": "^7.1.2",
-        "package-json-from-dist": "^1.0.0",
-        "path-scurry": "^1.11.1"
-      },
-      "bin": {
-        "glob": "dist/esm/bin.mjs"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/@rollup/plugin-node-resolve": {
-      "version": "15.2.3",
-      "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz",
-      "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@rollup/pluginutils": "^5.0.1",
-        "@types/resolve": "1.20.2",
-        "deepmerge": "^4.2.2",
-        "is-builtin-module": "^3.2.1",
-        "is-module": "^1.0.0",
-        "resolve": "^1.22.1"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      },
-      "peerDependencies": {
-        "rollup": "^2.78.0||^3.0.0||^4.0.0"
-      },
-      "peerDependenciesMeta": {
-        "rollup": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@rollup/plugin-replace": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz",
-      "integrity": "sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@rollup/pluginutils": "^5.0.1",
-        "magic-string": "^0.30.3"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      },
-      "peerDependencies": {
-        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
-      },
-      "peerDependenciesMeta": {
-        "rollup": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@rollup/pluginutils": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz",
-      "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/estree": "^1.0.0",
-        "estree-walker": "^2.0.2",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      },
-      "peerDependencies": {
-        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
-      },
-      "peerDependenciesMeta": {
-        "rollup": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@rollup/rollup-android-arm-eabi": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
-      "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ]
-    },
-    "node_modules/@rollup/rollup-android-arm64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
-      "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ]
-    },
-    "node_modules/@rollup/rollup-darwin-arm64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
-      "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ]
-    },
-    "node_modules/@rollup/rollup-darwin-x64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
-      "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ]
-    },
-    "node_modules/@rollup/rollup-freebsd-arm64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
-      "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ]
-    },
-    "node_modules/@rollup/rollup-freebsd-x64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
-      "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
-      "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
-      "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm64-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
-      "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm64-musl": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
-      "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-loong64-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
-      "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-loong64-musl": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
-      "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
-      "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-ppc64-musl": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
-      "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
-      "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-riscv64-musl": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
-      "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-s390x-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
-      "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-x64-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
-      "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-x64-musl": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
-      "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-openbsd-x64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
-      "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ]
-    },
-    "node_modules/@rollup/rollup-openharmony-arm64": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
-      "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-arm64-msvc": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
-      "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-ia32-msvc": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
-      "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-x64-gnu": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
-      "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-x64-msvc": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
-      "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@sinclair/typebox": {
-      "version": "0.34.37",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz",
-      "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/@sinonjs/commons": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
-      "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "type-detect": "4.0.8"
-      }
-    },
-    "node_modules/@sinonjs/fake-timers": {
-      "version": "13.0.5",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz",
-      "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "peer": true,
-      "dependencies": {
-        "@sinonjs/commons": "^3.0.1"
-      }
-    },
-    "node_modules/@sucrase/jest-plugin": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/@sucrase/jest-plugin/-/jest-plugin-3.0.0.tgz",
-      "integrity": "sha512-VRY6YKYImVWiRg1H3Yu24hwB1UPJDSDR62R/n+lOHR3+yDrfHEIAoddJivblMYN6U3vD+ndfTSrecZ9Jl+iGNw==",
-      "dev": true,
-      "license": "MIT",
-      "peerDependencies": {
-        "jest": ">=27",
-        "sucrase": ">=3.25.0"
-      }
-    },
-    "node_modules/@tootallnate/once": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
-      "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@tybys/wasm-util": {
-      "version": "0.10.0",
-      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
-      "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@types/babel__core": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
-      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/parser": "^7.20.7",
-        "@babel/types": "^7.20.7",
-        "@types/babel__generator": "*",
-        "@types/babel__template": "*",
-        "@types/babel__traverse": "*"
-      }
-    },
-    "node_modules/@types/babel__generator": {
-      "version": "7.27.0",
-      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
-      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.0.0"
-      }
-    },
-    "node_modules/@types/babel__template": {
-      "version": "7.4.4",
-      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
-      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/parser": "^7.1.0",
-        "@babel/types": "^7.0.0"
-      }
-    },
-    "node_modules/@types/babel__traverse": {
-      "version": "7.20.7",
-      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
-      "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.20.7"
-      }
-    },
-    "node_modules/@types/estree": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
-      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/expect": {
-      "version": "1.20.4",
-      "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz",
-      "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/glob-stream": {
-      "version": "8.0.3",
-      "resolved": "https://registry.npmjs.org/@types/glob-stream/-/glob-stream-8.0.3.tgz",
-      "integrity": "sha512-vctgrT9AH/GK3TRaIbRUU0TZn12GBU4kzelZdPyJp1Sc8L/6Wrq21UrtN4+x4saqTg6COUIUtFV6JSYcVln/EQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "@types/picomatch": "*",
-        "@types/streamx": "*"
-      }
-    },
-    "node_modules/@types/graceful-fs": {
-      "version": "4.1.9",
-      "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
-      "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*"
-      }
-    },
-    "node_modules/@types/gulp": {
-      "version": "4.0.17",
-      "resolved": "https://registry.npmjs.org/@types/gulp/-/gulp-4.0.17.tgz",
-      "integrity": "sha512-+pKQynu2C/HS16kgmDlAicjtFYP8kaa86eE9P0Ae7GB5W29we/E2TIdbOWtEZD5XkpY+jr8fyqfwO6SWZecLpQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "@types/undertaker": ">=1.2.6",
-        "@types/vinyl-fs": "*",
-        "chokidar": "^3.3.1"
-      }
-    },
-    "node_modules/@types/istanbul-lib-coverage": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
-      "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/istanbul-lib-report": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
-      "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/istanbul-lib-coverage": "*"
-      }
-    },
-    "node_modules/@types/istanbul-reports": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
-      "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/istanbul-lib-report": "*"
-      }
-    },
-    "node_modules/@types/jest": {
-      "version": "29.5.14",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
-      "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expect": "^29.0.0",
-        "pretty-format": "^29.0.0"
-      }
-    },
-    "node_modules/@types/jsdom": {
-      "version": "20.0.1",
-      "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
-      "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "@types/tough-cookie": "*",
-        "parse5": "^7.0.0"
-      }
-    },
-    "node_modules/@types/mute-stream": {
-      "version": "0.0.4",
-      "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz",
-      "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*"
-      }
-    },
-    "node_modules/@types/node": {
-      "version": "22.16.3",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz",
-      "integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "undici-types": "~6.21.0"
-      }
-    },
-    "node_modules/@types/picomatch": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.0.tgz",
-      "integrity": "sha512-J1Bng+wlyEERWSgJQU1Pi0HObCLVcr994xT/M+1wcl/yNRTGBupsCxthgkdYG+GCOMaQH7iSVUY3LJVBBqG7MQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/resolve": {
-      "version": "1.20.2",
-      "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
-      "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/stack-utils": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
-      "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/streamx": {
-      "version": "2.9.5",
-      "resolved": "https://registry.npmjs.org/@types/streamx/-/streamx-2.9.5.tgz",
-      "integrity": "sha512-IHYsa6jYrck8VEdSwpY141FTTf6D7boPeMq9jy4qazNrFMA4VbRz/sw5LSsfR7jwdDcx0QKWkUexZvsWBC2eIQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*"
-      }
-    },
-    "node_modules/@types/tough-cookie": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
-      "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/undertaker": {
-      "version": "1.2.11",
-      "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.11.tgz",
-      "integrity": "sha512-j1Z0V2ByRHr8ZK7eOeGq0LGkkdthNFW0uAZGY22iRkNQNL9/vAV0yFPr1QN3FM/peY5bxs9P+1f0PYJTQVa5iA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "@types/undertaker-registry": "*",
-        "async-done": "~1.3.2"
-      }
-    },
-    "node_modules/@types/undertaker-registry": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/@types/undertaker-registry/-/undertaker-registry-1.0.4.tgz",
-      "integrity": "sha512-tW77pHh2TU4uebWXWeEM5laiw8BuJ7pyJYDh6xenOs75nhny2kVgwYbegJ4BoLMYsIrXaBpKYaPdYO3/udG+hg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/vinyl": {
-      "version": "2.0.12",
-      "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.12.tgz",
-      "integrity": "sha512-Sr2fYMBUVGYq8kj3UthXFAu5UN6ZW+rYr4NACjZQJvHvj+c8lYv0CahmZ2P/r7iUkN44gGUBwqxZkrKXYPb7cw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/expect": "^1.20.4",
-        "@types/node": "*"
-      }
-    },
-    "node_modules/@types/vinyl-fs": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/@types/vinyl-fs/-/vinyl-fs-3.0.6.tgz",
-      "integrity": "sha512-e9GHnmABNUnJ4D99OjVO5s87TfYpmEs7/VKbVS/rt0KkZnKA2vIMyEC5K0H7W/XBiRUO4pdaZxvVUmzjRnrydA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/glob-stream": "*",
-        "@types/node": "*",
-        "@types/vinyl": "*"
-      }
-    },
-    "node_modules/@types/wrap-ansi": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz",
-      "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/yargs": {
-      "version": "17.0.33",
-      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
-      "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/yargs-parser": "*"
-      }
-    },
-    "node_modules/@types/yargs-parser": {
-      "version": "21.0.3",
-      "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
-      "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@ungap/structured-clone": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
-      "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-android-arm-eabi": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
-      "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-android-arm64": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
-      "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-darwin-arm64": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
-      "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-darwin-x64": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
-      "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-freebsd-x64": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
-      "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
-      "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
-      "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
-      "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
-      "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
-      "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
-      "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
-      "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
-      "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
-      "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-linux-x64-musl": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
-      "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-wasm32-wasi": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
-      "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
-      "cpu": [
-        "wasm32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@napi-rs/wasm-runtime": "^0.2.11"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
-      "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
-      "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true
-    },
-    "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
-      "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true
-    },
-    "node_modules/abab": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
-      "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
-      "deprecated": "Use your platform's native atob() and btoa() methods instead",
-      "dev": true,
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/acorn": {
-      "version": "8.15.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
-      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "acorn": "bin/acorn"
-      },
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/acorn-globals": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
-      "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "acorn": "^8.1.0",
-        "acorn-walk": "^8.0.2"
-      }
-    },
-    "node_modules/acorn-walk": {
-      "version": "8.3.4",
-      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
-      "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "acorn": "^8.11.0"
-      },
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/agent-base": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
-      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "debug": "4"
-      },
-      "engines": {
-        "node": ">= 6.0.0"
-      }
-    },
-    "node_modules/alias-hq": {
-      "version": "6.2.4",
-      "resolved": "https://registry.npmjs.org/alias-hq/-/alias-hq-6.2.4.tgz",
-      "integrity": "sha512-6KGuO4XB3PbvTfP+WJEJR2dGMy6h0UyLa2/kZOeeD/UIrYoaUAQwKdLovYyCpgZErYD1d3zIuZh6GPMDADvF4g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "colors": "^1.4.0",
-        "get-tsconfig": "^4.8.0",
-        "glob": "^7.2.3",
-        "inquirer": "^10.1.6",
-        "jscodeshift": "^0.16.1",
-        "json5": "^2.2.3",
-        "module-alias": "^2.2.3",
-        "node-fetch": "^2.7.0",
-        "open": "^7.4.2",
-        "vue-jscodeshift-adapter": "^2.2.1"
-      },
-      "bin": {
-        "alias-hq": "bin/alias-hq"
-      }
-    },
-    "node_modules/ansi-escapes": {
-      "version": "4.3.2",
-      "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
-      "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "type-fest": "^0.21.3"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/ansi-regex": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
-      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/any-promise": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
-      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/anymatch": {
-      "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
-      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "normalize-path": "^3.0.0",
-        "picomatch": "^2.0.4"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
-    "node_modules/anymatch/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/app-root-path": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
-      "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 6.0.0"
-      }
-    },
-    "node_modules/argparse": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
-      "dev": true,
-      "license": "Python-2.0"
-    },
-    "node_modules/array-each": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
-      "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/array-slice": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
-      "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/ast-types": {
-      "version": "0.16.1",
-      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
-      "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "tslib": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/async": {
-      "version": "3.2.6",
-      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
-      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/async-done": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz",
-      "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "end-of-stream": "^1.1.0",
-        "once": "^1.3.2",
-        "process-nextick-args": "^2.0.0",
-        "stream-exhaust": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 0.10"
-      }
-    },
-    "node_modules/async-settle": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-2.0.0.tgz",
-      "integrity": "sha512-Obu/KE8FurfQRN6ODdHN9LuXqwC+JFIM9NRyZqJJ4ZfLJmIYN9Rg0/kb+wF70VV5+fJusTMQlJ1t5rF7J/ETdg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "async-done": "^2.0.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/async-settle/node_modules/async-done": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz",
-      "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "end-of-stream": "^1.4.4",
-        "once": "^1.4.0",
-        "stream-exhaust": "^1.0.2"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/auto-bind": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz",
-      "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==",
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/b4a": {
-      "version": "1.6.7",
-      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
-      "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
-      "dev": true,
-      "license": "Apache-2.0"
-    },
-    "node_modules/babel-jest": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.4.tgz",
-      "integrity": "sha512-UjG2j7sAOqsp2Xua1mS/e+ekddkSu3wpf4nZUSvXNHuVWdaOUXQ77+uyjJLDE9i0atm5x4kds8K9yb5lRsRtcA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/transform": "30.0.4",
-        "@types/babel__core": "^7.20.5",
-        "babel-plugin-istanbul": "^7.0.0",
-        "babel-preset-jest": "30.0.1",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.11.0"
-      }
-    },
-    "node_modules/babel-plugin-istanbul": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz",
-      "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "peer": true,
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.0.0",
-        "@istanbuljs/load-nyc-config": "^1.0.0",
-        "@istanbuljs/schema": "^0.1.3",
-        "istanbul-lib-instrument": "^6.0.2",
-        "test-exclude": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/babel-plugin-jest-hoist": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz",
-      "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/template": "^7.27.2",
-        "@babel/types": "^7.27.3",
-        "@types/babel__core": "^7.20.5"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/babel-preset-current-node-syntax": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz",
-      "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/plugin-syntax-async-generators": "^7.8.4",
-        "@babel/plugin-syntax-bigint": "^7.8.3",
-        "@babel/plugin-syntax-class-properties": "^7.12.13",
-        "@babel/plugin-syntax-class-static-block": "^7.14.5",
-        "@babel/plugin-syntax-import-attributes": "^7.24.7",
-        "@babel/plugin-syntax-import-meta": "^7.10.4",
-        "@babel/plugin-syntax-json-strings": "^7.8.3",
-        "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
-        "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
-        "@babel/plugin-syntax-numeric-separator": "^7.10.4",
-        "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
-        "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
-        "@babel/plugin-syntax-optional-chaining": "^7.8.3",
-        "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
-        "@babel/plugin-syntax-top-level-await": "^7.14.5"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/babel-preset-jest": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz",
-      "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "babel-plugin-jest-hoist": "30.0.1",
-        "babel-preset-current-node-syntax": "^1.1.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.11.0"
-      }
-    },
-    "node_modules/bach": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/bach/-/bach-2.0.1.tgz",
-      "integrity": "sha512-A7bvGMGiTOxGMpNupYl9HQTf0FFDNF4VCmks4PJpFyN1AX2pdKuxuwdvUz2Hu388wcgp+OvGFNsumBfFNkR7eg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "async-done": "^2.0.0",
-        "async-settle": "^2.0.0",
-        "now-and-later": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/bach/node_modules/async-done": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz",
-      "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "end-of-stream": "^1.4.4",
-        "once": "^1.4.0",
-        "stream-exhaust": "^1.0.2"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/balanced-match": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
-      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/bare-events": {
-      "version": "2.6.0",
-      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz",
-      "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "optional": true
-    },
-    "node_modules/base64-js": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
-      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/binary-extensions": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
-      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/binaryextensions": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-2.3.0.tgz",
-      "integrity": "sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.8"
-      },
-      "funding": {
-        "url": "https://bevry.me/fund"
-      }
-    },
-    "node_modules/bl": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
-      "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "buffer": "^6.0.3",
-        "inherits": "^2.0.4",
-        "readable-stream": "^3.4.0"
-      }
-    },
-    "node_modules/bl/node_modules/buffer": {
-      "version": "6.0.3",
-      "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
-      "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "base64-js": "^1.3.1",
-        "ieee754": "^1.2.1"
-      }
-    },
-    "node_modules/bl/node_modules/readable-stream": {
-      "version": "3.6.2",
-      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "inherits": "^2.0.3",
-        "string_decoder": "^1.1.1",
-        "util-deprecate": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/brace-expansion": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0",
-        "concat-map": "0.0.1"
-      }
-    },
-    "node_modules/braces": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
-      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "fill-range": "^7.1.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/browserslist": {
-      "version": "4.25.1",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
-      "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/browserslist"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "caniuse-lite": "^1.0.30001726",
-        "electron-to-chromium": "^1.5.173",
-        "node-releases": "^2.0.19",
-        "update-browserslist-db": "^1.1.3"
-      },
-      "bin": {
-        "browserslist": "cli.js"
-      },
-      "engines": {
-        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
-      }
-    },
-    "node_modules/bs-logger": {
-      "version": "0.2.6",
-      "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
-      "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "fast-json-stable-stringify": "2.x"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/bser": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
-      "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "node-int64": "^0.4.0"
-      }
-    },
-    "node_modules/buffer": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
-      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "base64-js": "^1.3.1",
-        "ieee754": "^1.1.13"
-      }
-    },
-    "node_modules/buffer-crc32": {
-      "version": "0.2.13",
-      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
-      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "*"
-      }
-    },
-    "node_modules/buffer-from": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
-      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/builtin-modules": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
-      "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/call-bind-apply-helpers": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
-      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/callsites": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
-      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/camelcase": {
-      "version": "5.3.1",
-      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
-      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/caniuse-lite": {
-      "version": "1.0.30001727",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
-      "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "CC-BY-4.0"
-    },
-    "node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/char-regex": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
-      "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/chardet": {
-      "version": "0.7.0",
-      "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
-      "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/chokidar": {
-      "version": "3.6.0",
-      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
-      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "anymatch": "~3.1.2",
-        "braces": "~3.0.2",
-        "glob-parent": "~5.1.2",
-        "is-binary-path": "~2.1.0",
-        "is-glob": "~4.0.1",
-        "normalize-path": "~3.0.0",
-        "readdirp": "~3.6.0"
-      },
-      "engines": {
-        "node": ">= 8.10.0"
-      },
-      "funding": {
-        "url": "https://paulmillr.com/funding/"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.2"
-      }
-    },
-    "node_modules/ci-info": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
-      "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/sibiraj-s"
-        }
-      ],
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/citeproc": {
-      "version": "2.4.63",
-      "resolved": "https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz",
-      "integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q==",
-      "dev": true,
-      "license": "CPAL-1.0 OR AGPL-1.0"
-    },
-    "node_modules/cjs-module-lexer": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz",
-      "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/cli-width": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
-      "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">= 12"
-      }
-    },
-    "node_modules/cliui": {
-      "version": "7.0.4",
-      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
-      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "string-width": "^4.2.0",
-        "strip-ansi": "^6.0.0",
-        "wrap-ansi": "^7.0.0"
-      }
-    },
-    "node_modules/cliui/node_modules/wrap-ansi": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.0.0",
-        "string-width": "^4.1.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-      }
-    },
-    "node_modules/clone": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
-      "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
-    "node_modules/clone-buffer": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz",
-      "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.10"
-      }
-    },
-    "node_modules/clone-deep": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
-      "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-plain-object": "^2.0.4",
-        "kind-of": "^6.0.2",
-        "shallow-clone": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/clone-deep/node_modules/is-plain-object": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
-      "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "isobject": "^3.0.1"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/clone-stats": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz",
-      "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/cloneable-readable": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz",
-      "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "inherits": "^2.0.1",
-        "process-nextick-args": "^2.0.0",
-        "readable-stream": "^2.3.5"
-      }
-    },
-    "node_modules/co": {
-      "version": "4.6.0",
-      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
-      "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "iojs": ">= 1.0.0",
-        "node": ">= 0.12.0"
-      }
-    },
-    "node_modules/collect-v8-coverage": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
-      "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/color-convert": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-name": "~1.1.4"
-      },
-      "engines": {
-        "node": ">=7.0.0"
-      }
-    },
-    "node_modules/color-name": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/colors": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
-      "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.1.90"
-      }
-    },
-    "node_modules/combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "delayed-stream": "~1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/commander": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
-      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/commondir": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
-      "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/concat-map": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/convert-source-map": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
-      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/copy-props": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz",
-      "integrity": "sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "each-props": "^3.0.0",
-        "is-plain-object": "^5.0.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/core-util-is": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
-      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/create-jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
-      "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "exit": "^0.1.2",
-        "graceful-fs": "^4.2.9",
-        "jest-config": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "prompts": "^2.0.1"
-      },
-      "bin": {
-        "create-jest": "bin/create-jest.js"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/console": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
-      "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/environment": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
-      "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-mock": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/expect": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
-      "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expect": "^29.7.0",
-        "jest-snapshot": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/fake-timers": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
-      "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@sinonjs/fake-timers": "^10.0.2",
-        "@types/node": "*",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/globals": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
-      "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/expect": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "jest-mock": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/source-map": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
-      "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "callsites": "^3.0.0",
-        "graceful-fs": "^4.2.9"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/test-result": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
-      "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "collect-v8-coverage": "^1.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/test-sequencer": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
-      "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/test-result": "^29.7.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/transform": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
-      "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/types": "^29.6.3",
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "babel-plugin-istanbul": "^6.1.1",
-        "chalk": "^4.0.0",
-        "convert-source-map": "^2.0.0",
-        "fast-json-stable-stringify": "^2.1.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "pirates": "^4.0.4",
-        "slash": "^3.0.0",
-        "write-file-atomic": "^4.0.2"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@jest/types": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-      "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "@types/istanbul-reports": "^3.0.0",
-        "@types/node": "*",
-        "@types/yargs": "^17.0.8",
-        "chalk": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/create-jest/node_modules/@sinonjs/fake-timers": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
-      "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@sinonjs/commons": "^3.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/babel-jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
-      "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/transform": "^29.7.0",
-        "@types/babel__core": "^7.1.14",
-        "babel-plugin-istanbul": "^6.1.1",
-        "babel-preset-jest": "^29.6.3",
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.8.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/babel-plugin-istanbul": {
-      "version": "6.1.1",
-      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
-      "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.0.0",
-        "@istanbuljs/load-nyc-config": "^1.0.0",
-        "@istanbuljs/schema": "^0.1.2",
-        "istanbul-lib-instrument": "^5.0.4",
-        "test-exclude": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/create-jest/node_modules/babel-plugin-jest-hoist": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz",
-      "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/template": "^7.3.3",
-        "@babel/types": "^7.3.3",
-        "@types/babel__core": "^7.1.14",
-        "@types/babel__traverse": "^7.0.6"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/babel-preset-jest": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
-      "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "babel-plugin-jest-hoist": "^29.6.3",
-        "babel-preset-current-node-syntax": "^1.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/camelcase": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
-      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/create-jest/node_modules/ci-info": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
-      "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/sibiraj-s"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/create-jest/node_modules/cjs-module-lexer": {
-      "version": "1.4.3",
-      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
-      "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/create-jest/node_modules/istanbul-lib-instrument": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
-      "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@babel/core": "^7.12.3",
-        "@babel/parser": "^7.14.7",
-        "@istanbuljs/schema": "^0.1.2",
-        "istanbul-lib-coverage": "^3.2.0",
-        "semver": "^6.3.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-circus": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
-      "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/expect": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "co": "^4.6.0",
-        "dedent": "^1.0.0",
-        "is-generator-fn": "^2.0.0",
-        "jest-each": "^29.7.0",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "p-limit": "^3.1.0",
-        "pretty-format": "^29.7.0",
-        "pure-rand": "^6.0.0",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-config": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
-      "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/test-sequencer": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-jest": "^29.7.0",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "deepmerge": "^4.2.2",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-circus": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-runner": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "parse-json": "^5.2.0",
-        "pretty-format": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-json-comments": "^3.1.1"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "@types/node": "*",
-        "ts-node": ">=9.0.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "ts-node": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-docblock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
-      "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "detect-newline": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-each": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
-      "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "jest-get-type": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-environment-node": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
-      "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-haste-map": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz",
-      "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/graceful-fs": "^4.1.3",
-        "@types/node": "*",
-        "anymatch": "^3.0.3",
-        "fb-watchman": "^2.0.0",
-        "graceful-fs": "^4.2.9",
-        "jest-regex-util": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "jest-worker": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "walker": "^1.0.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "^2.3.2"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-leak-detector": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
-      "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-mock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
-      "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-regex-util": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
-      "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-resolve": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
-      "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-pnp-resolver": "^1.2.2",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "resolve": "^1.20.0",
-        "resolve.exports": "^2.0.0",
-        "slash": "^3.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-runner": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
-      "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/environment": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "emittery": "^0.13.1",
-        "graceful-fs": "^4.2.9",
-        "jest-docblock": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-haste-map": "^29.7.0",
-        "jest-leak-detector": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-resolve": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-watcher": "^29.7.0",
-        "jest-worker": "^29.7.0",
-        "p-limit": "^3.1.0",
-        "source-map-support": "0.5.13"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-runtime": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
-      "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/globals": "^29.7.0",
-        "@jest/source-map": "^29.6.3",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "cjs-module-lexer": "^1.0.0",
-        "collect-v8-coverage": "^1.0.0",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-bom": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-snapshot": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
-      "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@babel/generator": "^7.7.2",
-        "@babel/plugin-syntax-jsx": "^7.7.2",
-        "@babel/plugin-syntax-typescript": "^7.7.2",
-        "@babel/types": "^7.3.3",
-        "@jest/expect-utils": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-preset-current-node-syntax": "^1.0.0",
-        "chalk": "^4.0.0",
-        "expect": "^29.7.0",
-        "graceful-fs": "^4.2.9",
-        "jest-diff": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "natural-compare": "^1.4.0",
-        "pretty-format": "^29.7.0",
-        "semver": "^7.5.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-snapshot/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-validate": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
-      "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "camelcase": "^6.2.0",
-        "chalk": "^4.0.0",
-        "jest-get-type": "^29.6.3",
-        "leven": "^3.1.0",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-watcher": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
-      "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "ansi-escapes": "^4.2.1",
-        "chalk": "^4.0.0",
-        "emittery": "^0.13.1",
-        "jest-util": "^29.7.0",
-        "string-length": "^4.0.1"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/jest-worker": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz",
-      "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "jest-util": "^29.7.0",
-        "merge-stream": "^2.0.0",
-        "supports-color": "^8.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/create-jest/node_modules/pure-rand": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
-      "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/dubzzz"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fast-check"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/create-jest/node_modules/signal-exit": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/create-jest/node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
-    "node_modules/create-jest/node_modules/write-file-atomic": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz",
-      "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "imurmurhash": "^0.1.4",
-        "signal-exit": "^3.0.7"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
-      }
-    },
-    "node_modules/cross-spawn": {
-      "version": "7.0.6",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
-      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "path-key": "^3.1.0",
-        "shebang-command": "^2.0.0",
-        "which": "^2.0.1"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
-    "node_modules/cssfontparser": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz",
-      "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/cssom": {
-      "version": "0.5.0",
-      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
-      "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/cssstyle": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
-      "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "cssom": "~0.3.6"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/cssstyle/node_modules/cssom": {
-      "version": "0.3.8",
-      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
-      "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/data-urls": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
-      "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "abab": "^2.0.6",
-        "whatwg-mimetype": "^3.0.0",
-        "whatwg-url": "^11.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/de-indent": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
-      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/debug": {
-      "version": "4.4.1",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
-      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ms": "^2.1.3"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "peerDependenciesMeta": {
-        "supports-color": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/decimal.js": {
-      "version": "10.6.0",
-      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
-      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/dedent": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
-      "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==",
-      "dev": true,
-      "license": "MIT",
-      "peerDependencies": {
-        "babel-plugin-macros": "^3.1.0"
-      },
-      "peerDependenciesMeta": {
-        "babel-plugin-macros": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/deepmerge": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
-      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/detect-file": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
-      "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/detect-newline": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
-      "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/diff-sequences": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
-      "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/domexception": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
-      "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
-      "deprecated": "Use your platform's native DOMException instead",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "webidl-conversions": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/dunder-proto": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
-      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.1",
-        "es-errors": "^1.3.0",
-        "gopd": "^1.2.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/each-props": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/each-props/-/each-props-3.0.0.tgz",
-      "integrity": "sha512-IYf1hpuWrdzse/s/YJOrFmU15lyhSzxelNVAHTEG3DtP4QsLTWZUzcUL3HMXmKQxXpa4EIrBPpwRgj0aehdvAw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-plain-object": "^5.0.0",
-        "object.defaults": "^1.1.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/eastasianwidth": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
-      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/easy-transform-stream": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/easy-transform-stream/-/easy-transform-stream-1.0.1.tgz",
-      "integrity": "sha512-ktkaa6XR7COAR3oj02CF3IOgz2m1hCaY3SfzvKT4Svt2MhHw9XCt+ncJNWfe2TGz31iqzNGZ8spdKQflj+Rlog==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/ejs": {
-      "version": "3.1.10",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
-      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "jake": "^10.8.5"
-      },
-      "bin": {
-        "ejs": "bin/cli.js"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/electron-to-chromium": {
-      "version": "1.5.183",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz",
-      "integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/emittery": {
-      "version": "0.13.1",
-      "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
-      "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sindresorhus/emittery?sponsor=1"
-      }
-    },
-    "node_modules/emoji-regex": {
-      "version": "8.0.0",
-      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/end-of-stream": {
-      "version": "1.4.5",
-      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
-      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "once": "^1.4.0"
-      }
-    },
-    "node_modules/entities": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
-      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=0.12"
-      },
-      "funding": {
-        "url": "https://github.com/fb55/entities?sponsor=1"
-      }
-    },
-    "node_modules/error-ex": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
-      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-arrayish": "^0.2.1"
-      }
-    },
-    "node_modules/es-define-property": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
-      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-errors": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
-      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-module-lexer": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
-      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/es-object-atoms": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
-      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-set-tostringtag": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
-      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.6",
-        "has-tostringtag": "^1.0.2",
-        "hasown": "^2.0.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/esbuild": {
-      "version": "0.23.1",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
-      "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.23.1",
-        "@esbuild/android-arm": "0.23.1",
-        "@esbuild/android-arm64": "0.23.1",
-        "@esbuild/android-x64": "0.23.1",
-        "@esbuild/darwin-arm64": "0.23.1",
-        "@esbuild/darwin-x64": "0.23.1",
-        "@esbuild/freebsd-arm64": "0.23.1",
-        "@esbuild/freebsd-x64": "0.23.1",
-        "@esbuild/linux-arm": "0.23.1",
-        "@esbuild/linux-arm64": "0.23.1",
-        "@esbuild/linux-ia32": "0.23.1",
-        "@esbuild/linux-loong64": "0.23.1",
-        "@esbuild/linux-mips64el": "0.23.1",
-        "@esbuild/linux-ppc64": "0.23.1",
-        "@esbuild/linux-riscv64": "0.23.1",
-        "@esbuild/linux-s390x": "0.23.1",
-        "@esbuild/linux-x64": "0.23.1",
-        "@esbuild/netbsd-x64": "0.23.1",
-        "@esbuild/openbsd-arm64": "0.23.1",
-        "@esbuild/openbsd-x64": "0.23.1",
-        "@esbuild/sunos-x64": "0.23.1",
-        "@esbuild/win32-arm64": "0.23.1",
-        "@esbuild/win32-ia32": "0.23.1",
-        "@esbuild/win32-x64": "0.23.1"
-      }
-    },
-    "node_modules/escalade": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
-      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/escape-string-regexp": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.8.0"
-      }
-    },
-    "node_modules/escodegen": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
-      "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "dependencies": {
-        "esprima": "^4.0.1",
-        "estraverse": "^5.2.0",
-        "esutils": "^2.0.2"
-      },
-      "bin": {
-        "escodegen": "bin/escodegen.js",
-        "esgenerate": "bin/esgenerate.js"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "optionalDependencies": {
-        "source-map": "~0.6.1"
-      }
-    },
-    "node_modules/esprima": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
-      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "bin": {
-        "esparse": "bin/esparse.js",
-        "esvalidate": "bin/esvalidate.js"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/estraverse": {
-      "version": "5.3.0",
-      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
-      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=4.0"
-      }
-    },
-    "node_modules/estree-walker": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
-      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/esutils": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
-      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/execa": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
-      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "cross-spawn": "^7.0.3",
-        "get-stream": "^6.0.0",
-        "human-signals": "^2.1.0",
-        "is-stream": "^2.0.0",
-        "merge-stream": "^2.0.0",
-        "npm-run-path": "^4.0.1",
-        "onetime": "^5.1.2",
-        "signal-exit": "^3.0.3",
-        "strip-final-newline": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sindresorhus/execa?sponsor=1"
-      }
-    },
-    "node_modules/execa/node_modules/get-stream": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
-      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/execa/node_modules/signal-exit": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/exit": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
-      "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
-      "dev": true,
-      "engines": {
-        "node": ">= 0.8.0"
-      }
-    },
-    "node_modules/exit-x": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz",
-      "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">= 0.8.0"
-      }
-    },
-    "node_modules/expand-tilde": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
-      "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "homedir-polyfill": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/expect": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
-      "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/expect-utils": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/extend": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
-      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/external-editor": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
-      "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "chardet": "^0.7.0",
-        "iconv-lite": "^0.4.24",
-        "tmp": "^0.0.33"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/fast-fifo": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
-      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/fast-json-stable-stringify": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
-      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/fast-levenshtein": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz",
-      "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "fastest-levenshtein": "^1.0.7"
-      }
-    },
-    "node_modules/fastest-levenshtein": {
-      "version": "1.0.16",
-      "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
-      "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 4.9.1"
-      }
-    },
-    "node_modules/fastq": {
-      "version": "1.19.1",
-      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
-      "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "reusify": "^1.0.4"
-      }
-    },
-    "node_modules/fb-watchman": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
-      "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "bser": "2.1.1"
-      }
-    },
-    "node_modules/fetch-ponyfill": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz",
-      "integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "node-fetch": "~2.6.1"
-      }
-    },
-    "node_modules/fetch-ponyfill/node_modules/node-fetch": {
-      "version": "2.6.13",
-      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
-      "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "whatwg-url": "^5.0.0"
-      },
-      "engines": {
-        "node": "4.x || >=6.0.0"
-      },
-      "peerDependencies": {
-        "encoding": "^0.1.0"
-      },
-      "peerDependenciesMeta": {
-        "encoding": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/fetch-ponyfill/node_modules/tr46": {
-      "version": "0.0.3",
-      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
-      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/fetch-ponyfill/node_modules/webidl-conversions": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
-      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
-      "dev": true,
-      "license": "BSD-2-Clause"
-    },
-    "node_modules/fetch-ponyfill/node_modules/whatwg-url": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
-      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "tr46": "~0.0.3",
-        "webidl-conversions": "^3.0.0"
-      }
-    },
-    "node_modules/filelist": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
-      "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "minimatch": "^5.0.1"
-      }
-    },
-    "node_modules/filelist/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/filelist/node_modules/minimatch": {
-      "version": "5.1.6",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
-      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/fill-range": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
-      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "to-regex-range": "^5.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/find-cache-dir": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
-      "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "commondir": "^1.0.1",
-        "make-dir": "^2.0.0",
-        "pkg-dir": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/find-up": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
-      "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "locate-path": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/locate-path": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
-      "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "p-locate": "^3.0.0",
-        "path-exists": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/make-dir": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
-      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "pify": "^4.0.1",
-        "semver": "^5.6.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/p-limit": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
-      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "p-try": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/p-locate": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
-      "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "p-limit": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/path-exists": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
-      "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/pkg-dir": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
-      "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "find-up": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/find-cache-dir/node_modules/semver": {
-      "version": "5.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
-      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver"
-      }
-    },
-    "node_modules/find-up": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
-      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "locate-path": "^5.0.0",
-        "path-exists": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/findup-sync": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz",
-      "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "detect-file": "^1.0.0",
-        "is-glob": "^4.0.3",
-        "micromatch": "^4.0.4",
-        "resolve-dir": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/fined": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/fined/-/fined-2.0.0.tgz",
-      "integrity": "sha512-OFRzsL6ZMHz5s0JrsEr+TpdGNCtrVtnuG3x1yzGNiQHT0yaDnXAj8V/lWcpJVrnoDpcwXcASxAZYbuXda2Y82A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expand-tilde": "^2.0.2",
-        "is-plain-object": "^5.0.0",
-        "object.defaults": "^1.1.0",
-        "object.pick": "^1.3.0",
-        "parse-filepath": "^1.0.2"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/flagged-respawn": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-2.0.0.tgz",
-      "integrity": "sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/flow-parser": {
-      "version": "0.275.0",
-      "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.275.0.tgz",
-      "integrity": "sha512-fHNwawoA2LM7FsxhU/1lTRGq9n6/Q8k861eHgN7GKtamYt9Qrxpg/ZSrev8o1WX7fQ2D3Gg3+uvYN15PmsG7Yw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/for-in": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
-      "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/for-own": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
-      "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "for-in": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/foreground-child": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
-      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "cross-spawn": "^7.0.6",
-        "signal-exit": "^4.0.1"
-      },
-      "engines": {
-        "node": ">=14"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/form-data": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
-      "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "es-set-tostringtag": "^2.1.0",
-        "hasown": "^2.0.2",
-        "mime-types": "^2.1.12"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/fs-mkdirp-stream": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-2.0.1.tgz",
-      "integrity": "sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "graceful-fs": "^4.2.8",
-        "streamx": "^2.12.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/fs.realpath": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
-    "node_modules/function-bind": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
-      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "dev": true,
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/gensync": {
-      "version": "1.0.0-beta.2",
-      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
-      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/get-caller-file": {
-      "version": "2.0.5",
-      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
-      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": "6.* || 8.* || >= 10.*"
-      }
-    },
-    "node_modules/get-intrinsic": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
-      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.2",
-        "es-define-property": "^1.0.1",
-        "es-errors": "^1.3.0",
-        "es-object-atoms": "^1.1.1",
-        "function-bind": "^1.1.2",
-        "get-proto": "^1.0.1",
-        "gopd": "^1.2.0",
-        "has-symbols": "^1.1.0",
-        "hasown": "^2.0.2",
-        "math-intrinsics": "^1.1.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/get-package-type": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
-      "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/get-proto": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
-      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "dunder-proto": "^1.0.1",
-        "es-object-atoms": "^1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/get-stream": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
-      "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=16"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/get-tsconfig": {
-      "version": "4.10.1",
-      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
-      "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "resolve-pkg-maps": "^1.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
-      }
-    },
-    "node_modules/glob": {
-      "version": "7.2.3",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
-      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
-      "deprecated": "Glob versions prior to v9 are no longer supported",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "fs.realpath": "^1.0.0",
-        "inflight": "^1.0.4",
-        "inherits": "2",
-        "minimatch": "^3.1.1",
-        "once": "^1.3.0",
-        "path-is-absolute": "^1.0.0"
-      },
-      "engines": {
-        "node": "*"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/glob-parent": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
-      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "is-glob": "^4.0.1"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/glob-stream": {
-      "version": "8.0.3",
-      "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz",
-      "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@gulpjs/to-absolute-glob": "^4.0.0",
-        "anymatch": "^3.1.3",
-        "fastq": "^1.13.0",
-        "glob-parent": "^6.0.2",
-        "is-glob": "^4.0.3",
-        "is-negated-glob": "^1.0.0",
-        "normalize-path": "^3.0.0",
-        "streamx": "^2.12.5"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/glob-stream/node_modules/glob-parent": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
-      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "is-glob": "^4.0.3"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/glob-watcher": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-6.0.0.tgz",
-      "integrity": "sha512-wGM28Ehmcnk2NqRORXFOTOR064L4imSw3EeOqU5bIwUf62eXGwg89WivH6VMahL8zlQHeodzvHpXplrqzrz3Nw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "async-done": "^2.0.0",
-        "chokidar": "^3.5.3"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/glob-watcher/node_modules/async-done": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz",
-      "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "end-of-stream": "^1.4.4",
-        "once": "^1.4.0",
-        "stream-exhaust": "^1.0.2"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/global-modules": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
-      "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "global-prefix": "^1.0.1",
-        "is-windows": "^1.0.1",
-        "resolve-dir": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/global-prefix": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
-      "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expand-tilde": "^2.0.2",
-        "homedir-polyfill": "^1.0.1",
-        "ini": "^1.3.4",
-        "is-windows": "^1.0.1",
-        "which": "^1.2.14"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/global-prefix/node_modules/which": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
-      "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "isexe": "^2.0.0"
-      },
-      "bin": {
-        "which": "bin/which"
-      }
-    },
-    "node_modules/glogg": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/glogg/-/glogg-2.2.0.tgz",
-      "integrity": "sha512-eWv1ds/zAlz+M1ioHsyKJomfY7jbDDPpwSkv14KQj89bycx1nvK5/2Cj/T9g7kzJcX5Bc7Yv22FjfBZS/jl94A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "sparkles": "^2.1.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/gopd": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
-      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/graceful-fs": {
-      "version": "4.2.11",
-      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
-      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/gulp": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.0.tgz",
-      "integrity": "sha512-S8Z8066SSileaYw1S2N1I64IUc/myI2bqe2ihOBzO6+nKpvNSg7ZcWJt/AwF8LC/NVN+/QZ560Cb/5OPsyhkhg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "glob-watcher": "^6.0.0",
-        "gulp-cli": "^3.0.0",
-        "undertaker": "^2.0.0",
-        "vinyl-fs": "^4.0.0"
-      },
-      "bin": {
-        "gulp": "bin/gulp.js"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/gulp-cli": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.0.0.tgz",
-      "integrity": "sha512-RtMIitkT8DEMZZygHK2vEuLPqLPAFB4sntSxg4NoDta7ciwGZ18l7JuhCTiS5deOJi2IoK0btE+hs6R4sfj7AA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@gulpjs/messages": "^1.1.0",
-        "chalk": "^4.1.2",
-        "copy-props": "^4.0.0",
-        "gulplog": "^2.2.0",
-        "interpret": "^3.1.1",
-        "liftoff": "^5.0.0",
-        "mute-stdout": "^2.0.0",
-        "replace-homedir": "^2.0.0",
-        "semver-greatest-satisfied-range": "^2.0.0",
-        "string-width": "^4.2.3",
-        "v8flags": "^4.0.0",
-        "yargs": "^16.2.0"
-      },
-      "bin": {
-        "gulp": "bin/gulp.js"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/gulp-file": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/gulp-file/-/gulp-file-0.4.0.tgz",
-      "integrity": "sha512-3NPCJpAPpbNoV2aml8T96OK3Aof4pm4PMOIa1jSQbMNSNUUXdZ5QjVgLXLStjv0gg9URcETc7kvYnzXdYXUWug==",
-      "dev": true,
-      "license": "BSD",
-      "dependencies": {
-        "through2": "^0.4.1",
-        "vinyl": "^2.1.0"
-      }
-    },
-    "node_modules/gulp-plugin-extras": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/gulp-plugin-extras/-/gulp-plugin-extras-0.3.0.tgz",
-      "integrity": "sha512-I/kOBSpo61QsGQZcqozZYEnDseKvpudUafVVWDLYgBFAUJ37kW5R8Sjw9cMYzpGyPUfEYOeoY4p+dkfLqgyJUQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/vinyl": "^2.0.9",
-        "chalk": "^5.3.0",
-        "easy-transform-stream": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/gulp-plugin-extras/node_modules/chalk": {
-      "version": "5.4.1",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
-      "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^12.17.0 || ^14.13 || >=16.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/gulp-rename": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-2.0.0.tgz",
-      "integrity": "sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/gulp-replace": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/gulp-replace/-/gulp-replace-1.1.4.tgz",
-      "integrity": "sha512-SVSF7ikuWKhpAW4l4wapAqPPSToJoiNKsbDoUnRrSgwZHH7lH8pbPeQj1aOVYQrbZKhfSVBxVW+Py7vtulRktw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "@types/vinyl": "^2.0.4",
-        "istextorbinary": "^3.0.0",
-        "replacestream": "^4.0.3",
-        "yargs-parser": ">=5.0.0-security.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/gulp-zip": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/gulp-zip/-/gulp-zip-6.0.0.tgz",
-      "integrity": "sha512-fPGvNve2dBoZxGKcviTU7mOa77eQibyhwgGLTxnF+ZCKX8RFaTZKkPbdPnmw0r4TNPRjPCkQB/0VuP+MzgkEYg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "get-stream": "^8.0.1",
-        "gulp-plugin-extras": "^0.3.0",
-        "vinyl": "^3.0.0",
-        "yazl": "^2.5.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      },
-      "peerDependencies": {
-        "gulp": ">=4"
-      },
-      "peerDependenciesMeta": {
-        "gulp": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/gulp-zip/node_modules/replace-ext": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz",
-      "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/gulp-zip/node_modules/vinyl": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz",
-      "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "clone": "^2.1.2",
-        "remove-trailing-separator": "^1.1.0",
-        "replace-ext": "^2.0.0",
-        "teex": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/gulplog": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-2.2.0.tgz",
-      "integrity": "sha512-V2FaKiOhpR3DRXZuYdRLn/qiY0yI5XmqbTKrYbdemJ+xOh2d2MOweI/XFgMzd/9+1twdvMwllnZbWZNJ+BOm4A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "glogg": "^2.2.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/has-flag": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/has-symbols": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
-      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/has-tostringtag": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
-      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-symbols": "^1.0.3"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/hasown": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
-      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "function-bind": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/he": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
-      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "he": "bin/he"
-      }
-    },
-    "node_modules/homedir-polyfill": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
-      "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "parse-passwd": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/html-encoding-sniffer": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
-      "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "whatwg-encoding": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/html-escaper": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
-      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/http-proxy-agent": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
-      "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@tootallnate/once": "2",
-        "agent-base": "6",
-        "debug": "4"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/https-proxy-agent": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
-      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "agent-base": "6",
-        "debug": "4"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/human-signals": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
-      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=10.17.0"
-      }
-    },
-    "node_modules/iconv-lite": {
-      "version": "0.4.24",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
-      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/ieee754": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/import-local": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
-      "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "pkg-dir": "^4.2.0",
-        "resolve-cwd": "^3.0.0"
-      },
-      "bin": {
-        "import-local-fixture": "fixtures/cli.js"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/imurmurhash": {
-      "version": "0.1.4",
-      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.8.19"
-      }
-    },
-    "node_modules/indent-string": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz",
-      "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/inflight": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
-      "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "once": "^1.3.0",
-        "wrappy": "1"
-      }
-    },
-    "node_modules/inherits": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/ini": {
-      "version": "1.3.8",
-      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/inquirer": {
-      "version": "10.2.2",
-      "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-10.2.2.tgz",
-      "integrity": "sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@inquirer/core": "^9.1.0",
-        "@inquirer/prompts": "^5.5.0",
-        "@inquirer/type": "^1.5.3",
-        "@types/mute-stream": "^0.0.4",
-        "ansi-escapes": "^4.3.2",
-        "mute-stream": "^1.0.0",
-        "run-async": "^3.0.0",
-        "rxjs": "^7.8.1"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/interpret": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
-      "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/is-absolute": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
-      "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-relative": "^1.0.0",
-        "is-windows": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-arrayish": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
-      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/is-binary-path": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
-      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "binary-extensions": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/is-builtin-module": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
-      "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "builtin-modules": "^3.3.0"
-      },
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/is-core-module": {
-      "version": "2.16.1",
-      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
-      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "hasown": "^2.0.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/is-docker": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
-      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "is-docker": "cli.js"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/is-extglob": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-fullwidth-code-point": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/is-generator-fn": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
-      "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/is-glob": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
-      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-extglob": "^2.1.1"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-module": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
-      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/is-negated-glob": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz",
-      "integrity": "sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-number": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
-      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.12.0"
-      }
-    },
-    "node_modules/is-plain-object": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
-      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-potential-custom-element-name": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
-      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/is-reference": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
-      "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/estree": "*"
-      }
-    },
-    "node_modules/is-relative": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
-      "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-unc-path": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-stream": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
-      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/is-unc-path": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
-      "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "unc-path-regex": "^0.1.2"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-valid-glob": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz",
-      "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-windows": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
-      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/is-wsl": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
-      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-docker": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/isarray": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/isexe": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/isobject": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
-      "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/istanbul-lib-coverage": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
-      "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/istanbul-lib-instrument": {
-      "version": "6.0.3",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
-      "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@babel/core": "^7.23.9",
-        "@babel/parser": "^7.23.9",
-        "@istanbuljs/schema": "^0.1.3",
-        "istanbul-lib-coverage": "^3.2.0",
-        "semver": "^7.5.4"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/istanbul-lib-instrument/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/istanbul-lib-report": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
-      "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "istanbul-lib-coverage": "^3.0.0",
-        "make-dir": "^4.0.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/istanbul-lib-source-maps": {
-      "version": "5.0.6",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
-      "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "peer": true,
-      "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.23",
-        "debug": "^4.1.1",
-        "istanbul-lib-coverage": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/istanbul-reports": {
-      "version": "3.1.7",
-      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
-      "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "html-escaper": "^2.0.0",
-        "istanbul-lib-report": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/istextorbinary": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-3.3.0.tgz",
-      "integrity": "sha512-Tvq1W6NAcZeJ8op+Hq7tdZ434rqnMx4CCZ7H0ff83uEloDvVbqAwaMTZcafKGJT0VHkYzuXUiCY4hlXQg6WfoQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "binaryextensions": "^2.2.0",
-        "textextensions": "^3.2.0"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://bevry.me/fund"
-      }
-    },
-    "node_modules/jackspeak": {
-      "version": "3.4.3",
-      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
-      "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
-      "dev": true,
-      "license": "BlueOak-1.0.0",
-      "dependencies": {
-        "@isaacs/cliui": "^8.0.2"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      },
-      "optionalDependencies": {
-        "@pkgjs/parseargs": "^0.11.0"
-      }
-    },
-    "node_modules/jake": {
-      "version": "10.9.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
-      "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "async": "^3.2.3",
-        "chalk": "^4.0.2",
-        "filelist": "^1.0.4",
-        "minimatch": "^3.1.2"
-      },
-      "bin": {
-        "jake": "bin/cli.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/jest": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.4.tgz",
-      "integrity": "sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/core": "30.0.4",
-        "@jest/types": "30.0.1",
-        "import-local": "^3.2.0",
-        "jest-cli": "30.0.4"
-      },
-      "bin": {
-        "jest": "bin/jest.js"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jest-canvas-mock": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.0.tgz",
-      "integrity": "sha512-s2bmY2f22WPMzhB2YA93kiyf7CAfWAnV/sFfY9s48IVOrGmwui1eSFluDPesq1M+7tSC1hJAit6mzO0ZNXvVBA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "cssfontparser": "^1.2.1",
-        "moo-color": "^1.0.2"
-      }
-    },
-    "node_modules/jest-changed-files": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.2.tgz",
-      "integrity": "sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "execa": "^5.1.1",
-        "jest-util": "30.0.2",
-        "p-limit": "^3.1.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-changed-files/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-circus": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.4.tgz",
-      "integrity": "sha512-o6UNVfbXbmzjYgmVPtSQrr5xFZCtkDZGdTlptYvGFSN80RuOOlTe73djvMrs+QAuSERZWcHBNIOMH+OEqvjWuw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/environment": "30.0.4",
-        "@jest/expect": "30.0.4",
-        "@jest/test-result": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "co": "^4.6.0",
-        "dedent": "^1.6.0",
-        "is-generator-fn": "^2.1.0",
-        "jest-each": "30.0.2",
-        "jest-matcher-utils": "30.0.4",
-        "jest-message-util": "30.0.2",
-        "jest-runtime": "30.0.4",
-        "jest-snapshot": "30.0.4",
-        "jest-util": "30.0.2",
-        "p-limit": "^3.1.0",
-        "pretty-format": "30.0.2",
-        "pure-rand": "^7.0.0",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-circus/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-circus/node_modules/jest-diff": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz",
-      "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/diff-sequences": "30.0.1",
-        "@jest/get-type": "30.0.1",
-        "chalk": "^4.1.2",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-circus/node_modules/jest-matcher-utils": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz",
-      "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1",
-        "chalk": "^4.1.2",
-        "jest-diff": "30.0.4",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-circus/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-circus/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-circus/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-cli": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.4.tgz",
-      "integrity": "sha512-3dOrP3zqCWBkjoVG1zjYJpD9143N9GUCbwaF2pFF5brnIgRLHmKcCIw+83BvF1LxggfMWBA0gxkn6RuQVuRhIQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/core": "30.0.4",
-        "@jest/test-result": "30.0.4",
-        "@jest/types": "30.0.1",
-        "chalk": "^4.1.2",
-        "exit-x": "^0.2.2",
-        "import-local": "^3.2.0",
-        "jest-config": "30.0.4",
-        "jest-util": "30.0.2",
-        "jest-validate": "30.0.2",
-        "yargs": "^17.7.2"
-      },
-      "bin": {
-        "jest": "bin/jest.js"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-      },
-      "peerDependenciesMeta": {
-        "node-notifier": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jest-cli/node_modules/cliui": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
-      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "string-width": "^4.2.0",
-        "strip-ansi": "^6.0.1",
-        "wrap-ansi": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/jest-cli/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-cli/node_modules/wrap-ansi": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "ansi-styles": "^4.0.0",
-        "string-width": "^4.1.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-      }
-    },
-    "node_modules/jest-cli/node_modules/yargs": {
-      "version": "17.7.2",
-      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
-      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "cliui": "^8.0.1",
-        "escalade": "^3.1.1",
-        "get-caller-file": "^2.0.5",
-        "require-directory": "^2.1.1",
-        "string-width": "^4.2.3",
-        "y18n": "^5.0.5",
-        "yargs-parser": "^21.1.1"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/jest-cli/node_modules/yargs-parser": {
-      "version": "21.1.1",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
-      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/jest-config": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.4.tgz",
-      "integrity": "sha512-3dzbO6sh34thAGEjJIW0fgT0GA0EVlkski6ZzMcbW6dzhenylXAE/Mj2MI4HonroWbkKc6wU6bLVQ8dvBSZ9lA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/core": "^7.27.4",
-        "@jest/get-type": "30.0.1",
-        "@jest/pattern": "30.0.1",
-        "@jest/test-sequencer": "30.0.4",
-        "@jest/types": "30.0.1",
-        "babel-jest": "30.0.4",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "deepmerge": "^4.3.1",
-        "glob": "^10.3.10",
-        "graceful-fs": "^4.2.11",
-        "jest-circus": "30.0.4",
-        "jest-docblock": "30.0.1",
-        "jest-environment-node": "30.0.4",
-        "jest-regex-util": "30.0.1",
-        "jest-resolve": "30.0.2",
-        "jest-runner": "30.0.4",
-        "jest-util": "30.0.2",
-        "jest-validate": "30.0.2",
-        "micromatch": "^4.0.8",
-        "parse-json": "^5.2.0",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "strip-json-comments": "^3.1.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "peerDependencies": {
-        "@types/node": "*",
-        "esbuild-register": ">=3.4.0",
-        "ts-node": ">=9.0.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "esbuild-register": {
-          "optional": true
-        },
-        "ts-node": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jest-config/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-config/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/jest-config/node_modules/glob": {
-      "version": "10.4.5",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
-      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "foreground-child": "^3.1.0",
-        "jackspeak": "^3.1.2",
-        "minimatch": "^9.0.4",
-        "minipass": "^7.1.2",
-        "package-json-from-dist": "^1.0.0",
-        "path-scurry": "^1.11.1"
-      },
-      "bin": {
-        "glob": "dist/esm/bin.mjs"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/jest-config/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-config/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/jest-config/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-diff": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
-      "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "chalk": "^4.0.0",
-        "diff-sequences": "^29.6.3",
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-docblock": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz",
-      "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "detect-newline": "^3.1.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-each": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.2.tgz",
-      "integrity": "sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1",
-        "@jest/types": "30.0.1",
-        "chalk": "^4.1.2",
-        "jest-util": "30.0.2",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-each/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-each/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-each/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-environment-jsdom": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
-      "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/jsdom": "^20.0.0",
-        "@types/node": "*",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jsdom": "^20.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      },
-      "peerDependencies": {
-        "canvas": "^2.5.0"
-      },
-      "peerDependenciesMeta": {
-        "canvas": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jest-environment-jsdom/node_modules/@jest/environment": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
-      "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-mock": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
-      "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@sinonjs/fake-timers": "^10.0.2",
-        "@types/node": "*",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-environment-jsdom/node_modules/@jest/types": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-      "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "@types/istanbul-reports": "^3.0.0",
-        "@types/node": "*",
-        "@types/yargs": "^17.0.8",
-        "chalk": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
-      "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@sinonjs/commons": "^3.0.0"
-      }
-    },
-    "node_modules/jest-environment-jsdom/node_modules/jest-mock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
-      "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "jest-util": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-environment-node": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.4.tgz",
-      "integrity": "sha512-p+rLEzC2eThXqiNh9GHHTC0OW5Ca4ZfcURp7scPjYBcmgpR9HG6750716GuUipYf2AcThU3k20B31USuiaaIEg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/environment": "30.0.4",
-        "@jest/fake-timers": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "jest-mock": "30.0.2",
-        "jest-util": "30.0.2",
-        "jest-validate": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-environment-node/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-get-type": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
-      "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-haste-map": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.2.tgz",
-      "integrity": "sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "anymatch": "^3.1.3",
-        "fb-watchman": "^2.0.2",
-        "graceful-fs": "^4.2.11",
-        "jest-regex-util": "30.0.1",
-        "jest-util": "30.0.2",
-        "jest-worker": "30.0.2",
-        "micromatch": "^4.0.8",
-        "walker": "^1.0.8"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "^2.3.3"
-      }
-    },
-    "node_modules/jest-haste-map/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-leak-detector": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.2.tgz",
-      "integrity": "sha512-U66sRrAYdALq+2qtKffBLDWsQ/XoNNs2Lcr83sc9lvE/hEpNafJlq2lXCPUBMNqamMECNxSIekLfe69qg4KMIQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-leak-detector/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-leak-detector/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-matcher-utils": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
-      "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "chalk": "^4.0.0",
-        "jest-diff": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-message-util": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
-      "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.12.13",
-        "@jest/types": "^29.6.3",
-        "@types/stack-utils": "^2.0.0",
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "micromatch": "^4.0.4",
-        "pretty-format": "^29.7.0",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-message-util/node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-message-util/node_modules/@jest/types": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-      "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "@types/istanbul-reports": "^3.0.0",
-        "@types/node": "*",
-        "@types/yargs": "^17.0.8",
-        "chalk": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-message-util/node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/jest-mock": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz",
-      "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "jest-util": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-mock/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-pnp-resolver": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz",
-      "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      },
-      "peerDependencies": {
-        "jest-resolve": "*"
-      },
-      "peerDependenciesMeta": {
-        "jest-resolve": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jest-regex-util": {
-      "version": "30.0.1",
-      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz",
-      "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-resolve": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.2.tgz",
-      "integrity": "sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "jest-haste-map": "30.0.2",
-        "jest-pnp-resolver": "^1.2.3",
-        "jest-util": "30.0.2",
-        "jest-validate": "30.0.2",
-        "slash": "^3.0.0",
-        "unrs-resolver": "^1.7.11"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-resolve-dependencies": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.4.tgz",
-      "integrity": "sha512-EQBYow19B/hKr4gUTn+l8Z+YLlP2X0IoPyp0UydOtrcPbIOYzJ8LKdFd+yrbwztPQvmlBFUwGPPEzHH1bAvFAw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "jest-regex-util": "30.0.1",
-        "jest-snapshot": "30.0.4"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-resolve/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runner": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.4.tgz",
-      "integrity": "sha512-mxY0vTAEsowJwvFJo5pVivbCpuu6dgdXRmt3v3MXjBxFly7/lTk3Td0PaMyGOeNQUFmSuGEsGYqhbn7PA9OekQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/console": "30.0.4",
-        "@jest/environment": "30.0.4",
-        "@jest/test-result": "30.0.4",
-        "@jest/transform": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "emittery": "^0.13.1",
-        "exit-x": "^0.2.2",
-        "graceful-fs": "^4.2.11",
-        "jest-docblock": "30.0.1",
-        "jest-environment-node": "30.0.4",
-        "jest-haste-map": "30.0.2",
-        "jest-leak-detector": "30.0.2",
-        "jest-message-util": "30.0.2",
-        "jest-resolve": "30.0.2",
-        "jest-runtime": "30.0.4",
-        "jest-util": "30.0.2",
-        "jest-watcher": "30.0.4",
-        "jest-worker": "30.0.2",
-        "p-limit": "^3.1.0",
-        "source-map-support": "0.5.13"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runner/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-runner/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runner/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runner/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runtime": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.4.tgz",
-      "integrity": "sha512-tUQrZ8+IzoZYIHoPDQEB4jZoPyzBjLjq7sk0KVyd5UPRjRDOsN7o6UlvaGF8ddpGsjznl9PW+KRgWqCNO+Hn7w==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/environment": "30.0.4",
-        "@jest/fake-timers": "30.0.4",
-        "@jest/globals": "30.0.4",
-        "@jest/source-map": "30.0.1",
-        "@jest/test-result": "30.0.4",
-        "@jest/transform": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "cjs-module-lexer": "^2.1.0",
-        "collect-v8-coverage": "^1.0.2",
-        "glob": "^10.3.10",
-        "graceful-fs": "^4.2.11",
-        "jest-haste-map": "30.0.2",
-        "jest-message-util": "30.0.2",
-        "jest-mock": "30.0.2",
-        "jest-regex-util": "30.0.1",
-        "jest-resolve": "30.0.2",
-        "jest-snapshot": "30.0.4",
-        "jest-util": "30.0.2",
-        "slash": "^3.0.0",
-        "strip-bom": "^4.0.0"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/glob": {
-      "version": "10.4.5",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
-      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "foreground-child": "^3.1.0",
-        "jackspeak": "^3.1.2",
-        "minimatch": "^9.0.4",
-        "minipass": "^7.1.2",
-        "package-json-from-dist": "^1.0.0",
-        "path-scurry": "^1.11.1"
-      },
-      "bin": {
-        "glob": "dist/esm/bin.mjs"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/jest-runtime/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.4.tgz",
-      "integrity": "sha512-S/8hmSkeUib8WRUq9pWEb5zMfsOjiYWDWzFzKnjX7eDyKKgimsu9hcmsUEg8a7dPAw8s/FacxsXquq71pDgPjQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/core": "^7.27.4",
-        "@babel/generator": "^7.27.5",
-        "@babel/plugin-syntax-jsx": "^7.27.1",
-        "@babel/plugin-syntax-typescript": "^7.27.1",
-        "@babel/types": "^7.27.3",
-        "@jest/expect-utils": "30.0.4",
-        "@jest/get-type": "30.0.1",
-        "@jest/snapshot-utils": "30.0.4",
-        "@jest/transform": "30.0.4",
-        "@jest/types": "30.0.1",
-        "babel-preset-current-node-syntax": "^1.1.0",
-        "chalk": "^4.1.2",
-        "expect": "30.0.4",
-        "graceful-fs": "^4.2.11",
-        "jest-diff": "30.0.4",
-        "jest-matcher-utils": "30.0.4",
-        "jest-message-util": "30.0.2",
-        "jest-util": "30.0.2",
-        "pretty-format": "30.0.2",
-        "semver": "^7.7.2",
-        "synckit": "^0.11.8"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/@jest/expect-utils": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz",
-      "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/expect": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz",
-      "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/expect-utils": "30.0.4",
-        "@jest/get-type": "30.0.1",
-        "jest-matcher-utils": "30.0.4",
-        "jest-message-util": "30.0.2",
-        "jest-mock": "30.0.2",
-        "jest-util": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/jest-diff": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz",
-      "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/diff-sequences": "30.0.1",
-        "@jest/get-type": "30.0.1",
-        "chalk": "^4.1.2",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/jest-matcher-utils": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz",
-      "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1",
-        "chalk": "^4.1.2",
-        "jest-diff": "30.0.4",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/jest-message-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz",
-      "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.27.1",
-        "@jest/types": "30.0.1",
-        "@types/stack-utils": "^2.0.3",
-        "chalk": "^4.1.2",
-        "graceful-fs": "^4.2.11",
-        "micromatch": "^4.0.8",
-        "pretty-format": "30.0.2",
-        "slash": "^3.0.0",
-        "stack-utils": "^2.0.6"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-snapshot/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/jest-util": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
-      "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/node": "*",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "graceful-fs": "^4.2.9",
-        "picomatch": "^2.2.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-util/node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-util/node_modules/@jest/types": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-      "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "@types/istanbul-reports": "^3.0.0",
-        "@types/node": "*",
-        "@types/yargs": "^17.0.8",
-        "chalk": "^4.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-util/node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/jest-util/node_modules/ci-info": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
-      "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/sibiraj-s"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/jest-util/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/jest-validate": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.2.tgz",
-      "integrity": "sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/get-type": "30.0.1",
-        "@jest/types": "30.0.1",
-        "camelcase": "^6.3.0",
-        "chalk": "^4.1.2",
-        "leven": "^3.1.0",
-        "pretty-format": "30.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-validate/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-validate/node_modules/camelcase": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
-      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/jest-validate/node_modules/pretty-format": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
-      "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/schemas": "30.0.1",
-        "ansi-styles": "^5.2.0",
-        "react-is": "^18.3.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-watcher": {
-      "version": "30.0.4",
-      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.4.tgz",
-      "integrity": "sha512-YESbdHDs7aQOCSSKffG8jXqOKFqw4q4YqR+wHYpR5GWEQioGvL0BfbcjvKIvPEM0XGfsfJrka7jJz3Cc3gI4VQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/test-result": "30.0.4",
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "ansi-escapes": "^4.3.2",
-        "chalk": "^4.1.2",
-        "emittery": "^0.13.1",
-        "jest-util": "30.0.2",
-        "string-length": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-watcher/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-worker": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.2.tgz",
-      "integrity": "sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/node": "*",
-        "@ungap/structured-clone": "^1.3.0",
-        "jest-util": "30.0.2",
-        "merge-stream": "^2.0.0",
-        "supports-color": "^8.1.1"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-worker/node_modules/jest-util": {
-      "version": "30.0.2",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz",
-      "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jest/types": "30.0.1",
-        "@types/node": "*",
-        "chalk": "^4.1.2",
-        "ci-info": "^4.2.0",
-        "graceful-fs": "^4.2.11",
-        "picomatch": "^4.0.2"
-      },
-      "engines": {
-        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
-      }
-    },
-    "node_modules/jest-worker/node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
-    "node_modules/js-tokens": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
-      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/js-yaml": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
-      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "argparse": "^2.0.1"
-      },
-      "bin": {
-        "js-yaml": "bin/js-yaml.js"
-      }
-    },
-    "node_modules/jscodeshift": {
-      "version": "0.16.1",
-      "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.16.1.tgz",
-      "integrity": "sha512-oMQXySazy63awNBzMpXbbVv73u3irdxTeX2L5ueRyFRxi32qb9uzdZdOY5fTBYADBG19l5M/wnGknZSV1dzCdA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.24.7",
-        "@babel/parser": "^7.24.7",
-        "@babel/plugin-transform-class-properties": "^7.24.7",
-        "@babel/plugin-transform-modules-commonjs": "^7.24.7",
-        "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
-        "@babel/plugin-transform-optional-chaining": "^7.24.7",
-        "@babel/plugin-transform-private-methods": "^7.24.7",
-        "@babel/preset-flow": "^7.24.7",
-        "@babel/preset-typescript": "^7.24.7",
-        "@babel/register": "^7.24.6",
-        "chalk": "^4.1.2",
-        "flow-parser": "0.*",
-        "graceful-fs": "^4.2.4",
-        "micromatch": "^4.0.7",
-        "neo-async": "^2.5.0",
-        "node-dir": "^0.1.17",
-        "recast": "^0.23.9",
-        "temp": "^0.9.4",
-        "write-file-atomic": "^5.0.1"
-      },
-      "bin": {
-        "jscodeshift": "bin/jscodeshift.js"
-      },
-      "peerDependencies": {
-        "@babel/preset-env": "^7.1.6"
-      },
-      "peerDependenciesMeta": {
-        "@babel/preset-env": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jsdom": {
-      "version": "20.0.3",
-      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
-      "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "abab": "^2.0.6",
-        "acorn": "^8.8.1",
-        "acorn-globals": "^7.0.0",
-        "cssom": "^0.5.0",
-        "cssstyle": "^2.3.0",
-        "data-urls": "^3.0.2",
-        "decimal.js": "^10.4.2",
-        "domexception": "^4.0.0",
-        "escodegen": "^2.0.0",
-        "form-data": "^4.0.0",
-        "html-encoding-sniffer": "^3.0.0",
-        "http-proxy-agent": "^5.0.0",
-        "https-proxy-agent": "^5.0.1",
-        "is-potential-custom-element-name": "^1.0.1",
-        "nwsapi": "^2.2.2",
-        "parse5": "^7.1.1",
-        "saxes": "^6.0.0",
-        "symbol-tree": "^3.2.4",
-        "tough-cookie": "^4.1.2",
-        "w3c-xmlserializer": "^4.0.0",
-        "webidl-conversions": "^7.0.0",
-        "whatwg-encoding": "^2.0.0",
-        "whatwg-mimetype": "^3.0.0",
-        "whatwg-url": "^11.0.0",
-        "ws": "^8.11.0",
-        "xml-name-validator": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=14"
-      },
-      "peerDependencies": {
-        "canvas": "^2.5.0"
-      },
-      "peerDependenciesMeta": {
-        "canvas": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/jsesc": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
-      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "jsesc": "bin/jsesc"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/json-parse-even-better-errors": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
-      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/json5": {
-      "version": "2.2.3",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
-      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "json5": "lib/cli.js"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/jspsych": {
-      "version": "8.2.2",
-      "resolved": "https://registry.npmjs.org/jspsych/-/jspsych-8.2.2.tgz",
-      "integrity": "sha512-5Rs9WWXhSzrm/Nwtqjn/JmrKC4u7FpgDQLhDuqgl40eqYlmkxSkKXhoh07k3cYk6kyDonw0joGHNwRZUDPJ/KA==",
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "auto-bind": "^4.0.0",
-        "random-words": "^1.1.1",
-        "seedrandom": "^3.0.5",
-        "type-fest": "^2.9.0"
-      }
-    },
-    "node_modules/jspsych/node_modules/type-fest": {
-      "version": "2.19.0",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
-      "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
-      "license": "(MIT OR CC0-1.0)",
-      "peer": true,
-      "engines": {
-        "node": ">=12.20"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/kind-of": {
-      "version": "6.0.3",
-      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
-      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/kleur": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
-      "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/last-run": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/last-run/-/last-run-2.0.0.tgz",
-      "integrity": "sha512-j+y6WhTLN4Itnf9j5ZQos1BGPCS8DAwmgMroR3OzfxAsBxam0hMw7J8M3KqZl0pLQJ1jNnwIexg5DYpC/ctwEQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/lead": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz",
-      "integrity": "sha512-DpMa59o5uGUWWjruMp71e6knmwKU3jRBBn1kjuLWN9EeIOxNeSAwvHf03WIl8g/ZMR2oSQC9ej3yeLBwdDc/pg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/leven": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
-      "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/liftoff": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz",
-      "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "extend": "^3.0.2",
-        "findup-sync": "^5.0.0",
-        "fined": "^2.0.0",
-        "flagged-respawn": "^2.0.0",
-        "is-plain-object": "^5.0.0",
-        "rechoir": "^0.8.0",
-        "resolve": "^1.20.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/lines-and-columns": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
-      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/locate-path": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
-      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "p-locate": "^4.1.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/lodash.memoize": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-      "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/lru-cache": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
-      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^3.0.2"
-      }
-    },
-    "node_modules/magic-string": {
-      "version": "0.30.17",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
-      "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.0"
-      }
-    },
-    "node_modules/make-dir": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
-      "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "semver": "^7.5.3"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/make-dir/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/make-error": {
-      "version": "1.3.6",
-      "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
-      "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/makeerror": {
-      "version": "1.0.12",
-      "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
-      "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "tmpl": "1.0.5"
-      }
-    },
-    "node_modules/map-cache": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
-      "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/math-intrinsics": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
-      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/merge-stream": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
-      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/micromatch": {
-      "version": "4.0.8",
-      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
-      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "braces": "^3.0.3",
-        "picomatch": "^2.3.1"
-      },
-      "engines": {
-        "node": ">=8.6"
-      }
-    },
-    "node_modules/micromatch/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/mime-db": {
-      "version": "1.52.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/mime-types": {
-      "version": "2.1.35",
-      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
-      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mime-db": "1.52.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/mimic-fn": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
-      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^1.1.7"
-      },
-      "engines": {
-        "node": "*"
-      }
-    },
-    "node_modules/minimist": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-      "dev": true,
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/minipass": {
-      "version": "7.1.2",
-      "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
-      "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      }
-    },
-    "node_modules/mkdirp": {
-      "version": "0.5.6",
-      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
-      "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "minimist": "^1.2.6"
-      },
-      "bin": {
-        "mkdirp": "bin/cmd.js"
-      }
-    },
-    "node_modules/module-alias": {
-      "version": "2.2.3",
-      "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.3.tgz",
-      "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/moo": {
-      "version": "0.5.2",
-      "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
-      "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
-      "dev": true,
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/moo-color": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz",
-      "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-name": "^1.1.4"
-      }
-    },
-    "node_modules/ms": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/mute-stdout": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-2.0.0.tgz",
-      "integrity": "sha512-32GSKM3Wyc8dg/p39lWPKYu8zci9mJFzV1Np9Of0ZEpe6Fhssn/FbI7ywAMd40uX+p3ZKh3T5EeCFv81qS3HmQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/mute-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
-      "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
-      }
-    },
-    "node_modules/mz": {
-      "version": "2.7.0",
-      "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
-      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "any-promise": "^1.0.0",
-        "object-assign": "^4.0.1",
-        "thenify-all": "^1.0.0"
-      }
-    },
-    "node_modules/napi-postinstall": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.0.tgz",
-      "integrity": "sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "bin": {
-        "napi-postinstall": "lib/cli.js"
-      },
-      "engines": {
-        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/napi-postinstall"
-      }
-    },
-    "node_modules/natural-compare": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
-      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/neo-async": {
-      "version": "2.6.2",
-      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
-      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/node-dir": {
-      "version": "0.1.17",
-      "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz",
-      "integrity": "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "minimatch": "^3.0.2"
-      },
-      "engines": {
-        "node": ">= 0.10.5"
-      }
-    },
-    "node_modules/node-fetch": {
-      "version": "2.7.0",
-      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
-      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "whatwg-url": "^5.0.0"
-      },
-      "engines": {
-        "node": "4.x || >=6.0.0"
-      },
-      "peerDependencies": {
-        "encoding": "^0.1.0"
-      },
-      "peerDependenciesMeta": {
-        "encoding": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/node-fetch/node_modules/tr46": {
-      "version": "0.0.3",
-      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
-      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/node-fetch/node_modules/webidl-conversions": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
-      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
-      "dev": true,
-      "license": "BSD-2-Clause"
-    },
-    "node_modules/node-fetch/node_modules/whatwg-url": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
-      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "tr46": "~0.0.3",
-        "webidl-conversions": "^3.0.0"
-      }
-    },
-    "node_modules/node-int64": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
-      "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/node-releases": {
-      "version": "2.0.19",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
-      "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/normalize-path": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
-      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/now-and-later": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-3.0.0.tgz",
-      "integrity": "sha512-pGO4pzSdaxhWTGkfSfHx3hVzJVslFPwBp2Myq9MYN/ChfJZF87ochMAXnvz6/58RJSf5ik2q9tXprBBrk2cpcg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "once": "^1.4.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/npm-run-path": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
-      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "path-key": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/nwsapi": {
-      "version": "2.2.20",
-      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
-      "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/object-assign": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/object-keys": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz",
-      "integrity": "sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/object.defaults": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
-      "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "array-each": "^1.0.1",
-        "array-slice": "^1.0.0",
-        "for-own": "^1.0.0",
-        "isobject": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/object.pick": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
-      "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "isobject": "^3.0.1"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/once": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "wrappy": "1"
-      }
-    },
-    "node_modules/onetime": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
-      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mimic-fn": "^2.1.0"
-      },
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/open": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
-      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-docker": "^2.0.0",
-        "is-wsl": "^2.1.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/os-tmpdir": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
-      "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/ospec": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/ospec/-/ospec-3.1.0.tgz",
-      "integrity": "sha512-+nGtjV3vlADp+UGfL51miAh/hB4awPBkQrArhcgG4trAaoA2gKt5bf9w0m9ch9zOr555cHWaCHZEDiBOkNZSxw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "glob": "^7.1.3"
-      },
-      "bin": {
-        "ospec": "bin/ospec"
-      }
-    },
-    "node_modules/p-limit": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
-      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "yocto-queue": "^0.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/p-locate": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
-      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "p-limit": "^2.2.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/p-locate/node_modules/p-limit": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
-      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "p-try": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/p-try": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
-      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/package-json-from-dist": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
-      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
-      "dev": true,
-      "license": "BlueOak-1.0.0"
-    },
-    "node_modules/parse-filepath": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
-      "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-absolute": "^1.0.0",
-        "map-cache": "^0.2.0",
-        "path-root": "^0.1.1"
-      },
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
-    "node_modules/parse-json": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
-      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.0.0",
-        "error-ex": "^1.3.1",
-        "json-parse-even-better-errors": "^2.3.0",
-        "lines-and-columns": "^1.1.6"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/parse-passwd": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
-      "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/parse5": {
-      "version": "7.3.0",
-      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
-      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "entities": "^6.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/inikulin/parse5?sponsor=1"
-      }
-    },
-    "node_modules/path-exists": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
-      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/path-is-absolute": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/path-key": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
-      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/path-parse": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
-      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/path-root": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
-      "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "path-root-regex": "^0.1.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/path-root-regex": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
-      "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/path-scurry": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
-      "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
-      "dev": true,
-      "license": "BlueOak-1.0.0",
-      "dependencies": {
-        "lru-cache": "^10.2.0",
-        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/path-scurry/node_modules/lru-cache": {
-      "version": "10.4.3",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
-      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/picocolors": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
-      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/picomatch": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
-      "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/pify": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
-      "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/pirates": {
-      "version": "4.0.7",
-      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
-      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/pkg-dir": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
-      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "find-up": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/pretty-format": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-      "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/pretty-format/node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/pretty-format/node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/pretty-format/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/process-nextick-args": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
-      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/prompts": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
-      "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "kleur": "^3.0.3",
-        "sisteransi": "^1.0.5"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/psl": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
-      "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "punycode": "^2.3.1"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/lupomontero"
-      }
-    },
-    "node_modules/punycode": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
-      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/pure-rand": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
-      "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/dubzzz"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fast-check"
-        }
-      ],
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/querystringify": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
-      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/random-words": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/random-words/-/random-words-1.3.0.tgz",
-      "integrity": "sha512-brwCGe+DN9DqZrAQVNj1Tct1Lody6GrYL/7uei5wfjeQdacFyFd2h/51LNlOoBMzIKMS9xohuL4+wlF/z1g/xg==",
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "seedrandom": "^3.0.5"
-      }
-    },
-    "node_modules/react-is": {
-      "version": "18.3.1",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/readable-stream": {
-      "version": "2.3.8",
-      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
-      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "core-util-is": "~1.0.0",
-        "inherits": "~2.0.3",
-        "isarray": "~1.0.0",
-        "process-nextick-args": "~2.0.0",
-        "safe-buffer": "~5.1.1",
-        "string_decoder": "~1.1.1",
-        "util-deprecate": "~1.0.1"
-      }
-    },
-    "node_modules/readdirp": {
-      "version": "3.6.0",
-      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
-      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "picomatch": "^2.2.1"
-      },
-      "engines": {
-        "node": ">=8.10.0"
-      }
-    },
-    "node_modules/readdirp/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/recast": {
-      "version": "0.23.11",
-      "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
-      "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ast-types": "^0.16.1",
-        "esprima": "~4.0.0",
-        "source-map": "~0.6.1",
-        "tiny-invariant": "^1.3.3",
-        "tslib": "^2.0.1"
-      },
-      "engines": {
-        "node": ">= 4"
-      }
-    },
-    "node_modules/rechoir": {
-      "version": "0.8.0",
-      "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
-      "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "resolve": "^1.20.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/remove-trailing-separator": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
-      "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/replace-ext": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz",
-      "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.10"
-      }
-    },
-    "node_modules/replace-homedir": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-2.0.0.tgz",
-      "integrity": "sha512-bgEuQQ/BHW0XkkJtawzrfzHFSN70f/3cNOiHa2QsYxqrjaC30X1k74FJ6xswVBP0sr0SpGIdVFuPwfrYziVeyw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/replacestream": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/replacestream/-/replacestream-4.0.3.tgz",
-      "integrity": "sha512-AC0FiLS352pBBiZhd4VXB1Ab/lh0lEgpP+GGvZqbQh8a5cmXVoTe5EX/YeTFArnp4SRGTHh1qCHu9lGs1qG8sA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "escape-string-regexp": "^1.0.3",
-        "object-assign": "^4.0.1",
-        "readable-stream": "^2.0.2"
-      }
-    },
-    "node_modules/require-directory": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
-      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/requires-port": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
-      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/resolve": {
-      "version": "1.22.10",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
-      "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-core-module": "^2.16.0",
-        "path-parse": "^1.0.7",
-        "supports-preserve-symlinks-flag": "^1.0.0"
-      },
-      "bin": {
-        "resolve": "bin/resolve"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/resolve-cwd": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
-      "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "resolve-from": "^5.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/resolve-dir": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
-      "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "expand-tilde": "^2.0.0",
-        "global-modules": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/resolve-from": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
-      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/resolve-options": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-2.0.0.tgz",
-      "integrity": "sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "value-or-function": "^4.0.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/resolve-pkg-maps": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
-      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
-      "dev": true,
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
-      }
-    },
-    "node_modules/resolve.exports": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
-      "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/reusify": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
-      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "iojs": ">=1.0.0",
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/rimraf": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
-      "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
-      "deprecated": "Rimraf versions prior to v4 are no longer supported",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "glob": "^7.1.3"
-      },
-      "bin": {
-        "rimraf": "bin.js"
-      }
-    },
-    "node_modules/rollup": {
-      "version": "4.59.0",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
-      "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/estree": "1.0.8"
-      },
-      "bin": {
-        "rollup": "dist/bin/rollup"
-      },
-      "engines": {
-        "node": ">=18.0.0",
-        "npm": ">=8.0.0"
-      },
-      "optionalDependencies": {
-        "@rollup/rollup-android-arm-eabi": "4.59.0",
-        "@rollup/rollup-android-arm64": "4.59.0",
-        "@rollup/rollup-darwin-arm64": "4.59.0",
-        "@rollup/rollup-darwin-x64": "4.59.0",
-        "@rollup/rollup-freebsd-arm64": "4.59.0",
-        "@rollup/rollup-freebsd-x64": "4.59.0",
-        "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
-        "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
-        "@rollup/rollup-linux-arm64-gnu": "4.59.0",
-        "@rollup/rollup-linux-arm64-musl": "4.59.0",
-        "@rollup/rollup-linux-loong64-gnu": "4.59.0",
-        "@rollup/rollup-linux-loong64-musl": "4.59.0",
-        "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
-        "@rollup/rollup-linux-ppc64-musl": "4.59.0",
-        "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
-        "@rollup/rollup-linux-riscv64-musl": "4.59.0",
-        "@rollup/rollup-linux-s390x-gnu": "4.59.0",
-        "@rollup/rollup-linux-x64-gnu": "4.59.0",
-        "@rollup/rollup-linux-x64-musl": "4.59.0",
-        "@rollup/rollup-openbsd-x64": "4.59.0",
-        "@rollup/rollup-openharmony-arm64": "4.59.0",
-        "@rollup/rollup-win32-arm64-msvc": "4.59.0",
-        "@rollup/rollup-win32-ia32-msvc": "4.59.0",
-        "@rollup/rollup-win32-x64-gnu": "4.59.0",
-        "@rollup/rollup-win32-x64-msvc": "4.59.0",
-        "fsevents": "~2.3.2"
-      }
-    },
-    "node_modules/rollup-plugin-dts": {
-      "version": "6.1.1",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.1.1.tgz",
-      "integrity": "sha512-aSHRcJ6KG2IHIioYlvAOcEq6U99sVtqDDKVhnwt70rW6tsz3tv5OSjEiWcgzfsHdLyGXZ/3b/7b/+Za3Y6r1XA==",
-      "dev": true,
-      "license": "LGPL-3.0-only",
-      "dependencies": {
-        "magic-string": "^0.30.10"
-      },
-      "engines": {
-        "node": ">=16"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/Swatinem"
-      },
-      "optionalDependencies": {
-        "@babel/code-frame": "^7.24.2"
-      },
-      "peerDependencies": {
-        "rollup": "^3.29.4 || ^4",
-        "typescript": "^4.5 || ^5.0"
-      }
-    },
-    "node_modules/rollup-plugin-esbuild": {
-      "version": "6.1.1",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-6.1.1.tgz",
-      "integrity": "sha512-CehMY9FAqJD5OUaE/Mi1r5z0kNeYxItmRO2zG4Qnv2qWKF09J2lTy5GUzjJR354ZPrLkCj4fiBN41lo8PzBUhw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@rollup/pluginutils": "^5.0.5",
-        "debug": "^4.3.4",
-        "es-module-lexer": "^1.3.1",
-        "get-tsconfig": "^4.7.2"
-      },
-      "engines": {
-        "node": ">=14.18.0"
-      },
-      "peerDependencies": {
-        "esbuild": ">=0.18.0",
-        "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0"
-      }
-    },
-    "node_modules/rollup-plugin-modify": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-modify/-/rollup-plugin-modify-3.0.0.tgz",
-      "integrity": "sha512-p/ffs0Y2jz2dEnWjq1oVC7SY37tuS+aP7whoNaQz1EAAOPg+k3vKJo8cMMWx6xpdd0NzhX4y2YF9o/NPu5YR0Q==",
-      "dev": true,
-      "license": "WTFPL",
-      "dependencies": {
-        "magic-string": "0.25.2",
-        "ospec": "3.1.0"
-      }
-    },
-    "node_modules/rollup-plugin-modify/node_modules/magic-string": {
-      "version": "0.25.2",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.2.tgz",
-      "integrity": "sha512-iLs9mPjh9IuTtRsqqhNGYcZXGei0Nh/A4xirrsqW7c+QhKVFL2vm7U09ru6cHRD22azaP/wMDgI+HCqbETMTtg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "sourcemap-codec": "^1.4.4"
-      }
-    },
-    "node_modules/rollup-plugin-node-externals": {
-      "version": "7.1.3",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-7.1.3.tgz",
-      "integrity": "sha512-RM+7tJAejAoRsCf93TptTSdqUhRA8S78DleihMiu54Kac+uLkd9VIegLPhGnaW3ehZTXh56+R301mFH6j2A7vw==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "patreon",
-          "url": "https://patreon.com/Septh"
-        },
-        {
-          "type": "paypal",
-          "url": "https://paypal.me/septh07"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">= 21 || ^20.6.0 || ^18.19.0"
-      },
-      "peerDependencies": {
-        "rollup": "^3.0.0 || ^4.0.0"
-      }
-    },
-    "node_modules/run-async": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz",
-      "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.12.0"
-      }
-    },
-    "node_modules/rxjs": {
-      "version": "7.8.2",
-      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
-      "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "tslib": "^2.1.0"
-      }
-    },
-    "node_modules/safe-buffer": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
-      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/safer-buffer": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
-      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/saxes": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
-      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "xmlchars": "^2.2.0"
-      },
-      "engines": {
-        "node": ">=v12.22.7"
-      }
-    },
-    "node_modules/seedrandom": {
-      "version": "3.0.5",
-      "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
-      "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/semver": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
-      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      }
-    },
-    "node_modules/semver-greatest-satisfied-range": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-2.0.0.tgz",
-      "integrity": "sha512-lH3f6kMbwyANB7HuOWRMlLCa2itaCrZJ+SAqqkSZrZKO/cAsk2EOyaKHUtNkVLFyFW9pct22SFesFp3Z7zpA0g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "sver": "^1.8.3"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/shallow-clone": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
-      "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "kind-of": "^6.0.2"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/shebang-command": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
-      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "shebang-regex": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/shebang-regex": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
-      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/signal-exit": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
-      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=14"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/sisteransi": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
-      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/slash": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
-      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/source-map": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/source-map-support": {
-      "version": "0.5.13",
-      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
-      "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "buffer-from": "^1.0.0",
-        "source-map": "^0.6.0"
-      }
-    },
-    "node_modules/sourcemap-codec": {
-      "version": "1.4.8",
-      "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
-      "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
-      "deprecated": "Please use @jridgewell/sourcemap-codec instead",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/sparkles": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-2.1.0.tgz",
-      "integrity": "sha512-r7iW1bDw8R/cFifrD3JnQJX0K1jqT0kprL48BiBpLZLJPmAm34zsVBsK5lc7HirZYZqMW65dOXZgbAGt/I6frg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/sprintf-js": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-      "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
-      "dev": true,
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/stack-utils": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
-      "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "escape-string-regexp": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/stack-utils/node_modules/escape-string-regexp": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
-      "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/stream-composer": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz",
-      "integrity": "sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "streamx": "^2.13.2"
-      }
-    },
-    "node_modules/stream-exhaust": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz",
-      "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/streamx": {
-      "version": "2.22.1",
-      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz",
-      "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "fast-fifo": "^1.3.2",
-        "text-decoder": "^1.1.0"
-      },
-      "optionalDependencies": {
-        "bare-events": "^2.2.0"
-      }
-    },
-    "node_modules/string_decoder": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
-      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "safe-buffer": "~5.1.0"
-      }
-    },
-    "node_modules/string-length": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
-      "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "char-regex": "^1.0.2",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/string-width": {
-      "version": "4.2.3",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
-      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "emoji-regex": "^8.0.0",
-        "is-fullwidth-code-point": "^3.0.0",
-        "strip-ansi": "^6.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/string-width-cjs": {
-      "name": "string-width",
-      "version": "4.2.3",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
-      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "emoji-regex": "^8.0.0",
-        "is-fullwidth-code-point": "^3.0.0",
-        "strip-ansi": "^6.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/strip-ansi": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
-      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-regex": "^5.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/strip-ansi-cjs": {
-      "name": "strip-ansi",
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
-      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-regex": "^5.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/strip-bom": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
-      "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/strip-final-newline": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
-      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/strip-json-comments": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
-      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/sucrase": {
-      "version": "3.34.0",
-      "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
-      "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/gen-mapping": "^0.3.2",
-        "commander": "^4.0.0",
-        "glob": "7.1.6",
-        "lines-and-columns": "^1.1.6",
-        "mz": "^2.7.0",
-        "pirates": "^4.0.1",
-        "ts-interface-checker": "^0.1.9"
-      },
-      "bin": {
-        "sucrase": "bin/sucrase",
-        "sucrase-node": "bin/sucrase-node"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/sucrase/node_modules/glob": {
-      "version": "7.1.6",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
-      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
-      "deprecated": "Glob versions prior to v9 are no longer supported",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "fs.realpath": "^1.0.0",
-        "inflight": "^1.0.4",
-        "inherits": "2",
-        "minimatch": "^3.0.4",
-        "once": "^1.3.0",
-        "path-is-absolute": "^1.0.0"
-      },
-      "engines": {
-        "node": "*"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/supports-preserve-symlinks-flag": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
-      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/sver": {
-      "version": "1.8.4",
-      "resolved": "https://registry.npmjs.org/sver/-/sver-1.8.4.tgz",
-      "integrity": "sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==",
-      "dev": true,
-      "license": "MIT",
-      "optionalDependencies": {
-        "semver": "^6.3.0"
-      }
-    },
-    "node_modules/symbol-tree": {
-      "version": "3.2.4",
-      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
-      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/sync-fetch": {
-      "version": "0.4.5",
-      "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz",
-      "integrity": "sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "buffer": "^5.7.1",
-        "node-fetch": "^2.6.1"
-      },
-      "engines": {
-        "node": ">=14"
-      }
-    },
-    "node_modules/synckit": {
-      "version": "0.11.8",
-      "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz",
-      "integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@pkgr/core": "^0.2.4"
-      },
-      "engines": {
-        "node": "^14.18.0 || >=16.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/synckit"
-      }
-    },
-    "node_modules/teex": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
-      "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "streamx": "^2.12.5"
-      }
-    },
-    "node_modules/temp": {
-      "version": "0.9.4",
-      "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
-      "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mkdirp": "^0.5.1",
-        "rimraf": "~2.6.2"
-      },
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/test-exclude": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
-      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "@istanbuljs/schema": "^0.1.2",
-        "glob": "^7.1.4",
-        "minimatch": "^3.0.4"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/text-decoder": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
-      "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "b4a": "^1.6.4"
-      }
-    },
-    "node_modules/textextensions": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-3.3.0.tgz",
-      "integrity": "sha512-mk82dS8eRABNbeVJrEiN5/UMSCliINAuz8mkUwH4SwslkNP//gbEzlWNS5au0z5Dpx40SQxzqZevZkn+WYJ9Dw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://bevry.me/fund"
-      }
-    },
-    "node_modules/thenify": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
-      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "any-promise": "^1.0.0"
-      }
-    },
-    "node_modules/thenify-all": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
-      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "thenify": ">= 3.1.0 < 4"
-      },
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
-    "node_modules/through2": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz",
-      "integrity": "sha512-45Llu+EwHKtAZYTPPVn3XZHBgakWMN3rokhEv5hu596XP+cNgplMg+Gj+1nmAvj+L0K7+N49zBKx5rah5u0QIQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "readable-stream": "~1.0.17",
-        "xtend": "~2.1.1"
-      }
-    },
-    "node_modules/through2/node_modules/isarray": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
-      "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/through2/node_modules/readable-stream": {
-      "version": "1.0.34",
-      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
-      "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "core-util-is": "~1.0.0",
-        "inherits": "~2.0.1",
-        "isarray": "0.0.1",
-        "string_decoder": "~0.10.x"
-      }
-    },
-    "node_modules/through2/node_modules/string_decoder": {
-      "version": "0.10.31",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
-      "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/tiny-invariant": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
-      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/tmp": {
-      "version": "0.0.33",
-      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
-      "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "os-tmpdir": "~1.0.2"
-      },
-      "engines": {
-        "node": ">=0.6.0"
-      }
-    },
-    "node_modules/tmpl": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
-      "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
-      "dev": true,
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/to-regex-range": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
-      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-number": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=8.0"
-      }
-    },
-    "node_modules/to-through": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/to-through/-/to-through-3.0.0.tgz",
-      "integrity": "sha512-y8MN937s/HVhEoBU1SxfHC+wxCHkV1a9gW8eAdTadYh/bGyesZIVcbjI+mSpFbSVwQici/XjBjuUyri1dnXwBw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "streamx": "^2.12.5"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/tough-cookie": {
-      "version": "4.1.4",
-      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
-      "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "psl": "^1.1.33",
-        "punycode": "^2.1.1",
-        "universalify": "^0.2.0",
-        "url-parse": "^1.5.3"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/tr46": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
-      "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "punycode": "^2.1.1"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/ts-interface-checker": {
-      "version": "0.1.13",
-      "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
-      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
-      "dev": true,
-      "license": "Apache-2.0"
-    },
-    "node_modules/ts-jest": {
-      "version": "29.4.0",
-      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz",
-      "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "bs-logger": "^0.2.6",
-        "ejs": "^3.1.10",
-        "fast-json-stable-stringify": "^2.1.0",
-        "json5": "^2.2.3",
-        "lodash.memoize": "^4.1.2",
-        "make-error": "^1.3.6",
-        "semver": "^7.7.2",
-        "type-fest": "^4.41.0",
-        "yargs-parser": "^21.1.1"
-      },
-      "bin": {
-        "ts-jest": "cli.js"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
-      },
-      "peerDependencies": {
-        "@babel/core": ">=7.0.0-beta.0 <8",
-        "@jest/transform": "^29.0.0 || ^30.0.0",
-        "@jest/types": "^29.0.0 || ^30.0.0",
-        "babel-jest": "^29.0.0 || ^30.0.0",
-        "jest": "^29.0.0 || ^30.0.0",
-        "jest-util": "^29.0.0 || ^30.0.0",
-        "typescript": ">=4.3 <6"
-      },
-      "peerDependenciesMeta": {
-        "@babel/core": {
-          "optional": true
-        },
-        "@jest/transform": {
-          "optional": true
-        },
-        "@jest/types": {
-          "optional": true
-        },
-        "babel-jest": {
-          "optional": true
-        },
-        "esbuild": {
-          "optional": true
-        },
-        "jest-util": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/ts-jest/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/ts-jest/node_modules/type-fest": {
-      "version": "4.41.0",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
-      "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
-      "dev": true,
-      "license": "(MIT OR CC0-1.0)",
-      "engines": {
-        "node": ">=16"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/ts-jest/node_modules/yargs-parser": {
-      "version": "21.1.1",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
-      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/tslib": {
-      "version": "2.6.2",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
-      "dev": true,
-      "license": "0BSD"
-    },
-    "node_modules/type-detect": {
-      "version": "4.0.8",
-      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
-      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/type-fest": {
-      "version": "0.21.3",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
-      "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
-      "dev": true,
-      "license": "(MIT OR CC0-1.0)",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/typescript": {
-      "version": "5.8.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
-      "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "bin": {
-        "tsc": "bin/tsc",
-        "tsserver": "bin/tsserver"
-      },
-      "engines": {
-        "node": ">=14.17"
-      }
-    },
-    "node_modules/unc-path-regex": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
-      "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/undertaker": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-2.0.0.tgz",
-      "integrity": "sha512-tO/bf30wBbTsJ7go80j0RzA2rcwX6o7XPBpeFcb+jzoeb4pfMM2zUeSDIkY1AWqeZabWxaQZ/h8N9t35QKDLPQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "bach": "^2.0.1",
-        "fast-levenshtein": "^3.0.0",
-        "last-run": "^2.0.0",
-        "undertaker-registry": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/undertaker-registry": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-2.0.0.tgz",
-      "integrity": "sha512-+hhVICbnp+rlzZMgxXenpvTxpuvA67Bfgtt+O9WOE5jo7w/dyiF1VmoZVIHvP2EkUjsyKyTwYKlLhA+j47m1Ew==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/undici-types": {
-      "version": "6.21.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
-      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/universalify": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
-      "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 4.0.0"
-      }
-    },
-    "node_modules/unrs-resolver": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
-      "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "napi-postinstall": "^0.3.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/unrs-resolver"
-      },
-      "optionalDependencies": {
-        "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
-        "@unrs/resolver-binding-android-arm64": "1.11.1",
-        "@unrs/resolver-binding-darwin-arm64": "1.11.1",
-        "@unrs/resolver-binding-darwin-x64": "1.11.1",
-        "@unrs/resolver-binding-freebsd-x64": "1.11.1",
-        "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
-        "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
-        "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
-        "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
-        "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
-        "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
-        "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
-        "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
-        "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
-        "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
-        "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
-        "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
-        "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
-        "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
-      }
-    },
-    "node_modules/update-browserslist-db": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
-      "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/browserslist"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "escalade": "^3.2.0",
-        "picocolors": "^1.1.1"
-      },
-      "bin": {
-        "update-browserslist-db": "cli.js"
-      },
-      "peerDependencies": {
-        "browserslist": ">= 4.21.0"
-      }
-    },
-    "node_modules/url-parse": {
-      "version": "1.5.10",
-      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
-      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "querystringify": "^2.1.1",
-        "requires-port": "^1.0.0"
-      }
-    },
-    "node_modules/util-deprecate": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/v8-to-istanbul": {
-      "version": "9.3.0",
-      "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
-      "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.12",
-        "@types/istanbul-lib-coverage": "^2.0.1",
-        "convert-source-map": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10.12.0"
-      }
-    },
-    "node_modules/v8flags": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz",
-      "integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/value-or-function": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz",
-      "integrity": "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/vinyl": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz",
-      "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "clone": "^2.1.1",
-        "clone-buffer": "^1.0.0",
-        "clone-stats": "^1.0.0",
-        "cloneable-readable": "^1.0.0",
-        "remove-trailing-separator": "^1.0.1",
-        "replace-ext": "^1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.10"
-      }
-    },
-    "node_modules/vinyl-contents": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/vinyl-contents/-/vinyl-contents-2.0.0.tgz",
-      "integrity": "sha512-cHq6NnGyi2pZ7xwdHSW1v4Jfnho4TEGtxZHw01cmnc8+i7jgR6bRnED/LbrKan/Q7CvVLbnvA5OepnhbpjBZ5Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "bl": "^5.0.0",
-        "vinyl": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/vinyl-contents/node_modules/replace-ext": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz",
-      "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/vinyl-contents/node_modules/vinyl": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz",
-      "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "clone": "^2.1.2",
-        "remove-trailing-separator": "^1.1.0",
-        "replace-ext": "^2.0.0",
-        "teex": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/vinyl-fs": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz",
-      "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "fs-mkdirp-stream": "^2.0.1",
-        "glob-stream": "^8.0.3",
-        "graceful-fs": "^4.2.11",
-        "iconv-lite": "^0.6.3",
-        "is-valid-glob": "^1.0.0",
-        "lead": "^4.0.0",
-        "normalize-path": "3.0.0",
-        "resolve-options": "^2.0.0",
-        "stream-composer": "^1.0.2",
-        "streamx": "^2.14.0",
-        "to-through": "^3.0.0",
-        "value-or-function": "^4.0.0",
-        "vinyl": "^3.0.1",
-        "vinyl-sourcemap": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/vinyl-fs/node_modules/iconv-lite": {
-      "version": "0.6.3",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
-      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/vinyl-fs/node_modules/replace-ext": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz",
-      "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/vinyl-fs/node_modules/vinyl": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz",
-      "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "clone": "^2.1.2",
-        "remove-trailing-separator": "^1.1.0",
-        "replace-ext": "^2.0.0",
-        "teex": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/vinyl-sourcemap": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-2.0.0.tgz",
-      "integrity": "sha512-BAEvWxbBUXvlNoFQVFVHpybBbjW1r03WhohJzJDSfgrrK5xVYIDTan6xN14DlyImShgDRv2gl9qhM6irVMsV0Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "convert-source-map": "^2.0.0",
-        "graceful-fs": "^4.2.10",
-        "now-and-later": "^3.0.0",
-        "streamx": "^2.12.5",
-        "vinyl": "^3.0.0",
-        "vinyl-contents": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/vinyl-sourcemap/node_modules/replace-ext": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz",
-      "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/vinyl-sourcemap/node_modules/vinyl": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz",
-      "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "clone": "^2.1.2",
-        "remove-trailing-separator": "^1.1.0",
-        "replace-ext": "^2.0.0",
-        "teex": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
-    },
-    "node_modules/vue-jscodeshift-adapter": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/vue-jscodeshift-adapter/-/vue-jscodeshift-adapter-2.2.1.tgz",
-      "integrity": "sha512-4aTkHYknYgP9uk/465MDZjvrotF6o2RMWDy0t+9RUULfgbkT+rHLrNw8onxOk4Y8fCpgcS81b09afodRZY/LuQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "vue-sfc-descriptor-to-string": "^1.0.0",
-        "vue-template-compiler": "^2.5.13"
-      }
-    },
-    "node_modules/vue-sfc-descriptor-to-string": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/vue-sfc-descriptor-to-string/-/vue-sfc-descriptor-to-string-1.0.0.tgz",
-      "integrity": "sha512-VYNMsrIPZQZau5Gk8IVtgonN1quOznP9/pLIF5m2c4R30KCDDe3NwthrsM7lSUY2K4lezcb8j3Wu8cQhBuZEMQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "indent-string": "^3.2.0"
-      }
-    },
-    "node_modules/vue-template-compiler": {
-      "version": "2.7.16",
-      "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
-      "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "de-indent": "^1.0.2",
-        "he": "^1.2.0"
-      }
-    },
-    "node_modules/w3c-xmlserializer": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
-      "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "xml-name-validator": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=14"
-      }
-    },
-    "node_modules/walker": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
-      "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "makeerror": "1.0.12"
-      }
-    },
-    "node_modules/webidl-conversions": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
-      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/whatwg-encoding": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
-      "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "iconv-lite": "0.6.3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/whatwg-encoding/node_modules/iconv-lite": {
-      "version": "0.6.3",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
-      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/whatwg-mimetype": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
-      "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/whatwg-url": {
-      "version": "11.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
-      "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "tr46": "^3.0.0",
-        "webidl-conversions": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/which": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
-      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "isexe": "^2.0.0"
-      },
-      "bin": {
-        "node-which": "bin/node-which"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
-    "node_modules/wrap-ansi": {
-      "version": "6.2.0",
-      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
-      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.0.0",
-        "string-width": "^4.1.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/wrap-ansi-cjs": {
-      "name": "wrap-ansi",
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.0.0",
-        "string-width": "^4.1.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-      }
-    },
-    "node_modules/wrappy": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/write-file-atomic": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
-      "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "imurmurhash": "^0.1.4",
-        "signal-exit": "^4.0.1"
-      },
-      "engines": {
-        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
-      }
-    },
-    "node_modules/ws": {
-      "version": "8.18.3",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
-      "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.0.0"
-      },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": ">=5.0.2"
-      },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/xml-name-validator": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
-      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/xmlchars": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
-      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/xtend": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz",
-      "integrity": "sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==",
-      "dev": true,
-      "dependencies": {
-        "object-keys": "~0.4.0"
-      },
-      "engines": {
-        "node": ">=0.4"
-      }
-    },
-    "node_modules/y18n": {
-      "version": "5.0.8",
-      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
-      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/yallist": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
-      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/yargs": {
-      "version": "16.2.0",
-      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
-      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "cliui": "^7.0.2",
-        "escalade": "^3.1.1",
-        "get-caller-file": "^2.0.5",
-        "require-directory": "^2.1.1",
-        "string-width": "^4.2.0",
-        "y18n": "^5.0.5",
-        "yargs-parser": "^20.2.2"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/yargs-parser": {
-      "version": "22.0.0",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
-      "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": "^20.19.0 || ^22.12.0 || >=23"
-      }
-    },
-    "node_modules/yargs/node_modules/yargs-parser": {
-      "version": "20.2.9",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
-      "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/yazl": {
-      "version": "2.5.1",
-      "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz",
-      "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "buffer-crc32": "~0.2.3"
-      }
-    },
-    "node_modules/yocto-queue": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
-      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/yoctocolors-cjs": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
-      "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    }
-  }
-}
diff --git a/functions/metadata/package.json b/functions/metadata/package.json
index 488791b..a740ef2 100644
--- a/functions/metadata/package.json
+++ b/functions/metadata/package.json
@@ -1,27 +1,25 @@
 {
   "name": "@jspsych/metadata",
-  "version": "0.0.1",
-  "description": "jsPsych plugin for creating and customizing metadata according to Psych-DS standards.",
+  "version": "0.0.3",
+  "description": "jsPsych package for creating and customizing metadata according to Psych-DS standards.",
   "type": "module",
-  "main": "dist/index.cjs",
+  "main": "dist/index.js",
   "exports": {
-    "import": "./dist/index.js",
-    "require": "./dist/index.cjs"
+    ".": {
+      "import": {
+        "node": "./dist/index.js",
+        "default": "./dist/index.esm.js"
+      },
+      "require": "./dist/index.cjs",
+      "types": "./dist/index.d.ts"
+    }
   },
   "typings": "dist/index.d.ts",
   "unpkg": "dist/index.browser.min.js",
   "files": [
-    "src",
     "dist"
   ],
   "source": "src/index.ts",
-  "scripts": {
-    "test": "jest",
-    "test:watch": "npm test -- --watch",
-    "tsc": "tsc",
-    "build": "rollup --config",
-    "build:watch": "npm run build -- --watch"
-  },
   "repository": {
     "type": "git",
     "url": "git+https://github.com/jspsych/jsPsych.git",
@@ -33,13 +31,7 @@
     "url": "https://github.com/jspsych/jsPsych/issues"
   },
   "homepage": "https://www.jspsych.org/latest/metadata",
-  "peerDependencies": {
-    "jspsych": "^8.0.0"
-  },
-  "devDependencies": {
-    "@jspsych/config": "^3.2.2",
-    "@jspsych/test-utils": "^1.1.2",
-    "@types/jest": "^29.5.12",
-    "ts-jest": "^29.1.4"
+  "dependencies": {
+    "csv-parse": "^5.5.6"
   }
 }
diff --git a/functions/metadata/rollup.config.mjs b/functions/metadata/rollup.config.mjs
deleted file mode 100644
index 4661e14..0000000
--- a/functions/metadata/rollup.config.mjs
+++ /dev/null
@@ -1,3 +0,0 @@
-import { makeRollupConfig } from "@jspsych/config/rollup";
-
-export default makeRollupConfig("jsPsychMetadata");
diff --git a/functions/metadata/src/AuthorsMap.ts b/functions/metadata/src/AuthorsMap.ts
deleted file mode 100644
index ec9ff03..0000000
--- a/functions/metadata/src/AuthorsMap.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * Interface that defines the type for the fields that are specified for authors
- * according to Psych-DS regulations, with name being the one required field.
- *
- * @export
- * @interface AuthorFields
- * @typedef {AuthorFields}
- */
-export interface AuthorFields {
-  /** The type of the author. */
-  type?: string;
-  /** The name of the author. (required) */
-  name: string;
-  /** The given name of the author. */
-  givenName?: string;
-  /** The family name of the author. */
-  familyName?: string;
-  /** The identifier that distinguishes the author across datasets (URL). */
-  identifier?: string;
-}
-
-/**
- * Class that helps keep track of authors and allows for easy conversion to list format when
- * generating the final Metadata file.
- *
- * @export
- * @class AuthorsMap
- * @typedef {AuthorsMap}
- */
-export class AuthorsMap {
-  /**
-   * Field that keeps track of the authors in a map.
-   *
-   * @private
-   * @type {({ [key: string]: AuthorFields | string })}
-   */
-  private authors: { [key: string]: AuthorFields | string };
-
-  /**
-   * Creates an empty instance of authors map. Doesn't generate default metadata because
-   * can't assume anything about the authors.
-   *
-   * @constructor
-   */
-  constructor() {
-    this.authors = {};
-  }
-
-  /**
-   * Returns the final list format of the authors according to Psych-DS standards.
-   *
-   * @returns {(AuthorFields | string)[]} - List of authors
-   */
-  getList(): (AuthorFields | string)[] {
-    const author_list = [];
-    for (const key of Object.keys(this.authors)) {
-      author_list.push(this.authors[key]);
-    }
-    return author_list;
-  }
-
-  /**
-   * Method that creates an author. This method can also be used to overwrite existing authors
-   * with the same name in order to update fields.
-   *
-   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
-   */
-  setAuthor(author: AuthorFields | string): void {
-    // Handling string input
-    if (typeof author === "string") {
-      this.authors[author] = author;
-      return;
-    }
-
-    if (!author.name) {
-      console.warn("Name field is missing. Author not added.");
-      return;
-    }
-
-    const { name, ...rest } = author;
-
-    if (Object.keys(rest).length == 0) {
-      this.authors[name] = name;
-    } else {
-      const newAuthor: AuthorFields = { name, ...rest };
-      this.authors[name] = newAuthor;
-
-      const unexpectedFields = Object.keys(author).filter(
-        (key) => !["type", "name", "givenName", "familyName", "identifier"].includes(key)
-      );
-      if (unexpectedFields.length > 0) {
-        console.warn(
-          `Unexpected fields (${unexpectedFields.join(
-            ", "
-          )}) detected and included in the author object.`
-        );
-      }
-    }
-  }
-
-  /**
-   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
-   *
-   * @param {string} name - Name of author to be used as key.
-   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
-   */
-  getAuthor(name: string): AuthorFields | string | {} {
-    if (name in this.authors) {
-      return this.authors[name];
-    } else {
-      console.warn("Author (", name, ") not found.");
-      return {};
-    }
-  }
-}
diff --git a/functions/metadata/src/VariablesMap.ts b/functions/metadata/src/VariablesMap.ts
deleted file mode 100644
index dcf5ed6..0000000
--- a/functions/metadata/src/VariablesMap.ts
+++ /dev/null
@@ -1,353 +0,0 @@
-/**
- * Interface that defines the type for the fields that are specified for variables
- * according to Psych-DS regulations, with name being the one required field.
- *
- * @export
- * @interface VariableFields
- * @typedef {VariableFields}
- */
-export interface VariableFields {
-  type?: string;
-  name: string; // required
-  description?: string | Record;
-  value?: string; // string, boolean, or number
-  identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
-  minValue?: number;
-  maxValue?: number;
-  levels?: string[] | []; // technically property values in the other one but not sure how to format it
-  levelsOrdered?: boolean;
-  na?: boolean;
-  naValue?: string;
-  alternateName?: string;
-  privacy?: string;
-}
-
-/**
- * Custom class that stores and handles the storage, update and retrieval of variable metadata.
- *
- * @export
- * @class VariablesMap
- * @typedef {VariablesMap}
- */
-export class VariablesMap {
-  /**
-   * Field that holds a map of the current variables allowing for fast look-up.
-   *
-   * @private
-   * @type {{ [key: string]: VariableFields }}
-   */
-  private variables: { [key: string]: VariableFields };
-
-  /**
-   *  Creates the VariablesMap bycalling generateDefaultVariables() method to
-   * generate the basic metadata common to every dataset_description.json file.
-   *
-   * @constructor
-   */
-  constructor() {
-    this.generateDefaultVariables();
-  }
-
-  /**
-   * Generates the default variables shared between every JsPsych experiment and fills in
-   * with default descriptions according to JsPsych documentation.
-   */
-  generateDefaultVariables(): void {
-    this.variables = {};
-
-    const trial_type_var: VariableFields = {
-      type: "PropertyValue",
-      name: "trial_type",
-      description: {
-        default: "unknown",
-        jsPsych: "The name of the plugin used to run the trial.",
-      },
-      value: "string",
-    };
-    this.setVariable(trial_type_var);
-
-    const trial_index_var: VariableFields = {
-      type: "PropertyValue",
-      name: "trial_index",
-      description: {
-        default: "unknown",
-        jsPsych: "The index of the current trial across the whole experiment.",
-      },
-      value: "numeric",
-    };
-    this.setVariable(trial_index_var);
-
-    const time_elapsed_var: VariableFields = {
-      type: "PropertyValue",
-      name: "time_elapsed",
-      description: {
-        default: "unknown",
-        jsPsych:
-          "The number of milliseconds between the start of the experiment and when the trial ended.",
-      },
-      value: "numeric",
-    };
-    this.setVariable(time_elapsed_var);
-  }
-
-  /**
-   * Returns a list of the variables instead of an object according to the Psych-DS format.
-   *
-   * @returns {{}[]} - The list of variables represented as objects.
-   */
-  getList(): {}[] {
-    var var_list = [];
-
-    // need to check that this works as intended
-    for (const key of Object.keys(this.variables)) {
-      const variable = this.variables[key];
-      const description = variable["description"];
-      const numKeys = Object.keys(description).length;
-
-      if (numKeys === 0) console.error("Empty description"); // error: description empty
-      else if (numKeys === 1) {
-        // description becomes single field (assumed to be default)
-        const key = Object.keys(description)[0];
-        variable["description"] = description[key];
-      } else if (numKeys == 2) {
-        delete description["default"]; // deletes default
-
-        if (Object.keys(description).length == 1) {
-          // error checking that it reduced to one key
-          const key = Object.keys(description)[0];
-          variable["description"] = description[key];
-        }
-      } else if (numKeys > 2) {
-        // deletes default
-        delete description["default"];
-      }
-
-      var_list.push(variable);
-    }
-    return var_list;
-  }
-
-  /**
-   * Allows user to set a variable and includes all the fields that are possible according to
-   * Psych-DS guidelines. Only requires the name field which it uses a key to map to the variable.
-   * Can also be used to overwrite existing variables if they have the same name.
-   *
-   * @param {VariableFields} variable - The fields of the variable that is being created.
-   */
-  setVariable(variable: VariableFields): void {
-    if (!variable.name) {
-      // Ensure name is provided
-      console.warn("Name field is missing. Variable not added.", variable);
-      return;
-    }
-
-    this.variables[variable.name] = variable;
-
-    const unexpectedFields = Object.keys(variable).filter(
-      (key) =>
-        ![
-          "type",
-          "name",
-          "description",
-          "value",
-          "identifier",
-          "minValue",
-          "maxValue",
-          "levels",
-          "levelsOrdered",
-          "na",
-          "naValue",
-          "alternateName",
-          "privacy",
-        ].includes(key)
-    );
-    if (unexpectedFields.length > 0) {
-      console.warn(
-        `Unexpected fields (${unexpectedFields.join(
-          ", "
-        )}) detected and included in the variable object.`
-      );
-    }
-  }
-
-  /**
-   * Allows you to get information for a single variable returning empty dict if it doesn't exist.
-   * Allows you to update fields but not recommended in favor of updateVariable.
-   *
-   * @param {string} name
-   * @returns {(VariableFields | {})} - Variable information or empty dict if doesn't exist
-   */
-  getVariable(name: string): VariableFields | {} {
-    return this.variables[name] || {};
-  }
-
-  /**
-   * Checks if variable exists in VariablesMap.
-   *
-   * @param {string} name - Name of variable
-   * @returns {boolean} - True if exists, false if doesn't.
-   */
-  containsVariable(name: string): boolean {
-    return name in this.variables;
-  }
-
-  /**
-   * Method that gets a list of the names of variables.
-   *
-   * @returns {string[]} - String list containing names of existing variables.
-   */
-  getVariableNames(): string[] {
-    var var_list = [];
-    for (const key of Object.keys(this.variables)) {
-      var_list.push(this.variables[key]["name"]);
-    }
-
-    return var_list;
-  }
-
-  /**
-   * Allows you to update a variable or add a value in the case of updating values. In other situations will
-   * replace the existing value with the new value. Has special cases and logic for levels and names making it
-   * easier to update variable values.
-   *
-   *
-   * @param {string} var_name - Name of variable to be updated.
-   * @param {string} field_name - Specific field to be updated.
-   * @param {(string | boolean | number | { [key: string]: string })} added_value - Single value to be updated, with a mapping if adding to description with key representing pluginType.
-   */
-  updateVariable(
-    var_name: string,
-    field_name: string,
-    added_value: string | boolean | number | { [key: string]: string }
-  ): void {
-    const updated_var = this.getVariable(var_name);
-
-    if (Object.keys(updated_var).length === 0) {
-      // error checking to see variable exists
-      console.error(`Variable "${var_name}" does not exist.`);
-      return;
-    }
-
-    if (field_name === "levels") {
-      this.updateLevels(updated_var, added_value);
-    } else if (field_name === "minValue" || field_name === "maxValue") {
-      this.updateMinMax(updated_var, added_value, field_name);
-    } else if (field_name === "description") {
-      this.updateDescription(updated_var, added_value);
-    } else if (field_name === "name") {
-      this.updateName(updated_var, added_value);
-    } else {
-      updated_var[field_name] = added_value;
-    }
-  }
-
-  /**
-   * Logic that handles updates to levels field by creating new array if necessary, otherwise
-   * pushing the value if it doesn't already exist. Levels can only be added to with strings.
-   *
-   * @private
-   * @param {*} updated_var - The variable object to be updated.
-   * @param {*} added_value - The value being added to the levels field.
-   */
-  private updateLevels(updated_var, added_value): void {
-    if (!Array.isArray(updated_var["levels"])) {
-      updated_var["levels"] = [];
-    }
-    if (!updated_var["levels"].includes(added_value)) {
-      updated_var["levels"].push(added_value);
-    }
-  }
-
-  /**
-   * Logic to update the min and max for the specific value.
-   *
-   * @private
-   * @param {*} updated_var - The variable object to be updated.
-   * @param {*} added_value - The value that is being checked against current min/max.
-   * @param {*} field_name - The name of field that is being checked (min or max).
-   */
-  private updateMinMax(updated_var, added_value, field_name): void {
-    // check if min or max
-    if (!("minValue" in updated_var) || !("maxValue" in updated_var)) {
-      updated_var["maxValue"] = updated_var["minValue"] = added_value;
-      return;
-    }
-
-    // redundant checks, including them because of current formatting but want to delete field_name
-    if (field_name === "minValue" && updated_var["minValue"] > added_value) {
-      updated_var["minValue"] = added_value;
-    } else if (field_name === "maxValue" && updated_var["maxValue"] < added_value) {
-      updated_var["maxValue"] = added_value;
-    }
-  }
-
-  /**
-   * Logic for updating description field that checks to see value already exists. If it does,
-   * appends the pluginType to the current key and pushes that along with the value. Creates
-   * map if it does not exist.
-   *
-   * @private
-   * @param {*} updated_var - The variable to be updated.
-   * @param {*} added_value - The value to be added with the key being the name of the plugin and the key being the description field.
-   */
-  private updateDescription(updated_var, added_value): void {
-    // getting key and value for new value for clarity
-    const add_key = Object.keys(added_value)[0];
-    const add_value = Object.values(added_value)[0];
-
-    if (add_key === "undefined" || add_value === "undefined") {
-      console.error("New value is passed in bad format", added_value);
-      return;
-    }
-
-    var exists = false;
-    // creates map for description if doesn't exist
-    if (typeof updated_var["description"] !== "object") {
-      updated_var["description"] = {};
-    }
-
-    // appends key to other keys if default value/description are the same already exist to keep metadata shorter
-    Object.entries(updated_var["description"]).forEach(([key, value]) => {
-      if (value === add_value) {
-        if (!key.includes(add_key)) {
-          // substring check to see it doesn't exist
-          delete updated_var["description"][key]; // deletes old version
-          updated_var["description"][key + ", " + add_key] = add_value;
-        }
-        exists = true;
-      }
-    });
-
-    // if value description doesn't exist previous, adds
-    if (!exists) Object.assign(updated_var["description"], added_value); // Assuming added_value is { chatplugin: "response that user input" }
-  }
-
-  /**
-   * Logic for updating name. Needs to retain all the old values while creating a new reference in the map
-   * while keeping the same perspe
-   *
-   * @private
-   * @param {*} updated_var
-   * @param {*} added_value
-   */
-  private updateName(updated_var, added_value): void {
-    const old_name = updated_var["name"];
-    updated_var["name"] = added_value;
-    delete this.variables[old_name];
-
-    this.setVariable(updated_var);
-  }
-
-  /**
-   * Allows you to delete a variable by key/name. Returns console error if not found.
-   *
-   * @param {string} var_name - Name of variable to be deleted.
-   */
-  deleteVariable(var_name: string): void {
-    if (var_name in this.variables) {
-      delete this.variables[var_name];
-    } else {
-      console.error(`Variable "${var_name}" does not exist.`);
-    }
-  }
-}
diff --git a/functions/metadata/src/index.ts b/functions/metadata/src/index.ts
deleted file mode 100644
index 649a3c0..0000000
--- a/functions/metadata/src/index.ts
+++ /dev/null
@@ -1,520 +0,0 @@
-import { AuthorFields } from "./AuthorsMap";
-import { AuthorsMap } from "./AuthorsMap";
-import { VariableFields } from "./VariablesMap";
-import { VariablesMap } from "./VariablesMap";
-
-/**
- * Class that handles the storage, update and retrieval of Metadata.
- *
- * @export
- * @class JsPsychMetadata
- * @typedef {JsPsychMetadata}
- */
-export default class JsPsychMetadata {
-  /**
-   * Field that contains all metadata fields that aren't represented as a list.
-   *
-   * @private
-   * @type {{}}
-   */
-  private metadata: {};
-  /**
-   * Custom class that stores and handles the storage, update and retrieval of author metadata.
-   *
-   * @private
-   * @type {AuthorsMap}
-   */
-  private authors: AuthorsMap;
-  /**
-   * Custom class that stores and handles the storage, update and retrieval of variable metadata.
-   *
-   * @private
-   * @type {VariablesMap}
-   */
-  private variables: VariablesMap;
-
-  /** The cache is a dictionary of dictionaries, with the outer dictionary keyed by type of plugin
-   * and the inner dictionary keyed by variableName. This is so that even if we have two variables
-   * with the same name in different plugins, we can store their descriptions separately.
-   * @private
-   * @type {{}}
-   */
-  private cache: {};
-  private requests_cache: {}; // temporary requests cache before implementing faster method
-
-  /**
-   * Creates an instance of JsPsychMetadata while passing in JsPsych object to have access to context
-   *  allowing it to access the screen printing information.
-   *
-   * @constructor
-   * @param {JsPsych} JsPsych
-   */
-  constructor() {
-    this.generateDefaultMetadata();
-  }
-  /**
-   * Method that fills in JsPsychMetadata class with all the universal fields with default information.
-   * This is automatically called whenever creating an instance of JsPsychMetadata and indicates all
-   * the required fields that need to filled in to be Psych-DS compliant.
-   */
-  generateDefaultMetadata(): void {
-    this.metadata = {};
-    this.setMetadataField("name", "title");
-    this.setMetadataField("schemaVersion", "Psych-DS 0.4.0");
-    this.setMetadataField("@context", "https://schema.org");
-    this.setMetadataField("@type", "Dataset");
-    this.setMetadataField("description", "Dataset generated using JsPsych");
-    this.authors = new AuthorsMap();
-    this.variables = new VariablesMap();
-    this.cache = {};
-    this.requests_cache = {};
-  }
-
-  /**
-   * Method that sets simple metadata fields. This method can also be used to update/overwrite existing fields.
-   *
-   * @param {string} key - Metadata field name
-   * @param {*} value - Data associated with the field
-   */
-  setMetadataField(key: string, value: any): void {
-    this.metadata[key] = value;
-  }
-
-  /**
-   * Simple get that accesses the data associated with a field.
-   *
-   * @param {string} key - Field name
-   * @returns {*} - Data associated with the field
-   */
-  getMetadataField(key: string): any {
-    return this.metadata[key];
-  }
-
-  /**
-   * Returns the final Metadata in a single javascript object. Bundles together the author and variables
-   * together in a list rather than object compliant with Psych-DS standards.
-   *
-   * @returns {{}} - Final Metadata object
-   */
-  getMetadata(): {} {
-    const res = this.metadata;
-    res["author"] = this.authors.getList();
-    res["variableMeasured"] = this.variables.getList();
-
-    return res;
-  }
-
-  /**
-   * Method that creates an author. This method can also be used to overwrite existing authors
-   * with the same name in order to update fields.
-   *
-   * @param {AuthorFields | string} author - All the required or possible fields associated with listing an author according to Psych-DS standards. Option as a string to define an author according only to name.
-   */
-  setAuthor(fields: AuthorFields): void {
-    this.authors.setAuthor(fields); // Assuming `authors` is an instance of the AuthorsMap class
-  }
-
-  /**
-   * Method that fetches an author object allowing user to update (in existing workflow should not be necessary).
-   *
-   * @param {string} name - Name of author to be used as key.
-   * @returns {(AuthorFields | string | {})} - Object with author information. Empty object if not found.
-   */
-  getAuthor(name: string): AuthorFields | string | {} {
-    return this.authors.getAuthor(name);
-  }
-
-  /**
-   * Method that creates a variable. This method can also be used to overwrite variables with the same name
-   * as a way to update fields.
-   *
-   * @param {{
-   *     type?: string;
-   *     name: string; // required
-   *     description?: string | {};
-   *     value?: string; // string, boolean, or number
-   *     identifier?: string; // identifier that distinguish across dataset (URL), confusing should check description
-   *     minValue?: number;
-   *     maxValue?: number;
-   *     levels?: string[] | []; // technically property values in the other one but not sure how to format it
-   *     levelsOrdered?: boolean;
-   *     na?: boolean;
-   *     naValue?: string;
-   *     alternateName?: string;
-   *     privacy?: string;
-   *   }} fields - Fields associated with the current Psych-DS standard.
-   */
-  setVariable(variable: VariableFields): void {
-    this.variables.setVariable(variable);
-  }
-
-  /**
-   * Allows you to access a variable's information by using the name of the variable. Can
-   * be used to update fields within a variable, but suggest using updateVariable() to prevent errors.
-   *
-   * @param {string} name - Name of variable to be accessed
-   * @returns {{}} - Returns object of fields
-   */
-  getVariable(name: string): {} {
-    return this.variables.getVariable(name);
-  }
-
-  containsVariable(name: string): boolean {
-    return this.variables.containsVariable(name);
-  }
-
-  /**
-   * Allows you to update a variable or add a value in the case of updating values. In other situations will
-   * replace the existing value with the new value.
-   *
-   * @param {string} var_name - Name of variable to be updated.
-   * @param {string} field_name - Name of field to be updated.
-   * @param {(string | boolean | number | {})} added_value - Value to be used in the update.
-   */
-  updateVariable(
-    var_name: string,
-    field_name: string,
-    added_value: string | boolean | number | {}
-  ): void {
-    this.variables.updateVariable(var_name, field_name, added_value);
-  }
-
-  /**
-   * Allows you to delete a variable by key/name.
-   *
-   * @param {string} var_name - Name of variable to be deleted.
-   */
-  deleteVariable(var_name: string): void {
-    this.variables.deleteVariable(var_name);
-  }
-
-  /**
-   * Gets a list of all the variable names.
-   *
-   * @returns {string[]} - List of variable string names.
-   */
-  getVariableNames(): string[] {
-    return this.variables.getVariableNames();
-  }
-
-  /**
-   * Method that allows you to display metadata at the end of an experiment.
-   *
-   * @param {string} [elementId="jspsych-metadata-display"] - Id for how to style the metadata. Defaults to default styling.
-   */
-  displayMetadata(display_element) {
-    const elementId = "jspsych-metadata-display";
-    const metadata_string = JSON.stringify(this.getMetadata(), null, 2);
-    // const display_element = this.JsPsych.getDisplayElement();
-    display_element.innerHTML += `

Metadata

`;
-    document.getElementById(elementId).textContent += metadata_string;
-  }
-
-  /**
-   * Method that begins a download for the dataset_description.json at the end of experiment.
-   * Allows you to download the metadat.
-   */
-  saveAsJsonFile(): void {
-    const jsonString = JSON.stringify(this.getMetadata(), null, 2);
-    const blob = new Blob([jsonString], { type: "application/json" });
-    const url = URL.createObjectURL(blob);
-
-    const a = document.createElement("a");
-    a.href = url;
-    a.download = "dataset_description.json";
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-
-    URL.revokeObjectURL(url);
-  }
-
-  /**
-   * Function to convert string csv into a javascript json object.
-   *
-   * Created by reversing function in datamodule using ChatGPT.
-   *
-   * @private
-   * @param {*} csv - CSV that is represented as string
-   * @returns {*} - Returns a json object
-   */
-  private CSV2JSON(csvString) {
-    const lines = csvString.split("\r\n");
-    const result = [];
-    const headers = lines[0].split(",").map((header) => header.replace(/""/g, '"').slice(1, -1));
-
-    for (let i = 1; i < lines.length; i++) {
-      if (!lines[i]) continue; // Skip empty lines
-      const obj = {};
-      const currentLine = lines[i]
-        .split(",")
-        .map((value) => value.replace(/""/g, '"').slice(1, -1));
-
-      headers.forEach((header, index) => {
-        const value = currentLine[index];
-        if (value !== undefined && value !== "") {
-          if (!isNaN(value)) {
-            obj[header] = parseFloat(value); // Convert to number if possible
-          } else if (value.toLowerCase() === "null") {
-            obj[header] = null; // Set as null if the string is "null"
-          } else {
-            try {
-              obj[header] = JSON.parse(value); // Try to parse as JSON (handles objects and arrays)
-            } catch (e) {
-              obj[header] = value; // Use the string value if parsing fails
-            }
-          }
-        }
-        // If value is undefined or empty, skip adding it to the object
-      });
-
-      if (Object.keys(obj).length > 0) {
-        result.push(obj);
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * Generates observations based on the input data and processes optional metadata.
-   *
-   * This method accepts data, which can be an array of observation objects, a JSON string,
-   * or a CSV string. If the data is in CSV format, set the `csv` parameter to `true` to
-   * parse it into a JSON object. Each observation is processed asynchronously using the
-   * `generateObservation` method. Optionally, metadata can be provided in the form of an
-   * object, and each key-value pair in the metadata object will be processed by the
-   * `processMetadata` method.
-   *
-   * @async
-   * @param {Array|String} data - The data to generate observations from. Can be an array of objects, a JSON string, or a CSV string.
-   * @param {Object} [metadata={}] - Optional metadata to be processed. Each key-value pair in this object will be processed individually.
-   * @param {boolean} [csv=false] - Flag indicating if the data is in a string CSV. If true, the data will be parsed as CSV.
-   */
-  async generate(data, metadata = {}, csv = false) {
-    if (csv) {
-      data = this.CSV2JSON(data);
-    } else if (typeof data === "string") {
-      data = JSON.parse(data);
-    }
-
-    if (typeof data !== "object") {
-      console.error("Unable to parse data object object, not in correct format");
-      return;
-    }
-
-    for (const observation of data) {
-      await this.generateObservation(observation);
-    }
-
-    for (const key in metadata) {
-      this.processMetadata(metadata, key);
-    }
-  }
-
-  private async generateObservation(observation) {
-    // variables can be thought of mapping of one column in a row
-    const pluginType = observation["trial_type"];
-    const ignored_fields = new Set(["trial_type", "trial_index", "time_elapsed"]);
-
-    for (const variable in observation) {
-      const value = observation[variable];
-
-      if (value === null) continue;
-
-      if (ignored_fields.has(variable)) this.updateFields(variable, value, typeof value);
-      else await this.generateMetadata(variable, value, pluginType);
-    }
-  }
-
-  private async generateMetadata(variable, value, pluginType) {
-    // probably should work in a call to the plugin here
-    const description = await this.getPluginInfo(pluginType, variable);
-    const new_description = description
-      ? { [pluginType]: description }
-      : { [pluginType]: "unknown" };
-    const type = typeof value;
-
-    if (!this.containsVariable(variable)) {
-      // probs should have update description called here
-      const new_var = {
-        type: "PropertyValue",
-        name: variable,
-        description: { default: "unknown" },
-        value: type,
-      };
-      this.setVariable(new_var);
-    }
-
-    // hit the update variable decription fields
-    this.updateVariable(variable, "description", new_description);
-    this.updateFields(variable, value, type);
-  }
-
-  private updateFields(variable, value, type) {
-    // calls updates where updateVariable handles logic
-    if (type === "number") {
-      this.updateVariable(variable, "minValue", value); // technically can refactor one call to do both but makes confusing
-      this.updateVariable(variable, "maxValue", value);
-      return;
-    }
-    // calls updates where updateVariable handles logic
-    if (type !== "number" && type !== "object") {
-      this.updateVariable(variable, "levels", value);
-    }
-  }
-
-  private processMetadata(metadata, key) {
-    const value = metadata[key];
-
-    // iterating through variables metadata
-    if (key === "variables") {
-      if (typeof value !== "object" || value === null) {
-        console.warn("Variable object is either null or incorrect type");
-        return;
-      }
-
-      // all of the variables must already exist because should have datapoints
-      for (let variable_key in value) {
-        if (!this.containsVariable(variable_key)) {
-          console.warn("Metadata does not contain variable:", variable_key);
-          continue;
-        }
-
-        const variable_parameters = value[variable_key];
-
-        if (typeof variable_parameters !== "object" || variable_parameters === null) {
-          console.warn(
-            "Parameters of variable:",
-            variable_key,
-            "is either null or incorrect type. The value",
-            variable_parameters,
-            "is either null or not an object."
-          );
-          continue;
-        }
-
-        // calling updates for each of the renamed parameters within variable/errors handled by method call
-        for (const parameter in variable_parameters) {
-          const parameter_value = variable_parameters[parameter];
-          this.updateVariable(variable_key, parameter, parameter_value);
-          if (parameter === "name") variable_key = parameter_value; // renames future instances if changing name
-        }
-      }
-    } // iterating through each individual author class
-    else if (key === "author") {
-      if (typeof value !== "object" || value === null) {
-        console.warn("Author object is not correct type");
-        return;
-      }
-
-      for (const author_key in value) {
-        const author = value[author_key];
-
-        if (typeof author !== "string" && !("name" in author)) author["name"] = author_key; // handles string case and empty name (uses handle)
-
-        this.setAuthor(author);
-      }
-    } else this.setMetadataField(key, value);
-  }
-
-  /**
-   * Gets the description of a variable in a plugin by fetching the source code of the plugin
-   * from a remote source (usually unpkg.com) as a string, passing the script to getJsdocsDescription
-   * to extract the description for the variable (present as JSDoc); caches the result for future use.
-   *
-   * @param {string} pluginType - The type of the plugin for which information is to be fetched.
-   * @param {string} variableName - The name of the variable for which information is to be fetched.
-   * @returns {Promise} The description of the plugin variable if found, otherwise null.
-   * @throws Will throw an error if the fetch operation fails.
-   */
-  private async getPluginInfo(pluginType: string, variableName: string) {
-    // Check if the cache for the pluginType exists, if not initialize it
-    if (!this.cache[pluginType]) this.cache[pluginType] = {};
-    else if (variableName in this.cache[pluginType]) {
-      // If the variable already exists in the cache for the plugin, return the cached value
-      return this.cache[pluginType][variableName];
-    }
-
-    // If not, we proceed to fetch script:
-    // Construct the URL for the unpkg service
-    const unpkgUrl = `https://unpkg.com/@jspsych/plugin-${pluginType}/src/index.ts`;
-
-    try {
-      let description = "unknown";
-      // check requests cache
-      if (pluginType in this.requests_cache) {
-        const scriptContent = this.requests_cache[pluginType];
-        description = this.getJsdocsDescription(scriptContent, variableName);
-        this.cache[pluginType][variableName] = description;
-      } else {
-        // Fetch the script content from the unpkg URL
-        const response = await fetch(unpkgUrl);
-        const scriptContent = await response.text();
-        this.requests_cache[pluginType] = scriptContent;
-
-        // Extract the JSDoc description for the variable from the script content
-        description = this.getJsdocsDescription(scriptContent, variableName);
-
-        // Check again if the cache for the pluginType exists, if not initialize it
-        if (!this.cache[pluginType]) this.cache[pluginType] = {}; // don't think this ever returns true, might be able delete
-
-        // Cache the description for the variable in the pluginType cache
-        this.cache[pluginType][variableName] = description;
-        // Return the description
-      }
-
-      return description;
-    } catch (error) {
-      console.error(`Failed to fetch info from ${unpkgUrl}:`, error); // DISABLING to test other features
-      // Error is likely due to 1)a fetch failure, or 2)no JSDoc comments in the script content matched.
-      //HANDLE FETCH FAILURE CASES
-      // In case of the latter, we cache the null value to prevent repeated fetch attempts.
-
-      if (!this.cache[pluginType]) this.cache[pluginType] = {};
-
-      this.cache[pluginType][variableName] = null;
-
-      return "failed with error";
-    }
-  }
-
-  /**
-   * Extracts the description for a variable of a plugin from the JSDoc comments present in the script of the plugin. The script content is
-   * drawn from the remotely hosted source file of the plugin through getPluginInfo. The script content is taken
-   * as a string and Regex is used to extract the description.
-   *
-   *
-   * @param {string} scriptContent - The content of the script from which the JSDoc description is to be extracted.
-   * @param {string} variableName - The name of the variable for which the JSDoc description is to be extracted.
-   * @returns {string} The extracted JSDoc description, cleaned and trimmed.
-   */
-  private getJsdocsDescription(scriptContent: string, variableName: string) {
-    // Regex to match part of the content that starts with 'parameters:' and ends with '};', which
-    // is parameters info. THIS MUST BE CHANGED TO data FOR NEW PLUGIN LAYOUT
-    const paramRegex = scriptContent.match(/parameters:\s*{([\s\S]*?)};\s*/).join();
-
-    // Regex that matches everything up to the variable name
-    const regex = new RegExp(`((.|\n)*)(?=${variableName}:)`);
-
-    // Regex on paramRegex, to get everything from 'paramaters:' to the variable name.
-    const variableRegex = paramRegex.match(regex)[0];
-
-    // Finds the index of the last occurence of `/**` in the variableRegex string, and slices it from there
-    // to give the JSDoc comment for our variable.
-    const descrip = variableRegex.slice(variableRegex.lastIndexOf("/**"));
-
-    // Regex to remove the leading and trailing '/**' and '*/' characters.
-    const clean = descrip.match(/(?<=\*\*)([\s\S]*?)(?=\*\/)/)[1];
-
-    //CLEANING:
-    // Regex to remove all newline characters.
-    const cleaner = clean.replace(/(\r\n|\n|\r)/gm, "");
-
-    // Remove all '*' characters from the JSDoc comment.
-    const cleanest = cleaner.replace(/\*/gm, "");
-
-    // Return the cleaned JSDoc comment, trimmed of leading and trailing whitespace
-    return cleanest.trim();
-  }
-}
diff --git a/functions/metadata/tests/metadata-maps.test.ts b/functions/metadata/tests/metadata-maps.test.ts
deleted file mode 100644
index 5a68ac0..0000000
--- a/functions/metadata/tests/metadata-maps.test.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-import { AuthorsMap } from "../src/AuthorsMap";
-import { VariablesMap } from "../src/VariablesMap";
-import { VariableFields } from "../src/VariablesMap";
-
-let author_data = [
-  {
-    name: "John Cena",
-    identifier: "www.johncena.com",
-  },
-  {
-    name: "Barrack Obama",
-  },
-  {
-    type: "Author",
-    name: "Donald Trump",
-  },
-  {
-    type: "Contributor",
-    name: "Stan Johnson",
-    givenName: "Julio Jones",
-    familyName: "Aaron",
-    identifier: "www.stantheman",
-  },
-];
-
-describe("AuthorsMap", () => {
-  let authors: AuthorsMap;
-
-  beforeEach(() => {
-    authors = new AuthorsMap();
-    for (const a of author_data) {
-      authors.setAuthor(a);
-    }
-  });
-
-  test("#setAndGetAuthor", () => {
-    expect(authors.getAuthor(author_data[0]["name"])).toStrictEqual(author_data[0]);
-    expect(authors.getAuthor(author_data[1]["name"])).toStrictEqual(author_data[1]["name"]); // when only name, writes string not object according to Psych-DS standards
-    expect(authors.getAuthor(author_data[2]["name"])).toStrictEqual(author_data[2]);
-    expect(authors.getAuthor(author_data[3]["name"])).toStrictEqual(author_data[3]);
-  });
-
-  test("#setOverwrite", () => {
-    const newJohnCena = {
-      type: "WWE Pro Wrestler",
-      name: "John Cena",
-    };
-
-    authors.setAuthor(newJohnCena);
-    expect(authors.getAuthor("John Cena")).toStrictEqual(newJohnCena);
-    expect(authors.getAuthor("John Cena")).not.toStrictEqual(author_data[0]);
-  });
-
-  test("#getList", () => {
-    const compare: (VariableFields | {})[] = [];
-
-    for (const a of author_data) {
-      compare.push(authors.getAuthor(a["name"]));
-    }
-
-    expect(authors.getList()).toStrictEqual(compare);
-  });
-});
-
-const variable_data: VariableFields[] = [
-  {
-    type: "PropertyValue",
-    name: "trial_type",
-    description: {
-      default: "unknown",
-      jsPsych: "The name of the plugin used to run the trial.",
-    },
-    value: "string",
-  },
-  {
-    type: "PropertyValue",
-    name: "trial_index",
-    description: {
-      default: "unknown",
-      jsPsych: "The index of the current trial across the whole experiment.",
-    },
-    value: "numeric",
-  },
-  {
-    type: "PropertyValue",
-    name: "time_elapsed",
-    description: {
-      default: "unknown",
-      jsPsych:
-        "The number of milliseconds between the start of the experiment and when the trial ended.",
-    },
-    value: "numeric",
-  },
-];
-
-describe("VariablesMap", () => {
-  let variablesMap: VariablesMap;
-
-  beforeEach(() => {
-    variablesMap = new VariablesMap();
-  });
-
-  test("#setAndGetVariable", () => {
-    expect(variablesMap.getVariable(variable_data[0]["name"])).toStrictEqual(variable_data[0]);
-    expect(variablesMap.getVariable(variable_data[1]["name"])).toStrictEqual(variable_data[1]);
-    expect(variablesMap.getVariable(variable_data[2]["name"])).toStrictEqual(variable_data[2]);
-  });
-
-  test("#setOverwrite", () => {
-    const newTrialType: VariableFields = {
-      type: "PropertyValue",
-      name: "trial_type",
-      description: {
-        default: "different fields",
-        jsPsych: "checking what this is",
-      },
-      value: "string",
-    };
-
-    variablesMap.setVariable(newTrialType);
-    expect(variablesMap.getVariable("trial_type")).toStrictEqual(newTrialType);
-    expect(variablesMap.getVariable("trial_type")).not.toStrictEqual(variable_data[0]);
-  });
-
-  test("#getList", () => {
-    const compare: (VariableFields | {})[] = [];
-
-    for (const v of variable_data) {
-      compare.push(variablesMap.getVariable(v["name"]));
-    }
-
-    expect(variablesMap.getList()).toStrictEqual(compare);
-  });
-
-  test("#deleteVariables", () => {
-    variablesMap.deleteVariable("trial_type");
-    variablesMap.deleteVariable("trial_index");
-    variablesMap.deleteVariable("time_elapsed");
-
-    expect(variablesMap.getList().length).toBe(0);
-  });
-
-  // // updating normal variable (exists and doesn't exist)
-  test("#updateNormalVariables", () => {
-    const compare = {
-      type: "PropertyValue",
-      name: "trial_type",
-      description: {
-        default: "unknown",
-        jsPsych: "The name of the plugin used to run the trial.",
-      },
-      value: "string",
-    };
-
-    const new_description = { plugin: "new description that is super informative!" };
-    const new_min_value = 0;
-    const new_max_value = 100;
-
-    variablesMap.updateVariable("trial_type", "description", new_description);
-    variablesMap.updateVariable("trial_type", "minValue", new_min_value);
-    variablesMap.updateVariable("trial_type", "maxValue", new_max_value);
-
-    compare.description = { ...compare.description, ...new_description };
-    compare["minValue"] = new_min_value;
-    compare["maxValue"] = new_max_value;
-
-    expect(variablesMap.getVariable("trial_type")).toStrictEqual(compare);
-  });
-
-  test("#updateLevels", () => {
-    interface Compare {
-      type: string;
-      name: string;
-      description: {};
-      value: string;
-      levels: any[]; // Use specific type if known, e.g., string[] if levels contain strings
-    }
-
-    const compare: Compare = {
-      type: "PropertyValue",
-      name: "trial_type",
-      description: {
-        default: "unknown",
-        jsPsych: "The name of the plugin used to run the trial.",
-      },
-      value: "string",
-      levels: [],
-    };
-
-    const level1: string = "

hello world

"; - const level2: string = "

BOOOOOOO

"; - const level3: string = "

......spot me......

"; - - variablesMap.updateVariable("trial_type", "levels", level1); - variablesMap.updateVariable("trial_type", "levels", level2); - variablesMap.updateVariable("trial_type", "levels", level3); - - compare["levels"].push(level1); - compare["levels"].push(level2); - compare["levels"].push(level3); - - expect(variablesMap.getVariable("trial_type")).toStrictEqual(compare); - }); - - // // updating name (checking references) - test("#updatingName", () => { - const compare = { - type: "PropertyValue", - name: "trial_type", - description: { - default: "unknown", - jsPsych: "The name of the plugin used to run the trial.", - }, - value: "string", - }; - const newName = "trial_type_updated"; - - variablesMap.updateVariable("trial_type", "name", newName); - compare["name"] = newName; - - expect(variablesMap.getVariable(newName)).toStrictEqual(compare); - }); - - test("#gettingListOneKey", () => { - for (const variable of variable_data) { - variablesMap.deleteVariable(variable["name"]); - } - - let one_key_string = { - type: "PropertyValue", - name: "animation style", - description: "unknown", - value: "string", - }; - - variablesMap.setVariable(one_key_string); - expect([one_key_string]).toStrictEqual(variablesMap.getList()); - }); - - test("#gettingListTwoUniqueKey", () => { - for (const variable of variable_data) { - variablesMap.deleteVariable(variable["name"]); - } - - let two_key = { - type: "PropertyValue", - name: "animation style", - description: { - grown: "how tall the user is", - diet: "what the user likes to eat", - }, - value: "string", - }; - - variablesMap.setVariable(two_key); - expect([two_key]).toStrictEqual(variablesMap.getList()); - }); - - test("#gettingListTwoKeyDefault", () => { - for (const variable of variable_data) { - variablesMap.deleteVariable(variable["name"]); - } - - let add_default = { - type: "PropertyValue", - name: "animation style", - description: { - default: "how tall the user is", - diet: "what the user likes to eat", - }, - value: "string", - }; - - let expected = { - type: "PropertyValue", - name: "animation style", - description: "what the user likes to eat", - value: "string", - }; - - variablesMap.setVariable(add_default); - expect([expected]).toStrictEqual(variablesMap.getList()); - }); - - test("#gettingListThreeKeyDefault", () => { - for (const variable of variable_data) { - variablesMap.deleteVariable(variable["name"]); - } - - let add_default = { - type: "PropertyValue", - name: "animation style", - description: { - default: "how tall the user is", - diet: "what the user likes to eat", - hamburger: "how many hamburgers the user ate", - }, - value: "string", - }; - - let expected = { - type: "PropertyValue", - name: "animation style", - description: { - diet: "what the user likes to eat", - hamburger: "how many hamburgers the user ate", - }, - value: "string", - }; - - variablesMap.setVariable(add_default); - expect([expected]).toStrictEqual(variablesMap.getList()); - }); -}); diff --git a/functions/metadata/tests/metadata-module.test.ts b/functions/metadata/tests/metadata-module.test.ts deleted file mode 100644 index 1cf2a0c..0000000 --- a/functions/metadata/tests/metadata-module.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import JsPsychMetadata from "../src/index"; - -// missing displaying data modules tests -describe("JsPsychMetadata", () => { - let jsPsychMetadata: JsPsychMetadata; - - beforeEach(() => { - jsPsychMetadata = new JsPsychMetadata(); - jsPsychMetadata.generateDefaultMetadata(); - }); - - test("#setAndGetField", () => { - // Set metadata fields - jsPsychMetadata.setMetadataField("citations", 100); - jsPsychMetadata.setMetadataField("colors", ["green", "yellow", "red"]); - jsPsychMetadata.setMetadataField("description", "Updated description that says nothing"); // update - - // Check if fields are set correctly - expect(jsPsychMetadata.getMetadataField("citations")).toBe(100); - expect(jsPsychMetadata.getMetadataField("colors")).toStrictEqual(["green", "yellow", "red"]); - expect(jsPsychMetadata.getMetadataField("description")).toBe( - "Updated description that says nothing" - ); - - // Check if unset field returns undefined - expect(jsPsychMetadata.getMetadataField("undefinedField")).toBeUndefined(); - }); - - test("#setAndGetAuthor", () => { - const author1 = { - name: "John Cena", - }; - jsPsychMetadata.setAuthor(author1); - expect(jsPsychMetadata.getAuthor("John Cena")).toStrictEqual(author1["name"]); - - author1["type"] = "WWE Pro Wrestler"; - jsPsychMetadata.setAuthor(author1); - expect(jsPsychMetadata.getAuthor("John Cena")).toStrictEqual(author1); - }); - - test("#setAndGetVariable", () => { - const trialType = { - type: "PropertyValue", - name: "trial_type", - description: "Plugin type that has been used to run trials", - value: "string", - }; - - jsPsychMetadata.setVariable(trialType); - expect(jsPsychMetadata.getVariable("trial_type")).toStrictEqual(trialType); - }); - - test("#deleteVariable", () => { - const trialType = { - type: "PropertyValue", - name: "trial_type", - description: "Plugin type that has been used to run trials", - value: "string", - }; - jsPsychMetadata.setVariable(trialType); - - jsPsychMetadata.deleteVariable("trial_type"); - expect(jsPsychMetadata.getVariableNames()).not.toContain("trial_type"); - }); - - test("#updateVariable", () => { - const trialType = { - type: "PropertyValue", - name: "trial_type", - description: { - default: "unknown", - jsPsych: "The name of the plugin used to run the trial.", - }, - value: "string", - }; - - jsPsychMetadata.updateVariable("trial_type", "levels", 100); - trialType["levels"] = [100]; - expect(jsPsychMetadata.getVariable("trial_type")).toStrictEqual(trialType); - }); -}); diff --git a/functions/metadata/tsconfig.json b/functions/metadata/tsconfig.json deleted file mode 100644 index 6ff723e..0000000 --- a/functions/metadata/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "@jspsych/config/tsconfig.core.json", - "compilerOptions": { - "baseUrl": "." - }, - "include": ["src", "tests/metadata-module.test.ts"] -} diff --git a/functions/package-lock.json b/functions/package-lock.json index 730d804..3c38ebb 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -20,7 +20,8 @@ "devDependencies": { "@types/archiver": "^7.0.0", "@types/is-base64": "^1.1.3", - "firebase-functions-test": "^3.4.1" + "firebase-functions-test": "^3.4.1", + "typescript": "^5.9.3" }, "engines": { "node": "22" @@ -28,16 +29,10 @@ }, "metadata": { "name": "@jspsych/metadata", - "version": "0.0.1", + "version": "0.0.3", "license": "MIT", - "devDependencies": { - "@jspsych/config": "^3.2.2", - "@jspsych/test-utils": "^1.1.2", - "@types/jest": "^29.5.12", - "ts-jest": "^29.1.4" - }, - "peerDependencies": { - "jspsych": "^8.0.0" + "dependencies": { + "csv-parse": "^5.5.6" } }, "node_modules/@babel/code-frame": { @@ -46,6 +41,7 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -61,6 +57,7 @@ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -71,6 +68,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -102,6 +100,7 @@ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -119,7 +118,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@babel/generator": { "version": "7.29.1", @@ -127,6 +127,7 @@ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -138,25 +139,13 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -174,6 +163,7 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -183,29 +173,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC" - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } + "license": "ISC", + "peer": true }, "node_modules/@babel/helper-globals": { "version": "7.28.0", @@ -213,20 +182,7 @@ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -237,6 +193,7 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -251,6 +208,7 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -263,57 +221,13 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -324,6 +238,7 @@ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -334,6 +249,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -344,6 +260,7 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -354,6 +271,7 @@ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -368,6 +286,7 @@ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.29.0" }, @@ -384,6 +303,7 @@ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -397,6 +317,7 @@ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -410,6 +331,7 @@ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -423,6 +345,7 @@ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -433,28 +356,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", - "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -471,6 +379,7 @@ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -484,6 +393,7 @@ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -497,6 +407,7 @@ "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -513,6 +424,7 @@ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -526,6 +438,7 @@ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -539,6 +452,7 @@ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -552,6 +466,7 @@ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -565,6 +480,7 @@ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -578,6 +494,7 @@ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -591,6 +508,7 @@ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -607,6 +525,7 @@ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -623,6 +542,7 @@ "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -633,9360 +553,1758 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-class-properties": { + "node_modules/@babel/template": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "ms": "^2.1.3" }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "peer": true }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "peer": true }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" } }, - "node_modules/@babel/preset-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", - "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==", + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-flow-strip-types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "tslib": "^2.4.0" } }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "tslib": "^2.4.0" } }, - "node_modules/@babel/register": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.28.6.tgz", - "integrity": "sha512-pgcbbEl/dWQYb6L6Yew6F94rdwygfuv+vJ/tXfwIOYAfPB6TNWpXUMEtEq3YuTeHRdvMIhvz13bkT9CNaS+wqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "find-cache-dir": "^2.0.0", - "make-dir": "^2.1.0", - "pirates": "^4.0.6", - "source-map-support": "^0.5.16" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" }, - "node_modules/@babel/register/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" }, - "node_modules/@babel/register/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" }, - "node_modules/@babel/register/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=20.0.0" } }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=20.0.0" } }, - "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=20.0.0" } }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@citation-js/core": { - "version": "0.7.21", - "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.21.tgz", - "integrity": "sha512-Vobv2/Yfnn6C6BVO/pvj7madQ7Mfzl83/jAWwixbemGF6ZThhGMz8++FD9hWHyHXDMYuLGa6fK68c2VsolZmTA==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", "dependencies": { - "@citation-js/date": "^0.5.0", - "@citation-js/name": "^0.4.2", - "fetch-ponyfill": "^7.1.0", - "sync-fetch": "^0.4.1" + "tslib": "^2.1.0" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@citation-js/date": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@citation-js/date/-/date-0.5.1.tgz", - "integrity": "sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@citation-js/name": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@citation-js/name/-/name-0.4.2.tgz", - "integrity": "sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=20.0.0" } }, - "node_modules/@citation-js/plugin-bibtex": { - "version": "0.7.21", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-bibtex/-/plugin-bibtex-0.7.21.tgz", - "integrity": "sha512-O008pSsJgiYKn4+7gAWrbNpNdUH++aMeYmZaJ2oFQ8X1tcY5jNBxJcr0zZojNtUi5CVOaXXHQ0yIifoUhuF2Vg==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@citation-js/date": "^0.5.0", - "@citation-js/name": "^0.4.2", - "moo": "^0.5.1" + "tslib": "^2.1.0" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@citation-js/core": "^0.7.0" + "node": ">=20.0.0" } }, - "node_modules/@citation-js/plugin-cff": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-cff/-/plugin-cff-0.6.2.tgz", - "integrity": "sha512-jvERDFbtQQOBb9s+E8VbRIYsEIb2YEbcLH3yVDxXK0xqBGQDE5m8JZAYUkENy4FmbaD979l0+xJTWAsYN1pV/w==", - "dev": true, - "license": "MIT", + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@citation-js/date": "^0.5.0", - "@citation-js/plugin-yaml": "^0.6.2" + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@citation-js/plugin-csl": { - "version": "0.7.22", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.22.tgz", - "integrity": "sha512-/rGdtbeP3nS4uZDdEbQUHT8PrUcIs0da2t+sWMKYXoOhXQqfw3oJJ7p4tUD+R8lptyIR5Eq20/DFk/kQDdLpYg==", - "dev": true, - "license": "MIT", + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@citation-js/date": "^0.5.0", - "citeproc": "^2.4.6" + "arrify": "^2.0.0", + "extend": "^3.0.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@citation-js/core": "^0.7.0" + "node": ">=14.0.0" } }, - "node_modules/@citation-js/plugin-github": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-github/-/plugin-github-0.6.2.tgz", - "integrity": "sha512-NKq/1Ja060o4II1Z4p1+utwpvMsx+XIWdNiFvnJDfR2Z9E1xGETjByPpdobGBsteUTpJPEe9OVfF8Dee/Q7zLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@citation-js/date": "^0.5.0", - "@citation-js/name": "^0.4.2" - }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=14.0.0" } }, - "node_modules/@citation-js/plugin-npm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-npm/-/plugin-npm-0.6.2.tgz", - "integrity": "sha512-mbQg/N9HM+gOqHJCdDZEElSW+h/oM94snKCl3llXuZ4MEH3tHraElS6CYRW/vW7s8KUTTHhgE62Q6ua5aRml8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@citation-js/date": "^0.5.0", - "@citation-js/name": "^0.4.2" - }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, "engines": { - "node": ">=14.0.0" + "node": ">=14" } }, - "node_modules/@citation-js/plugin-software-formats": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-software-formats/-/plugin-software-formats-0.6.2.tgz", - "integrity": "sha512-x1IG0LBKglBU6SuiiKfvOtn7g7o7s+YhQhB44o7zrFaKEO8jkyQ5qMKtM5VFdCBL7teLfzZLjpjNkdJXtZ6XZw==", - "dev": true, - "license": "MIT", + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@citation-js/plugin-cff": "^0.6.2", - "@citation-js/plugin-github": "^0.6.2", - "@citation-js/plugin-npm": "^0.6.2", - "@citation-js/plugin-yaml": "^0.6.2", - "@citation-js/plugin-zenodo": "^0.6.2" + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=14" } }, - "node_modules/@citation-js/plugin-yaml": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-yaml/-/plugin-yaml-0.6.2.tgz", - "integrity": "sha512-qw53Uy2fDekKAzNhv8pkAWpIccIxyKQ3nQuClMgzDPdyeWg34ElIs4bDub9ZZup15fy+X//2gP8k12RJqNo4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@citation-js/plugin-yaml/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/@citation-js/plugin-yaml/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "argparse": "^2.0.1" + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=14" } }, - "node_modules/@citation-js/plugin-zenodo": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-zenodo/-/plugin-zenodo-0.6.2.tgz", - "integrity": "sha512-3XQOO3u4WXY/7AWZyQ+9SuBzS8bYTlJ+NF1uCgrZO64g36nK5iIc5YV9cBl2TL2QhHF6S36nvAsXsj5fX9FeHw==", - "dev": true, - "license": "MIT", + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@citation-js/date": "^0.5.0", - "@citation-js/name": "^0.4.2" + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=14" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" + "engines": { + "node": ">=14" } }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", "optional": true, - "os": [ - "android" - ], + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", - "cpu": [ - "arm64" - ], + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "ISC", + "peer": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", - "cpu": [ - "x64" - ], + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "peer": true, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", - "cpu": [ - "arm64" - ], + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "peer": true, + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", - "cpu": [ - "x64" - ], + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", - "cpu": [ - "arm64" - ], + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", - "cpu": [ - "ia32" - ], + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", - "cpu": [ - "loong64" - ], + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", - "cpu": [ - "mips64el" - ], + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", - "cpu": [ - "ppc64" - ], + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", - "cpu": [ - "riscv64" - ], + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", - "cpu": [ - "s390x" - ], + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", - "cpu": [ - "x64" - ], + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peer": true, + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", - "cpu": [ - "x64" - ], + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", - "cpu": [ - "arm64" - ], + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", - "cpu": [ - "x64" - ], + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "peer": true, + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", - "cpu": [ - "x64" - ], + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", - "cpu": [ - "arm64" - ], + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "peer": true, + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", - "cpu": [ - "ia32" - ], + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "peer": true, + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", - "cpu": [ - "x64" - ], + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", - "license": "MIT" - }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/component": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", - "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", - "license": "Apache-2.0", + "peer": true, "dependencies": { - "@firebase/util": "1.13.0", - "tslib": "^2.1.0" + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" }, "engines": { - "node": ">=20.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@firebase/database": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", - "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", - "license": "Apache-2.0", + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.0", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.13.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=20.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@firebase/database-compat": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", - "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", - "license": "Apache-2.0", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@firebase/component": "0.7.0", - "@firebase/database": "1.1.0", - "@firebase/database-types": "1.0.16", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.13.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@firebase/database-types": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", - "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", - "license": "Apache-2.0", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.13.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@firebase/logger": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", - "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">=6.0.0" } }, - "node_modules/@firebase/util": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", - "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", - "hasInstallScript": true, - "license": "Apache-2.0", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@google-cloud/firestore": { - "version": "7.11.6", - "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", - "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@opentelemetry/api": "^1.3.0", - "fast-deep-equal": "^3.1.1", - "functional-red-black-tree": "^1.0.1", - "google-gax": "^4.3.3", - "protobufjs": "^7.2.6" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/paginator": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", - "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "arrify": "^2.0.0", - "extend": "^3.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/projectify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", - "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/promisify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", - "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", - "license": "Apache-2.0", + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", "optional": true, - "engines": { - "node": ">=14" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@google-cloud/storage": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", - "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@google-cloud/paginator": "^5.0.0", - "@google-cloud/projectify": "^4.0.0", - "@google-cloud/promisify": "<4.1.0", - "abort-controller": "^3.0.0", - "async-retry": "^1.3.3", - "duplexify": "^4.1.3", - "fast-xml-parser": "^5.3.4", - "gaxios": "^6.0.2", - "google-auth-library": "^9.6.3", - "html-entities": "^2.5.2", - "mime": "^3.0.0", - "p-limit": "^3.0.1", - "retry-request": "^7.0.0", - "teeny-request": "^9.0.0", - "uuid": "^8.0.0" - }, - "engines": { - "node": ">=14" - } + "node_modules/@jspsych/metadata": { + "resolved": "metadata", + "link": true }, - "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=14" + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@google-cloud/storage/node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", "optional": true, - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" - }, "engines": { - "node": ">=14" + "node": ">=8.0.0" } }, - "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, - "node_modules/@google-cloud/storage/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, "license": "MIT", - "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@grpc/proto-loader": "^0.8.0", - "@js-sdsl/ordered-map": "^4.4.2" - }, + "peer": true, "engines": { - "node": ">=12.10.0" - } - }, - "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.5.3", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://opencollective.com/pkgr" } }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" }, - "node_modules/@gulpjs/messages": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@gulpjs/messages/-/messages-1.1.0.tgz", - "integrity": "sha512-Ys9sazDatyTgZVb4xPlDufLweJ/Os2uHWOv+Caxvy2O85JcnT4M3vc73bi8pdLWlv3fdWQz3pdI9tVwo8rQQSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" }, - "node_modules/@gulpjs/to-absolute-glob": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz", - "integrity": "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-negated-glob": "^1.0.0" - }, - "engines": { - "node": ">=10.13.0" - } + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { - "@hapi/hoek": "^9.0.0" + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" } }, - "node_modules/@inquirer/checkbox": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", - "integrity": "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" }, - "node_modules/@inquirer/confirm": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz", - "integrity": "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" - }, - "engines": { - "node": ">=18" - } + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" }, - "node_modules/@inquirer/core": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", - "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", - "dev": true, - "license": "MIT", + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", "dependencies": { - "@inquirer/figures": "^1.0.6", - "@inquirer/type": "^2.0.0", - "@types/mute-stream": "^0.0.4", - "@types/node": "^22.5.5", - "@types/wrap-ansi": "^3.0.0", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "^1.0.0", - "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@inquirer/core/node_modules/@inquirer/type": { + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", - "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", - "dev": true, - "license": "MIT", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" }, - "node_modules/@inquirer/core/node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } + "peer": true }, - "node_modules/@inquirer/core/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "type-detect": "4.0.8" } }, - "node_modules/@inquirer/core/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inquirer/core/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@inquirer/core/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, "engines": { - "node": ">=8" + "node": ">= 10" } }, - "node_modules/@inquirer/core/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" + "tslib": "^2.4.0" } }, - "node_modules/@inquirer/editor": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-2.2.0.tgz", - "integrity": "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==", + "node_modules/@types/archiver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", - "external-editor": "^3.1.0" - }, - "engines": { - "node": ">=18" + "@types/readdir-glob": "*" } }, - "node_modules/@inquirer/expand": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-2.3.0.tgz", - "integrity": "sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "peer": true, + "dependencies": { + "@babel/types": "^7.0.0" } }, - "node_modules/@inquirer/input": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", - "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" - }, - "engines": { - "node": ">=18" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/@inquirer/number": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-1.1.0.tgz", - "integrity": "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" - }, - "engines": { - "node": ">=18" + "@babel/types": "^7.28.2" } }, - "node_modules/@inquirer/password": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-2.2.0.tgz", - "integrity": "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==", - "dev": true, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "license": "MIT", "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", - "ansi-escapes": "^4.3.2" - }, - "engines": { - "node": ">=18" + "@types/connect": "*", + "@types/node": "*" } }, - "node_modules/@inquirer/prompts": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.5.0.tgz", - "integrity": "sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==", - "dev": true, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^2.5.0", - "@inquirer/confirm": "^3.2.0", - "@inquirer/editor": "^2.2.0", - "@inquirer/expand": "^2.3.0", - "@inquirer/input": "^2.3.0", - "@inquirer/number": "^1.1.0", - "@inquirer/password": "^2.2.0", - "@inquirer/rawlist": "^2.3.0", - "@inquirer/search": "^1.1.0", - "@inquirer/select": "^2.5.0" - }, - "engines": { - "node": ">=18" - } + "optional": true }, - "node_modules/@inquirer/rawlist": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-2.3.0.tgz", - "integrity": "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==", - "dev": true, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" + "@types/node": "*" } }, - "node_modules/@inquirer/search": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-1.1.0.tgz", - "integrity": "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==", - "dev": true, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" + "@types/node": "*" } }, - "node_modules/@inquirer/select": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", - "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", - "dev": true, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" } }, - "node_modules/@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", - "dev": true, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "license": "MIT", "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@types/is-base64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/is-base64/-/is-base64-1.1.3.tgz", + "integrity": "sha512-8h40c+MFeMKhEw8Ebckd11MEt9sngs4AAkvcZVfDYOFDd7FeTD80pTfm3uvepbWcld1zVkHW+WA6b7UaHAOU/w==", "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } + "peer": true }, - "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "@types/istanbul-lib-report": "*" } }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "dependencies": { + "@types/ms": "*", + "@types/node": "*" } }, - "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT", - "peer": true, - "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "optional": true }, - "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", - "dev": true, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", + "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "license": "MIT", - "peer": true, "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "undici-types": "~7.18.0" } }, - "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/node": "*" } }, - "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", - "dev": true, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", - "peer": true, + "optional": true, "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@types/caseless": "*", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/tough-cookie": "*", + "form-data": "^2.5.5" } }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", - "peer": true, + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 0.12" } }, - "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", - "dev": true, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/node": "*" } }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "license": "MIT", - "peer": true, "dependencies": { + "@types/http-errors": "*", "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/send": "<1" } }, - "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", - "dev": true, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "license": "MIT", - "peer": true, "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "@types/mime": "^1", + "@types/node": "*" } }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "peer": true }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", - "dev": true, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "optional": true }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/yargs-parser": "*" } }, - "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "peer": true }, - "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/test-result": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "license": "ISC", + "peer": true }, - "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "optional": true, + "os": [ + "android" + ], + "peer": true }, - "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "optional": true, + "os": [ + "android" + ], + "peer": true }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } + "optional": true, + "os": [ + "darwin" + ], + "peer": true }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, - "node_modules/@jspsych/config": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@jspsych/config/-/config-3.3.2.tgz", - "integrity": "sha512-7IYDKJOWEgCnNK4iphGRRXI7Zh+IjO6XD+K+zmfSlfDRyaTFHZzJiBVxGVnIAVBX/MtSLdIAkz9Ll+Ur+wwVeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@citation-js/core": "^0.7.14", - "@citation-js/plugin-bibtex": "^0.7.14", - "@citation-js/plugin-csl": "^0.7.14", - "@citation-js/plugin-software-formats": "^0.6.1", - "@rollup/plugin-commonjs": "26.0.1", - "@rollup/plugin-node-resolve": "15.2.3", - "@rollup/plugin-replace": "^6.0.1", - "@sucrase/jest-plugin": "3.0.0", - "@types/gulp": "4.0.17", - "@types/jest": "29.5.8", - "@types/node": "^22.10.10", - "alias-hq": "6.2.4", - "app-root-path": "^3.1.0", - "esbuild": "0.23.1", - "glob": "7.2.3", - "gulp": "5.0.0", - "gulp-cli": "3.0.0", - "gulp-file": "0.4.0", - "gulp-rename": "2.0.0", - "gulp-replace": "1.1.4", - "gulp-zip": "6.0.0", - "jest": "29.7.0", - "jest-canvas-mock": "2.5.0", - "jest-environment-jsdom": "29.7.0", - "merge-stream": "2.0.0", - "rollup": "^4.22.4", - "rollup-plugin-dts": "6.1.1", - "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-modify": "^3.0.0", - "rollup-plugin-node-externals": "7.1.3", - "sucrase": "3.34.0", - "tslib": "2.6.2", - "typescript": "^5.7.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jspsych/config/node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jspsych/config/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jspsych/config/node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@types/jest": { - "version": "29.5.8", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz", - "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@jspsych/config/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jspsych/config/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jspsych/config/node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/@jspsych/config/node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jspsych/config/node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jspsych/config/node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@jspsych/config/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@jspsych/config/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jspsych/config/node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jspsych/config/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@jspsych/config/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jspsych/config/node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@jspsych/config/node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jspsych/config/node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jspsych/config/node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/@jspsych/config/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/@jspsych/config/node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@jspsych/config/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@jspsych/config/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jspsych/config/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jspsych/config/node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/@jspsych/config/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@jspsych/config/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jspsych/config/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/@jspsych/config/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@jspsych/config/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jspsych/config/node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@jspsych/metadata": { - "resolved": "metadata", - "link": true - }, - "node_modules/@jspsych/test-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@jspsych/test-utils/-/test-utils-1.2.0.tgz", - "integrity": "sha512-3j0n0k6/DLSIz91ngqV/SXfXONnpaBC/McCrXysq6kMS08utzTEX56xKNzEmfY37bQKCijJsBreF6oH9Zss60w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/jest": "*", - "jspsych": ">=7.0.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.1.tgz", - "integrity": "sha512-UnsKoZK6/aGIH6AdkptXhNvhaqftcjq3zZdT+LY5Ftms6JR06nADcDsYp5hTU9E2lbJUEOhdlY5J4DNTneM+jQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "glob": "^10.4.1", - "is-reference": "1.2.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", - "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-builtin-module": "^3.2.1", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-replace": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", - "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "license": "BSD-3-Clause" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@sucrase/jest-plugin": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sucrase/jest-plugin/-/jest-plugin-3.0.0.tgz", - "integrity": "sha512-VRY6YKYImVWiRg1H3Yu24hwB1UPJDSDR62R/n+lOHR3+yDrfHEIAoddJivblMYN6U3vD+ndfTSrecZ9Jl+iGNw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jest": ">=27", - "sucrase": ">=3.25.0" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/archiver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", - "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/readdir-glob": "*" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/caseless": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", - "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/expect": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz", - "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/glob-stream": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@types/glob-stream/-/glob-stream-8.0.3.tgz", - "integrity": "sha512-vctgrT9AH/GK3TRaIbRUU0TZn12GBU4kzelZdPyJp1Sc8L/6Wrq21UrtN4+x4saqTg6COUIUtFV6JSYcVln/EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/picomatch": "*", - "@types/streamx": "*" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/gulp": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@types/gulp/-/gulp-4.0.17.tgz", - "integrity": "sha512-+pKQynu2C/HS16kgmDlAicjtFYP8kaa86eE9P0Ae7GB5W29we/E2TIdbOWtEZD5XkpY+jr8fyqfwO6SWZecLpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/undertaker": ">=1.2.6", - "@types/vinyl-fs": "*", - "chokidar": "^3.3.1" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/is-base64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/is-base64/-/is-base64-1.1.3.tgz", - "integrity": "sha512-8h40c+MFeMKhEw8Ebckd11MEt9sngs4AAkvcZVfDYOFDd7FeTD80pTfm3uvepbWcld1zVkHW+WA6b7UaHAOU/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@types/jest/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@types/jest/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } - }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "25.3.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", - "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, - "node_modules/@types/readdir-glob": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", - "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/request": { - "version": "2.48.13", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", - "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/caseless": "*", - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.5" - } - }, - "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/streamx": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/@types/streamx/-/streamx-2.9.5.tgz", - "integrity": "sha512-IHYsa6jYrck8VEdSwpY141FTTf6D7boPeMq9jy4qazNrFMA4VbRz/sw5LSsfR7jwdDcx0QKWkUexZvsWBC2eIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/undertaker": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.12.tgz", - "integrity": "sha512-52BiBni1srlIx/o7anEB1Y230yr3+21P0utA4VXLyeyeR2gHANKi5kJ/e0FakD4RYEXX0D9dOC7PDrVqL1j98Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/undertaker-registry": "*", - "async-done": "~1.3.2" - } - }, - "node_modules/@types/undertaker-registry": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/undertaker-registry/-/undertaker-registry-1.0.4.tgz", - "integrity": "sha512-tW77pHh2TU4uebWXWeEM5laiw8BuJ7pyJYDh6xenOs75nhny2kVgwYbegJ4BoLMYsIrXaBpKYaPdYO3/udG+hg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/vinyl": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.12.tgz", - "integrity": "sha512-Sr2fYMBUVGYq8kj3UthXFAu5UN6ZW+rYr4NACjZQJvHvj+c8lYv0CahmZ2P/r7iUkN44gGUBwqxZkrKXYPb7cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/expect": "^1.20.4", - "@types/node": "*" - } - }, - "node_modules/@types/vinyl-fs": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/vinyl-fs/-/vinyl-fs-3.0.7.tgz", - "integrity": "sha512-ojGFhBnh5pj5Crf2yBOk3rjJXUX2U4W9z6tZ7hn6pUbQa/J8KH8NrXem0POYVQWI3ifnx4T65DPktuWfxc3iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/glob-stream": "*", - "@types/node": "*", - "@types/vinyl": "*" - } - }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/alias-hq": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/alias-hq/-/alias-hq-6.2.4.tgz", - "integrity": "sha512-6KGuO4XB3PbvTfP+WJEJR2dGMy6h0UyLa2/kZOeeD/UIrYoaUAQwKdLovYyCpgZErYD1d3zIuZh6GPMDADvF4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "colors": "^1.4.0", - "get-tsconfig": "^4.8.0", - "glob": "^7.2.3", - "inquirer": "^10.1.6", - "jscodeshift": "^0.16.1", - "json5": "^2.2.3", - "module-alias": "^2.2.3", - "node-fetch": "^2.7.0", - "open": "^7.4.2", - "vue-jscodeshift-adapter": "^2.2.1" - }, - "bin": { - "alias-hq": "bin/alias-hq" - } - }, - "node_modules/alias-hq/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/alias-hq/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/alias-hq/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/alias-hq/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/app-root-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", - "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver/node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/archiver/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/array-each": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", - "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-slice": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", - "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/async-done": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", - "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.2", - "process-nextick-args": "^2.0.0", - "stream-exhaust": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "license": "MIT", - "optional": true, - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/async-settle": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-2.0.0.tgz", - "integrity": "sha512-Obu/KE8FurfQRN6ODdHN9LuXqwC+JFIM9NRyZqJJ4ZfLJmIYN9Rg0/kb+wF70VV5+fJusTMQlJ1t5rF7J/ETdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-done": "^2.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/async-settle/node_modules/async-done": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz", - "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.4.4", - "once": "^1.4.0", - "stream-exhaust": "^1.0.2" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/auto-bind": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", - "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, - "node_modules/bach": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/bach/-/bach-2.0.1.tgz", - "integrity": "sha512-A7bvGMGiTOxGMpNupYl9HQTf0FFDNF4VCmks4PJpFyN1AX2pdKuxuwdvUz2Hu388wcgp+OvGFNsumBfFNkR7eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-done": "^2.0.0", - "async-settle": "^2.0.0", - "now-and-later": "^3.0.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/bach/node_modules/async-done": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz", - "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.4.4", - "once": "^1.4.0", - "stream-exhaust": "^1.0.2" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz", - "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.2.tgz", - "integrity": "sha512-lMseYRMTzMrxPGfXkDwOWym2iv9dUMlTqpjXa0M+7ymI1TJKhxQ2jkDOK7y1EGvxuqJcXOoJ/HYEBxIlWObgjQ==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz", - "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==", - "license": "Apache-2.0", - "dependencies": { - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", - "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/binaryextensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-2.3.0.tgz", - "integrity": "sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/bl": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", - "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/citeproc": { - "version": "2.4.63", - "resolved": "https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz", - "integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q==", - "dev": true, - "license": "CPAL-1.0 OR AGPL-1.0" - }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", - "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clone-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clone-stats": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", - "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/cloneable-readable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", - "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "process-nextick-args": "^2.0.0", - "readable-stream": "^2.3.5" - } - }, - "node_modules/cloneable-readable/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/cloneable-readable/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/cloneable-readable/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/compress-commons/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/copy-props": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", - "integrity": "sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "each-props": "^3.0.0", - "is-plain-object": "^5.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/crc32-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-jest/node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/create-jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/create-jest/node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/create-jest/node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/create-jest/node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/create-jest/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/create-jest/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/create-jest/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/create-jest/node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-jest/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/create-jest/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/create-jest/node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/create-jest/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/create-jest/node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/create-jest/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/create-jest/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/create-jest/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/create-jest/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/create-jest/node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssfontparser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", - "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csv-string": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/csv-string/-/csv-string-4.1.1.tgz", - "integrity": "sha512-KGvaJEZEdh2O/EVvczwbPLqJZtSQaWQ4cEJbiOJEG4ALq+dBBqNmBkRXTF4NV79V25+XYtiqbco1IWrmHLm5FQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/data-urls/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", - "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/domexception/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexify": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", - "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.2" - } - }, - "node_modules/each-props": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/each-props/-/each-props-3.0.0.tgz", - "integrity": "sha512-IYf1hpuWrdzse/s/YJOrFmU15lyhSzxelNVAHTEG3DtP4QsLTWZUzcUL3HMXmKQxXpa4EIrBPpwRgj0aehdvAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^5.0.0", - "object.defaults": "^1.1.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/easy-transform-stream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/easy-transform-stream/-/easy-transform-stream-1.0.1.tgz", - "integrity": "sha512-ktkaa6XR7COAR3oj02CF3IOgz2m1hCaY3SfzvKT4Svt2MhHw9XCt+ncJNWfe2TGz31iqzNGZ8spdKQflj+Rlog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/exit-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor/node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/farmhash-modern": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", - "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fetch-ponyfill": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz", - "integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "~2.6.1" - } - }, - "node_modules/fetch-ponyfill/node_modules/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/find-cache-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", - "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^2.0.0", - "pkg-dir": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/find-cache-dir/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/find-cache-dir/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/find-cache-dir/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/find-cache-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-cache-dir/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/find-cache-dir/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/find-cache-dir/node_modules/pkg-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", - "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/find-cache-dir/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/findup-sync": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", - "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.3", - "micromatch": "^4.0.4", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/fined": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-2.0.0.tgz", - "integrity": "sha512-OFRzsL6ZMHz5s0JrsEr+TpdGNCtrVtnuG3x1yzGNiQHT0yaDnXAj8V/lWcpJVrnoDpcwXcASxAZYbuXda2Y82A==", - "dev": true, - "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.2", - "is-plain-object": "^5.0.0", - "object.defaults": "^1.1.0", - "object.pick": "^1.3.0", - "parse-filepath": "^1.0.2" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/firebase-admin": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", - "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", - "license": "Apache-2.0", - "dependencies": { - "@fastify/busboy": "^3.0.0", - "@firebase/database-compat": "^2.0.0", - "@firebase/database-types": "^1.0.6", - "farmhash-modern": "^1.1.0", - "fast-deep-equal": "^3.1.1", - "google-auth-library": "^10.6.1", - "jsonwebtoken": "^9.0.0", - "jwks-rsa": "^3.1.0", - "node-forge": "^1.3.1", - "uuid": "^11.0.2" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@google-cloud/firestore": "^7.11.0", - "@google-cloud/storage": "^7.19.0" - } - }, - "node_modules/firebase-functions": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.2.tgz", - "integrity": "sha512-fWFVI+4weuaat+Fp+4xYY1T+omiTvya8fW79+edgLWCOaDEBSBNlfhstnt+K1esblscZlJf8v+IA0LsCG8Uf1Q==", - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.5", - "@types/express": "^4.17.21", - "cors": "^2.8.5", - "express": "^4.21.0", - "protobufjs": "^7.2.2" - }, - "bin": { - "firebase-functions": "lib/bin/firebase-functions.js" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@apollo/server": "^5.2.0", - "@as-integrations/express4": "^1.1.2", - "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", - "graphql": "^16.12.0" - }, - "peerDependenciesMeta": { - "@apollo/server": { - "optional": true - }, - "@as-integrations/express4": { - "optional": true - }, - "graphql": { - "optional": true - } - } - }, - "node_modules/firebase-functions-test": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/firebase-functions-test/-/firebase-functions-test-3.4.1.tgz", - "integrity": "sha512-qAq0oszrBGdf4bnCF6t4FoSgMsepeIXh0Pi/FhikSE6e+TvKKGpfrfUP/5pFjJZxFcLsweoau88KydCql4xSeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.14.104", - "lodash": "^4.17.5", - "ts-deepmerge": "^2.0.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "firebase-admin": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", - "firebase-functions": ">=4.9.0", - "jest": ">=28.0.0" - } - }, - "node_modules/flagged-respawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-2.0.0.tgz", - "integrity": "sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/flow-parser": { - "version": "0.303.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.303.0.tgz", - "integrity": "sha512-SGifrPA0IQqN4S3MFZ3KPqphxWd+VehHEwpQPEjWQGtJSnPsFakrlqNQH88MLyJGi/Z7WO15FMylybGONwqwxA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", - "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", - "dev": true, - "license": "MIT", - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-mkdirp-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-2.0.1.tgz", - "integrity": "sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.8", - "streamx": "^2.12.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "license": "MIT", - "optional": true - }, - "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "os": [ + "darwin" + ], + "peer": true }, - "node_modules/gaxios/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } + "os": [ + "freebsd" + ], + "peer": true }, - "node_modules/gaxios/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" ], + "dev": true, "license": "MIT", "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/gcp-metadata/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gcp-metadata/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/gcp-metadata/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "os": [ + "linux" + ], + "peer": true }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8.0.0" - } + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/glob-stream": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz", - "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==", + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@gulpjs/to-absolute-glob": "^4.0.0", - "anymatch": "^3.1.3", - "fastq": "^1.13.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "is-negated-glob": "^1.0.0", - "normalize-path": "^3.0.0", - "streamx": "^2.12.5" - }, - "engines": { - "node": ">=10.13.0" - } + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/glob-stream/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/glob-watcher": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-6.0.0.tgz", - "integrity": "sha512-wGM28Ehmcnk2NqRORXFOTOR064L4imSw3EeOqU5bIwUf62eXGwg89WivH6VMahL8zlQHeodzvHpXplrqzrz3Nw==", + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "async-done": "^2.0.0", - "chokidar": "^3.5.3" - }, - "engines": { - "node": ">= 10.13.0" - } + "optional": true, + "os": [ + "linux" + ], + "peer": true }, - "node_modules/glob-watcher/node_modules/async-done": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz", - "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==", + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "end-of-stream": "^1.4.4", - "once": "^1.4.0", - "stream-exhaust": "^1.0.2" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": ">= 10.13.0" + "node": ">=14.0.0" } }, - "node_modules/global-modules": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", - "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } + "optional": true, + "os": [ + "win32" + ], + "peer": true }, - "node_modules/global-prefix": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", - "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" - }, - "engines": { - "node": ">=0.10.0" - } + "optional": true, + "os": [ + "win32" + ], + "peer": true }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true }, - "node_modules/glogg": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/glogg/-/glogg-2.2.0.tgz", - "integrity": "sha512-eWv1ds/zAlz+M1ioHsyKJomfY7jbDDPpwSkv14KQj89bycx1nvK5/2Cj/T9g7kzJcX5Bc7Yv22FjfBZS/jl94A==", - "dev": true, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { - "sparkles": "^2.1.0" + "event-target-shim": "^5.0.0" }, "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", - "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", - "license": "Apache-2.0", + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "7.1.3", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": ">=18" + "node": ">= 0.6" } }, - "node_modules/google-auth-library/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, - "node_modules/google-auth-library/node_modules/debug": { + "node_modules/agent-base/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -9999,548 +2317,642 @@ } } }, - "node_modules/google-auth-library/node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" + "type-fest": "^0.21.3" }, "engines": { - "node": ">=18" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/google-auth-library/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, "engines": { - "node": ">= 14" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/google-auth-library/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/google-auth-library/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { - "glob": "^10.3.7" + "color-convert": "^2.0.1" }, - "bin": { - "rimraf": "dist/esm/bin.mjs" + "engines": { + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/google-gax": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", - "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", - "license": "Apache-2.0", - "optional": true, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "peer": true, "dependencies": { - "@grpc/grpc-js": "^1.10.9", - "@grpc/proto-loader": "^0.7.13", - "@types/long": "^4.0.0", - "abort-controller": "^3.0.0", - "duplexify": "^4.0.0", - "google-auth-library": "^9.3.0", - "node-fetch": "^2.7.0", - "object-hash": "^3.0.0", - "proto3-json-serializer": "^2.0.2", - "protobufjs": "^7.3.2", - "retry-request": "^7.0.0", - "uuid": "^9.0.1" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=14" + "node": ">= 8" } }, - "node_modules/google-gax/node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", - "optional": true, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" }, "engines": { - "node": ">=14" + "node": ">= 14" } }, - "node_modules/google-gax/node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "license": "Apache-2.0", - "optional": true, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">= 14" } }, - "node_modules/google-gax/node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=14" + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/google-gax/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", - "optional": true, "dependencies": { - "whatwg-url": "^5.0.0" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/google-gax/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } ], "license": "MIT", - "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8.0.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", - "optional": true, "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">=14.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/gulp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.0.tgz", - "integrity": "sha512-S8Z8066SSileaYw1S2N1I64IUc/myI2bqe2ihOBzO6+nKpvNSg7ZcWJt/AwF8LC/NVN+/QZ560Cb/5OPsyhkhg==", + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "glob-watcher": "^6.0.0", - "gulp-cli": "^3.0.0", - "undertaker": "^2.0.0", - "vinyl-fs": "^4.0.0" - }, - "bin": { - "gulp": "bin/gulp.js" - }, - "engines": { - "node": ">=10.13.0" + "sprintf-js": "~1.0.2" } }, - "node_modules/gulp-cli": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.0.0.tgz", - "integrity": "sha512-RtMIitkT8DEMZZygHK2vEuLPqLPAFB4sntSxg4NoDta7ciwGZ18l7JuhCTiS5deOJi2IoK0btE+hs6R4sfj7AA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@gulpjs/messages": "^1.1.0", - "chalk": "^4.1.2", - "copy-props": "^4.0.0", - "gulplog": "^2.2.0", - "interpret": "^3.1.1", - "liftoff": "^5.0.0", - "mute-stdout": "^2.0.0", - "replace-homedir": "^2.0.0", - "semver-greatest-satisfied-range": "^2.0.0", - "string-width": "^4.2.3", - "v8flags": "^4.0.0", - "yargs": "^16.2.0" - }, - "bin": { - "gulp": "bin/gulp.js" - }, - "engines": { - "node": ">=10.13.0" - } + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" }, - "node_modules/gulp-cli/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "license": "MIT", + "optional": true, "engines": { "node": ">=8" } }, - "node_modules/gulp-cli/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "retry": "0.13.1" } }, - "node_modules/gulp-cli/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true }, - "node_modules/gulp-cli/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, - "node_modules/gulp-cli/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, + "workspaces": [ + "test/babel-8" + ], "dependencies": { - "ansi-regex": "^5.0.1" + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/gulp-cli/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/gulp-cli/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/gulp-cli/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, - "license": "ISC", + "license": "MIT", + "peer": true, + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, - "node_modules/gulp-file": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/gulp-file/-/gulp-file-0.4.0.tgz", - "integrity": "sha512-3NPCJpAPpbNoV2aml8T96OK3Aof4pm4PMOIa1jSQbMNSNUUXdZ5QjVgLXLStjv0gg9URcETc7kvYnzXdYXUWug==", - "dev": true, - "license": "BSD", - "dependencies": { - "through2": "^0.4.1", - "vinyl": "^2.1.0" + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } } }, - "node_modules/gulp-plugin-extras": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/gulp-plugin-extras/-/gulp-plugin-extras-0.3.0.tgz", - "integrity": "sha512-I/kOBSpo61QsGQZcqozZYEnDseKvpudUafVVWDLYgBFAUJ37kW5R8Sjw9cMYzpGyPUfEYOeoY4p+dkfLqgyJUQ==", - "dev": true, - "license": "MIT", + "node_modules/bare-fs": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz", + "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==", + "license": "Apache-2.0", "dependencies": { - "@types/vinyl": "^2.0.9", - "chalk": "^5.3.0", - "easy-transform-stream": "^1.0.1" + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { - "node": ">=18" + "bare": ">=1.16.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gulp-plugin-extras/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "peerDependencies": { + "bare-buffer": "*" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, - "node_modules/gulp-rename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-2.0.0.tgz", - "integrity": "sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ==", - "dev": true, - "license": "MIT", + "node_modules/bare-os": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.2.tgz", + "integrity": "sha512-lMseYRMTzMrxPGfXkDwOWym2iv9dUMlTqpjXa0M+7ymI1TJKhxQ2jkDOK7y1EGvxuqJcXOoJ/HYEBxIlWObgjQ==", + "license": "Apache-2.0", "engines": { - "node": ">=4" + "bare": ">=1.14.0" } }, - "node_modules/gulp-replace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/gulp-replace/-/gulp-replace-1.1.4.tgz", - "integrity": "sha512-SVSF7ikuWKhpAW4l4wapAqPPSToJoiNKsbDoUnRrSgwZHH7lH8pbPeQj1aOVYQrbZKhfSVBxVW+Py7vtulRktw==", - "dev": true, - "license": "MIT", + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", "dependencies": { - "@types/node": "*", - "@types/vinyl": "^2.0.4", - "istextorbinary": "^3.0.0", - "replacestream": "^4.0.3", - "yargs-parser": ">=5.0.0-security.0" - }, - "engines": { - "node": ">=10" + "bare-os": "^3.0.1" } }, - "node_modules/gulp-zip": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gulp-zip/-/gulp-zip-6.0.0.tgz", - "integrity": "sha512-fPGvNve2dBoZxGKcviTU7mOa77eQibyhwgGLTxnF+ZCKX8RFaTZKkPbdPnmw0r4TNPRjPCkQB/0VuP+MzgkEYg==", - "dev": true, - "license": "MIT", + "node_modules/bare-stream": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz", + "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==", + "license": "Apache-2.0", "dependencies": { - "get-stream": "^8.0.1", - "gulp-plugin-extras": "^0.3.0", - "vinyl": "^3.0.0", - "yazl": "^2.5.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { - "gulp": ">=4" + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" }, "peerDependenciesMeta": { - "gulp": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { "optional": true } } }, - "node_modules/gulp-zip/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/gulp-zip/node_modules/replace-ext": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", - "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==", - "dev": true, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { - "node": ">= 10" + "node": "*" } }, - "node_modules/gulp-zip/node_modules/vinyl": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", - "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", - "dev": true, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { - "clone": "^2.1.2", - "remove-trailing-separator": "^1.1.0", - "replace-ext": "^2.0.0", - "teex": "^1.0.1" + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/gulplog": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-2.2.0.tgz", - "integrity": "sha512-V2FaKiOhpR3DRXZuYdRLn/qiY0yI5XmqbTKrYbdemJ+xOh2d2MOweI/XFgMzd/9+1twdvMwllnZbWZNJ+BOm4A==", + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "glogg": "^2.2.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">= 10.13.0" + "node": ">=8" } }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "peer": true, "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { - "handlebars": "bin/handlebars" + "browserslist": "cli.js" }, "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -10549,209 +2961,253 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, + "peer": true, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", - "bin": { - "he": "bin/he" + "peer": true, + "engines": { + "node": ">=6" } }, - "node_modules/homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "parse-passwd": "^1.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, "license": "MIT", - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, + "peer": true, "engines": { - "node": ">=12" + "node": ">=10" } }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" + "url": "https://github.com/sponsors/sibiraj-s" } ], "license": "MIT", - "optional": true + "peer": true, + "engines": { + "node": ">=8" + } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "peer": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, + "license": "ISC", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=12" } }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, "license": "MIT" }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "devOptional": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "devOptional": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=8" } }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "devOptional": true, "license": "MIT", "dependencies": { - "agent-base": "6", - "debug": "4" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "color-name": "~1.1.4" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=7.0.0" } }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, "engines": { - "node": ">=10.17.0" + "node": ">= 0.8" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -10766,3664 +3222,3780 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, "engines": { - "node": ">=0.8.19" + "node": ">= 0.6" } }, - "node_modules/indent-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", - "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", - "dev": true, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.6" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "license": "MIT", + "peer": true + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" }, - "node_modules/inquirer": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-10.2.2.tgz", - "integrity": "sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg==", - "dev": true, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/prompts": "^5.5.0", - "@inquirer/type": "^1.5.3", - "@types/mute-stream": "^0.0.4", - "ansi-escapes": "^4.3.2", - "mute-stream": "^1.0.0", - "run-async": "^3.0.0", - "rxjs": "^7.8.1" + "object-assign": "^4", + "vary": "^1" }, "engines": { - "node": ">=18" + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/interpret": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", - "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", - "dev": true, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, "engines": { - "node": ">=10.13.0" + "node": ">= 14" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, "engines": { - "node": ">= 0.10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", "license": "MIT" }, - "node_modules/is-base64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-1.1.0.tgz", - "integrity": "sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==", + "node_modules/csv-string": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/csv-string/-/csv-string-4.1.1.tgz", + "integrity": "sha512-KGvaJEZEdh2O/EVvczwbPLqJZtSQaWQ4cEJbiOJEG4ALq+dBBqNmBkRXTF4NV79V25+XYtiqbco1IWrmHLm5FQ==", "license": "MIT", - "bin": { - "is_base64": "bin/is-base64", - "is-base64": "bin/is-base64" + "engines": { + "node": ">=12.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" + "ms": "2.0.0" } }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "dev": true, "license": "MIT", - "dependencies": { - "builtin-modules": "^3.3.0" - }, - "engines": { - "node": ">=6" + "peer": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, + "peer": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, + "optional": true, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.4.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.8" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/is-negated-glob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", - "integrity": "sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "license": "ISC", + "peer": true }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", + "optional": true, "dependencies": { - "@types/estree": "*" + "once": "^1.4.0" } }, - "node_modules/is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "is-unc-path": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" + "is-arrayish": "^0.2.1" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "dev": true, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "dependencies": { - "unc-path-regex": "^0.1.2" - }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-valid-glob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", - "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", - "dev": true, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "devOptional": true, "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/isexe": { + "node_modules/escape-string-regexp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, - "license": "BSD-3-Clause", + "license": "BSD-2-Clause", + "peer": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.6" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=6" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=0.8.x" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" + "bare-events": "^2.7.0" } }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "ms": "^2.1.3" + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "MIT", + "license": "ISC", "peer": true }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, + "license": "MIT", + "peer": true, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/istextorbinary": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-3.3.0.tgz", - "integrity": "sha512-Tvq1W6NAcZeJ8op+Hq7tdZ434rqnMx4CCZ7H0ff83uEloDvVbqAwaMTZcafKGJT0VHkYzuXUiCY4hlXQg6WfoQ==", + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "binaryextensions": "^2.2.0", - "textextensions": "^3.2.0" + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://bevry.me/fund" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">= 0.10.0" }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", - "import-local": "^3.2.0", - "jest-cli": "30.2.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true + "peer": true + }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" } - } + ], + "license": "MIT", + "optional": true }, - "node_modules/jest-canvas-mock": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.0.tgz", - "integrity": "sha512-s2bmY2f22WPMzhB2YA93kiyf7CAfWAnV/sFfY9s48IVOrGmwui1eSFluDPesq1M+7tSC1hJAit6mzO0ZNXvVBA==", - "dev": true, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", + "optional": true, "dependencies": { - "cssfontparser": "^1.2.1", - "moo-color": "^1.0.2" + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" } }, - "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.2.0", - "p-limit": "^3.1.0" + "websocket-driver": ">=0.5.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=0.8.0" } }, - "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "peer": true, "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "p-limit": "^3.1.0", - "pretty-format": "30.2.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "bser": "2.1.1" } }, - "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", - "dev": true, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": "^12.20 || >= 14.13" } }, - "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "to-regex-range": "^5.0.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } + "node": ">=8" } }, - "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", - "dev": true, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 0.8" } }, - "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "detect-newline": "^3.1.0" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=8" } }, - "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" } }, - "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", - "dev": true, + "node_modules/firebase-functions": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.2.tgz", + "integrity": "sha512-fWFVI+4weuaat+Fp+4xYY1T+omiTvya8fW79+edgLWCOaDEBSBNlfhstnt+K1esblscZlJf8v+IA0LsCG8Uf1Q==", "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "@types/cors": "^2.8.5", + "@types/express": "^4.17.21", + "cors": "^2.8.5", + "express": "^4.21.0", + "protobufjs": "^7.2.2" + }, + "bin": { + "firebase-functions": "lib/bin/firebase-functions.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "@apollo/server": "^5.2.0", + "@as-integrations/express4": "^1.1.2", + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", + "graphql": "^16.12.0" }, "peerDependenciesMeta": { - "canvas": { + "@apollo/server": { + "optional": true + }, + "@as-integrations/express4": { + "optional": true + }, + "graphql": { "optional": true } } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/firebase-functions-test": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/firebase-functions-test/-/firebase-functions-test-3.4.1.tgz", + "integrity": "sha512-qAq0oszrBGdf4bnCF6t4FoSgMsepeIXh0Pi/FhikSE6e+TvKKGpfrfUP/5pFjJZxFcLsweoau88KydCql4xSeg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@types/lodash": "^4.14.104", + "lodash": "^4.17.5", + "ts-deepmerge": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14.0.0" + }, + "peerDependencies": { + "firebase-admin": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "firebase-functions": ">=4.9.0", + "jest": ">=28.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "fetch-blob": "^3.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12.20.0" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } + "license": "ISC", + "peer": true }, - "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, + "optional": true, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 14" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, + "node_modules/gaxios/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "optional": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "ms": "^2.1.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/jest-environment-jsdom/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", + "optional": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 14" } }, - "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", - "dev": true, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT", - "peer": true, + "optional": true + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" } - }, - "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" + "node": ">=18" } }, - "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", - "dev": true, + "node_modules/gcp-metadata/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "peer": true, - "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 14" } }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", - "dev": true, + "node_modules/gcp-metadata/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "ms": "^2.1.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", - "dev": true, + "node_modules/gcp-metadata/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 14" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node_modules/gcp-metadata/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/gcp-metadata/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" }, - "peerDependencies": { - "jest-resolve": "*" + "bin": { + "rimraf": "dist/esm/bin.mjs" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "license": "ISC", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", - "dev": true, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "peer": true, "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=8.0.0" } }, - "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 0.4" } }, - "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", - "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, "bin": { - "semver": "bin/semver.js" + "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "node_modules/google-auth-library/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "peer": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 14" } }, - "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", - "dev": true, + "node_modules/google-auth-library/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.2.0" + "ms": "^2.1.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", - "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, "engines": { - "node": ">=10" + "node": ">= 14" + } + }, + "node_modules/google-auth-library/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/google-auth-library/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.2.0", - "string-length": "^4.0.2" + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" } }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", - "dev": true, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "peer": true, + "optional": true, "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" } }, - "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", + "optional": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "gaxios": "^6.0.0", + "jws": "^4.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/jscodeshift": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.16.1.tgz", - "integrity": "sha512-oMQXySazy63awNBzMpXbbVv73u3irdxTeX2L5ueRyFRxi32qb9uzdZdOY5fTBYADBG19l5M/wnGknZSV1dzCdA==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/plugin-transform-class-properties": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/preset-flow": "^7.24.7", - "@babel/preset-typescript": "^7.24.7", - "@babel/register": "^7.24.6", - "chalk": "^4.1.2", - "flow-parser": "0.*", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.7", - "neo-async": "^2.5.0", - "node-dir": "^0.1.17", - "recast": "^0.23.9", - "temp": "^0.9.4", - "write-file-atomic": "^5.0.1" - }, - "bin": { - "jscodeshift": "bin/jscodeshift.js" - }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" - }, - "peerDependenciesMeta": { - "@babel/preset-env": { - "optional": true - } + "peer": true, + "engines": { + "node": ">=8" } }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsdom/node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=6.0" + "node": ">= 0.4" }, - "optionalDependencies": { - "source-map": "~0.6.1" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsdom/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/jsdom/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": ">=12" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", + "optional": true, "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">=12" + "node": ">= 6" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "optional": true, + "dependencies": { + "ms": "^2.1.3" }, "engines": { - "node": ">=6" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" + "optional": true }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">=6" + "node": ">= 6" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "optional": true, "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" + "ms": "^2.1.3" }, "engines": { - "node": ">=12", - "npm": ">=6" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/jsonwebtoken/node_modules/ms": { + "node_modules/https-proxy-agent/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "license": "MIT", + "optional": true }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, "engines": { - "node": ">=10" + "node": ">=10.17.0" } }, - "node_modules/jspsych": { - "version": "8.2.3", - "resolved": "https://registry.npmjs.org/jspsych/-/jspsych-8.2.3.tgz", - "integrity": "sha512-jqKatUYHtDDxZgwNEkfyc9w11LLvDOCbfyTMg0J9a0WdCCUMOxlOYvCCk/o8fwH3sy4hTvAplQYhDg8XZ0SAeQ==", + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", - "peer": true, "dependencies": { - "auto-bind": "^4.0.0", - "random-words": "^1.1.1", - "seedrandom": "^3.0.5", - "type-fest": "^2.9.0" + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/jspsych/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", "peer": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, "engines": { - "node": ">=12.20" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "peer": true, + "engines": { + "node": ">=0.8.19" } }, - "node_modules/jwks-rsa": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", - "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", - "license": "MIT", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "peer": true, "dependencies": { - "@types/jsonwebtoken": "^9.0.4", - "debug": "^4.3.4", - "jose": "^4.15.4", - "limiter": "^1.1.5", - "lru-memoizer": "^2.2.0" - }, - "engines": { - "node": ">=14" + "once": "^1.3.0", + "wrappy": "1" } }, - "node_modules/jwks-rsa/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">= 0.10" } }, - "node_modules/jwks-rsa/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT", + "peer": true }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "node_modules/is-base64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-1.1.0.tgz", + "integrity": "sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==", "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "bin": { + "is_base64": "bin/is-base64", + "is-base64": "bin/is-base64" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/last-run": { + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/last-run/-/last-run-2.0.0.tgz", - "integrity": "sha512-j+y6WhTLN4Itnf9j5ZQos1BGPCS8DAwmgMroR3OzfxAsBxam0hMw7J8M3KqZl0pLQJ1jNnwIexg5DYpC/ctwEQ==", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, "engines": { - "node": ">= 10.13.0" + "node": ">=8" } }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "license": "MIT", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "readable-stream": "^2.0.5" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "node": ">=10" } }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/lead": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz", - "integrity": "sha512-DpMa59o5uGUWWjruMp71e6knmwKU3jRBBn1kjuLWN9EeIOxNeSAwvHf03WIl8g/ZMR2oSQC9ej3yeLBwdDc/pg==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=10.13.0" + "node": ">=10" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, "engines": { - "node": ">=6" + "node": ">=10" } }, - "node_modules/liftoff": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz", - "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==", + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "extend": "^3.0.2", - "findup-sync": "^5.0.0", - "fined": "^2.0.0", - "flagged-respawn": "^2.0.0", - "is-plain-object": "^5.0.0", - "rechoir": "^0.8.0", - "resolve": "^1.20.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=10.13.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "p-locate": "^4.1.0" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT", - "optional": true - }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", + "license": "MIT", + "peer": true, "dependencies": { - "yallist": "^4.0.0" + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/lru-memoizer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", - "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "6.0.0" + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "semver": "^7.5.3" + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "peer": true, "dependencies": { - "tmpl": "1.0.5" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "detect-newline": "^3.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peer": true, + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" + }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=8.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, "license": "MIT", - "optional": true, - "bin": { - "mime": "cli.js" + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=10.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "mime-db": "1.52.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "brace-expansion": "^2.0.2" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/module-alias": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.3.4.tgz", - "integrity": "sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/moo": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", - "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/moo-color": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", - "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "color-name": "^1.1.4" + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/mute-stdout": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-2.0.0.tgz", - "integrity": "sha512-32GSKM3Wyc8dg/p39lWPKYu8zci9mJFzV1Np9Of0ZEpe6Fhssn/FbI7ywAMd40uX+p3ZKh3T5EeCFv81qS3HmQ==", + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, "engines": { - "node": ">= 10.13.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=10" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "peer": true, - "bin": { - "napi-postinstall": "lib/cli.js" - }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-dir": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", - "integrity": "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==", + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "minimatch": "^3.0.2" + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">= 0.10.5" - } - }, - "node_modules/node-dir/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/node-dir/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, + "peer": true, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/now-and-later": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-3.0.0.tgz", - "integrity": "sha512-pGO4pzSdaxhWTGkfSfHx3hVzJVslFPwBp2Myq9MYN/ChfJZF87ochMAXnvz6/58RJSf5ik2q9tXprBBrk2cpcg==", + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "once": "^1.4.0" + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" }, "engines": { - "node": ">= 10.13.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "path-key": "^3.0.0" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6" + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/panva" } }, - "node_modules/object-keys": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", - "integrity": "sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, - "node_modules/object.defaults": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", - "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "array-each": "^1.0.1", - "array-slice": "^1.0.0", - "for-own": "^1.0.0", - "isobject": "^3.0.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "engines": { - "node": ">=0.10.0" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" + "peer": true, + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" + "bignumber.js": "^9.0.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" + "peer": true, + "bin": { + "json5": "lib/cli.js" }, "engines": { "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "dev": true, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12", + "npm": ">=6" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" } }, - "node_modules/ospec": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/ospec/-/ospec-3.1.0.tgz", - "integrity": "sha512-+nGtjV3vlADp+UGfL51miAh/hB4awPBkQrArhcgG4trAaoA2gKt5bf9w0m9ch9zOr555cHWaCHZEDiBOkNZSxw==", - "dev": true, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "ospec": "bin/ospec" + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" } }, - "node_modules/ospec/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" } }, - "node_modules/ospec/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "ms": "^2.1.3" }, "engines": { - "node": "*" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/ospec/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "readable-stream": "^2.0.5" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6.3" } }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "safe-buffer": "~5.1.0" } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, - "node_modules/parse-filepath": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", - "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, "license": "MIT", - "dependencies": { - "is-absolute": "^1.0.0", - "map-cache": "^0.2.0", - "path-root": "^0.1.1" - }, - "engines": { - "node": ">=0.8" - } + "peer": true }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "p-locate": "^4.1.0" }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { - "entities": "^6.0.0" + "yallist": "^4.0.0" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=10" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" } }, - "node_modules/path-exists": { + "node_modules/make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.5.3" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-root": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", - "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "path-root-regex": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" + "tmpl": "1.0.5" } }, - "node_modules/path-root-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", - "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", - "dev": true, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 0.6" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - } + "peer": true }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 0.6" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "find-up": "^4.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=8" + "node": ">=8.6" } }, - "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "dev": true, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "optional": true, + "bin": { + "mime": "cli.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" + "dependencies": { + "mime-db": "1.52.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">= 0.6" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { - "node": ">= 0.6.0" + "node": ">= 0.6" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, + "peer": true, "engines": { - "node": ">= 6" + "node": ">=6" } }, - "node_modules/proto3-json-serializer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", - "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", - "license": "Apache-2.0", - "optional": true, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", "dependencies": { - "protobufjs": "^7.2.5" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "node": ">=16 || 14 >=14.17" }, - "engines": { - "node": ">=12.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 0.10" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" + "peer": true, + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/lupomontero" + "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/psl/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.6" } }, - "node_modules/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "funding": [ { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" }, { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" + "type": "github", + "url": "https://paypal.me/jimmywarting" } ], "license": "MIT", - "peer": true + "engines": { + "node": ">=10.5.0" + } }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", "dependencies": { - "side-channel": "^1.1.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, - "license": "MIT" - }, - "node_modules/random-words": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/random-words/-/random-words-1.3.0.tgz", - "integrity": "sha512-brwCGe+DN9DqZrAQVNj1Tct1Lody6GrYL/7uei5wfjeQdacFyFd2h/51LNlOoBMzIKMS9xohuL4+wlF/z1g/xg==", "license": "MIT", "peer": true, "dependencies": { - "seedrandom": "^3.0.5" + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, + "optional": true, "engines": { - "node": ">= 0.8" + "node": ">= 6" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "devOptional": true, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "ee-first": "1.1.1" }, "engines": { - "node": ">= 6" + "node": ">= 0.8" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "license": "Apache-2.0", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", "dependencies": { - "minimatch": "^5.1.0" + "wrappy": "1" } }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "brace-expansion": "^2.0.1" + "mimic-fn": "^2.1.0" }, "engines": { - "node": ">=10" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" + "p-limit": "^2.2.0" }, "engines": { - "node": ">= 4" + "node": ">=8" } }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "resolve": "^1.20.0" + "p-try": "^2.0.0" }, "engines": { - "node": ">= 10.13.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true, - "license": "ISC" - }, - "node_modules/replace-ext": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", - "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.10" + "node": ">=6" } }, - "node_modules/replace-homedir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-2.0.0.tgz", - "integrity": "sha512-bgEuQQ/BHW0XkkJtawzrfzHFSN70f/3cNOiHa2QsYxqrjaC30X1k74FJ6xswVBP0sr0SpGIdVFuPwfrYziVeyw==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, "engines": { - "node": ">= 10.13.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/replacestream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/replacestream/-/replacestream-4.0.3.tgz", - "integrity": "sha512-AC0FiLS352pBBiZhd4VXB1Ab/lh0lEgpP+GGvZqbQh8a5cmXVoTe5EX/YeTFArnp4SRGTHh1qCHu9lGs1qG8sA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "escape-string-regexp": "^1.0.3", - "object-assign": "^4.0.1", - "readable-stream": "^2.0.2" + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "node_modules/replacestream/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=0.8.0" + "node": ">=8" } }, - "node_modules/replacestream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "peer": true, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/replacestream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/replacestream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" + "engines": { + "node": ">=8" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "devOptional": true, - "license": "MIT", + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, + "peer": true, "engines": { - "node": ">= 0.4" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, + "peer": true, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/resolve-dir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", - "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" + "find-up": "^4.0.0" }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/resolve-options": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-2.0.0.tgz", - "integrity": "sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==", + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "value-or-function": "^4.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "license": "MIT", - "optional": true, "engines": { - "node": ">= 4" + "node": ">= 0.6.0" } }, - "node_modules/retry-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", - "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", - "license": "MIT", + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@types/request": "^2.48.8", - "extend": "^3.0.2", - "teeny-request": "^9.0.0" + "protobufjs": "^7.2.5" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=12.0.0" } }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", "dependencies": { - "glob": "^7.1.3" + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" }, - "bin": { - "rimraf": "bin.js" + "engines": { + "node": ">= 0.10" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } + "peer": true }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "side-channel": "^1.1.0" }, "engines": { - "node": "*" + "node": ">=0.6" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { - "node": "*" + "node": ">= 0.6" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-dts": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.1.1.tgz", - "integrity": "sha512-aSHRcJ6KG2IHIioYlvAOcEq6U99sVtqDDKVhnwt70rW6tsz3tv5OSjEiWcgzfsHdLyGXZ/3b/7b/+Za3Y6r1XA==", + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "LGPL-3.0-only", + "license": "MIT", + "peer": true + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, "dependencies": { - "magic-string": "^0.30.10" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/Swatinem" - }, - "optionalDependencies": { - "@babel/code-frame": "^7.24.2" - }, - "peerDependencies": { - "rollup": "^3.29.4 || ^4", - "typescript": "^4.5 || ^5.0" + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" } }, - "node_modules/rollup-plugin-esbuild": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-6.1.1.tgz", - "integrity": "sha512-CehMY9FAqJD5OUaE/Mi1r5z0kNeYxItmRO2zG4Qnv2qWKF09J2lTy5GUzjJR354ZPrLkCj4fiBN41lo8PzBUhw==", - "dev": true, - "license": "MIT", + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", "dependencies": { - "@rollup/pluginutils": "^5.0.5", - "debug": "^4.3.4", - "es-module-lexer": "^1.3.1", - "get-tsconfig": "^4.7.2" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=14.18.0" - }, - "peerDependencies": { - "esbuild": ">=0.18.0", - "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" + "node": ">=10" } }, - "node_modules/rollup-plugin-esbuild/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=0.10.0" } }, - "node_modules/rollup-plugin-esbuild/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup-plugin-modify": { + "node_modules/resolve-cwd": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-modify/-/rollup-plugin-modify-3.0.0.tgz", - "integrity": "sha512-p/ffs0Y2jz2dEnWjq1oVC7SY37tuS+aP7whoNaQz1EAAOPg+k3vKJo8cMMWx6xpdd0NzhX4y2YF9o/NPu5YR0Q==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "magic-string": "0.25.2", - "ospec": "3.1.0" - } - }, - "node_modules/rollup-plugin-modify/node_modules/magic-string": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.2.tgz", - "integrity": "sha512-iLs9mPjh9IuTtRsqqhNGYcZXGei0Nh/A4xirrsqW7c+QhKVFL2vm7U09ru6cHRD22azaP/wMDgI+HCqbETMTtg==", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "sourcemap-codec": "^1.4.4" + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/rollup-plugin-node-externals": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-7.1.3.tgz", - "integrity": "sha512-RM+7tJAejAoRsCf93TptTSdqUhRA8S78DleihMiu54Kac+uLkd9VIegLPhGnaW3ehZTXh56+R301mFH6j2A7vw==", + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "funding": [ - { - "type": "patreon", - "url": "https://patreon.com/Septh" - }, - { - "type": "paypal", - "url": "https://paypal.me/septh07" - } - ], "license": "MIT", + "peer": true, "engines": { - "node": ">= 21 || ^20.6.0 || ^18.19.0" - }, - "peerDependencies": { - "rollup": "^3.0.0 || ^4.0.0" + "node": ">=8" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", + "optional": true, "engines": { - "node": ">=0.12.0" + "node": ">= 4" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, "dependencies": { - "tslib": "^2.1.0" + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" } }, "node_modules/safe-buffer": { @@ -14452,49 +7024,17 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "license": "MIT", - "peer": true - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } }, - "node_modules/semver-greatest-satisfied-range": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-2.0.0.tgz", - "integrity": "sha512-lH3f6kMbwyANB7HuOWRMlLCa2itaCrZJ+SAqqkSZrZKO/cAsk2EOyaKHUtNkVLFyFW9pct22SFesFp3Z7zpA0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "sver": "^1.8.3" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -14558,19 +7098,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -14676,19 +7203,13 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -14699,6 +7220,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14709,35 +7231,19 @@ "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, - "node_modules/sparkles": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-2.1.0.tgz", - "integrity": "sha512-r7iW1bDw8R/cFifrD3JnQJX0K1jqT0kprL48BiBpLZLJPmAm34zsVBsK5lc7HirZYZqMW65dOXZgbAGt/I6frg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/stack-utils": { "version": "2.0.6", @@ -14745,6 +7251,7 @@ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -14761,16 +7268,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-composer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", - "integrity": "sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "streamx": "^2.13.2" - } - }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -14781,13 +7278,6 @@ "stubs": "^3.0.0" } }, - "node_modules/stream-exhaust": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz", - "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==", - "dev": true, - "license": "MIT" - }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -14821,6 +7311,7 @@ "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -14835,6 +7326,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -14845,6 +7337,7 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -14954,6 +7447,7 @@ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -14964,188 +7458,57 @@ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/stubs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", - "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "license": "MIT", - "optional": true - }, - "node_modules/sucrase": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", - "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "7.1.6", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sver": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/sver/-/sver-1.8.4.tgz", - "integrity": "sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==", - "dev": true, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", - "optionalDependencies": { - "semver": "^6.3.0" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" + "optional": true }, - "node_modules/sync-fetch": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz", - "integrity": "sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==", - "dev": true, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", "license": "MIT", - "dependencies": { - "buffer": "^5.7.1", - "node-fetch": "^2.6.1" - }, - "engines": { - "node": ">=14" - } + "optional": true }, - "node_modules/sync-fetch/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "whatwg-url": "^5.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">=8" } }, "node_modules/synckit": { @@ -15238,39 +7601,13 @@ "streamx": "^2.12.5" } }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/temp/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -15286,6 +7623,7 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -15298,6 +7636,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -15319,6 +7658,7 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -15335,93 +7675,13 @@ "b4a": "^1.6.4" } }, - "node_modules/textextensions": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-3.3.0.tgz", - "integrity": "sha512-mk82dS8eRABNbeVJrEiN5/UMSCliINAuz8mkUwH4SwslkNP//gbEzlWNS5au0z5Dpx40SQxzqZevZkn+WYJ9Dw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/through2": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz", - "integrity": "sha512-45Llu+EwHKtAZYTPPVn3XZHBgakWMN3rokhEv5hu596XP+cNgplMg+Gj+1nmAvj+L0K7+N49zBKx5rah5u0QIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "~1.0.17", - "xtend": "~2.1.1" - } - }, - "node_modules/through2/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/through2/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -15429,6 +7689,7 @@ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-number": "^7.0.0" }, @@ -15436,19 +7697,6 @@ "node": ">=8.0" } }, - "node_modules/to-through": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/to-through/-/to-through-3.0.0.tgz", - "integrity": "sha512-y8MN937s/HVhEoBU1SxfHC+wxCHkV1a9gW8eAdTadYh/bGyesZIVcbjI+mSpFbSVwQici/XjBjuUyri1dnXwBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -15458,38 +7706,12 @@ "node": ">=0.6" } }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "devOptional": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/ts-deepmerge": { "version": "2.0.7", @@ -15498,92 +7720,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -15596,6 +7732,7 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -15606,98 +7743,39 @@ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/undertaker": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-2.0.0.tgz", - "integrity": "sha512-tO/bf30wBbTsJ7go80j0RzA2rcwX6o7XPBpeFcb+jzoeb4pfMM2zUeSDIkY1AWqeZabWxaQZ/h8N9t35QKDLPQ==", - "dev": true, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { - "bach": "^2.0.1", - "fast-levenshtein": "^3.0.0", - "last-run": "^2.0.0", - "undertaker-registry": "^2.0.0" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.6" } }, - "node_modules/undertaker-registry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-2.0.0.tgz", - "integrity": "sha512-+hhVICbnp+rlzZMgxXenpvTxpuvA67Bfgtt+O9WOE5jo7w/dyiF1VmoZVIHvP2EkUjsyKyTwYKlLhA+j47m1Ew==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/undertaker/node_modules/fast-levenshtein": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", - "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fastest-levenshtein": "^1.0.7" + "node": ">=14.17" } }, "node_modules/undici-types": { @@ -15706,16 +7784,6 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -15781,6 +7849,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -15792,17 +7861,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15837,6 +7895,7 @@ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -15846,26 +7905,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8flags": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", - "integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/value-or-function": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz", - "integrity": "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -15875,224 +7914,13 @@ "node": ">= 0.8" } }, - "node_modules/vinyl": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", - "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^2.1.1", - "clone-buffer": "^1.0.0", - "clone-stats": "^1.0.0", - "cloneable-readable": "^1.0.0", - "remove-trailing-separator": "^1.0.1", - "replace-ext": "^1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/vinyl-contents": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-contents/-/vinyl-contents-2.0.0.tgz", - "integrity": "sha512-cHq6NnGyi2pZ7xwdHSW1v4Jfnho4TEGtxZHw01cmnc8+i7jgR6bRnED/LbrKan/Q7CvVLbnvA5OepnhbpjBZ5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^5.0.0", - "vinyl": "^3.0.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/vinyl-contents/node_modules/replace-ext": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", - "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/vinyl-contents/node_modules/vinyl": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", - "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^2.1.2", - "remove-trailing-separator": "^1.1.0", - "replace-ext": "^2.0.0", - "teex": "^1.0.1" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/vinyl-fs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz", - "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fs-mkdirp-stream": "^2.0.1", - "glob-stream": "^8.0.3", - "graceful-fs": "^4.2.11", - "iconv-lite": "^0.6.3", - "is-valid-glob": "^1.0.0", - "lead": "^4.0.0", - "normalize-path": "3.0.0", - "resolve-options": "^2.0.0", - "stream-composer": "^1.0.2", - "streamx": "^2.14.0", - "to-through": "^3.0.0", - "value-or-function": "^4.0.0", - "vinyl": "^3.0.1", - "vinyl-sourcemap": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/vinyl-fs/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/vinyl-fs/node_modules/replace-ext": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", - "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/vinyl-fs/node_modules/vinyl": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", - "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^2.1.2", - "remove-trailing-separator": "^1.1.0", - "replace-ext": "^2.0.0", - "teex": "^1.0.1" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/vinyl-sourcemap": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-2.0.0.tgz", - "integrity": "sha512-BAEvWxbBUXvlNoFQVFVHpybBbjW1r03WhohJzJDSfgrrK5xVYIDTan6xN14DlyImShgDRv2gl9qhM6irVMsV0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "convert-source-map": "^2.0.0", - "graceful-fs": "^4.2.10", - "now-and-later": "^3.0.0", - "streamx": "^2.12.5", - "vinyl": "^3.0.0", - "vinyl-contents": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/vinyl-sourcemap/node_modules/replace-ext": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", - "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/vinyl-sourcemap/node_modules/vinyl": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", - "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^2.1.2", - "remove-trailing-separator": "^1.1.0", - "replace-ext": "^2.0.0", - "teex": "^1.0.1" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/vue-jscodeshift-adapter": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/vue-jscodeshift-adapter/-/vue-jscodeshift-adapter-2.2.1.tgz", - "integrity": "sha512-4aTkHYknYgP9uk/465MDZjvrotF6o2RMWDy0t+9RUULfgbkT+rHLrNw8onxOk4Y8fCpgcS81b09afodRZY/LuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "vue-sfc-descriptor-to-string": "^1.0.0", - "vue-template-compiler": "^2.5.13" - } - }, - "node_modules/vue-sfc-descriptor-to-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/vue-sfc-descriptor-to-string/-/vue-sfc-descriptor-to-string-1.0.0.tgz", - "integrity": "sha512-VYNMsrIPZQZau5Gk8IVtgonN1quOznP9/pLIF5m2c4R30KCDDe3NwthrsM7lSUY2K4lezcb8j3Wu8cQhBuZEMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^3.2.0" - } - }, - "node_modules/vue-template-compiler": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", - "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "makeerror": "1.0.12" } @@ -16110,8 +7938,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "devOptional": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -16136,49 +7964,12 @@ "node": ">=0.8.0" } }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "devOptional": true, "license": "MIT", + "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -16199,13 +7990,6 @@ "node": ">= 8" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -16307,6 +8091,7 @@ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" @@ -16315,57 +8100,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/xtend": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", - "integrity": "sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==", - "dev": true, - "dependencies": { - "object-keys": "~0.4.0" - }, - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -16456,16 +8190,6 @@ "node": ">=8" } }, - "node_modules/yazl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", - "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -16479,19 +8203,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", diff --git a/functions/package.json b/functions/package.json index 812883a..f1cef00 100644 --- a/functions/package.json +++ b/functions/package.json @@ -9,7 +9,8 @@ "build": "tsc", "watch": "tsc --watch", "deploy": "npm run build && firebase deploy --only functions", - "lint": "echo 'TODO: Linting is not set up yet'" + "lint": "echo 'TODO: Linting is not set up yet'", + "sync:metadata": "node scripts/sync-metadata.mjs" }, "engines": { "node": "22" @@ -31,7 +32,8 @@ "devDependencies": { "@types/archiver": "^7.0.0", "@types/is-base64": "^1.1.3", - "firebase-functions-test": "^3.4.1" + "firebase-functions-test": "^3.4.1", + "typescript": "^5.9.3" }, "private": true } diff --git a/functions/scripts/sync-metadata.mjs b/functions/scripts/sync-metadata.mjs new file mode 100644 index 0000000..233996c --- /dev/null +++ b/functions/scripts/sync-metadata.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * sync-metadata.mjs — refresh the vendored @jspsych/metadata copy in functions/metadata/. + * + * WHY THIS EXISTS + * DataPipe depends on @jspsych/metadata, but the version published to npm is stale + * (it lags the fixes that live on the repo's main branch — e.g. nested-object/array + * expansion and the getExtractedArrays/Objects APIs). Until upstream cuts a fresh npm + * release, we vendor the package: build it from a PINNED upstream commit and commit the + * built dist into functions/metadata/. `functions/package.json` references it as + * "file:metadata", and functions/src imports "../metadata/dist/index.js" directly, so + * deploys need NO metadata build step. + * + * WHAT IT DOES + * 1. Clone the upstream repo into a temp dir at a chosen ref (default: main). + * 2. Install + build packages/metadata there. + * 3. Replace functions/metadata/ with the freshly built dist + package.json + LICENSE. + * 4. Record exact provenance (source repo, commit SHA, commit date) in VENDORED_FROM.json + * and regenerate functions/metadata/README.md. + * + * USAGE + * npm run sync:metadata # vendor from upstream main + * npm run sync:metadata -- --ref # vendor from a specific commit/branch + * METADATA_REPO=/path/to/local/clone npm run sync:metadata # use a local clone (faster) + * + * EXIT PLAN + * The day @jspsych/metadata ships a released npm version containing these fixes, delete + * this script + functions/metadata/, and set functions/package.json's dependency to the + * published "^x.y.z". Then semver does the tracking and this whole mechanism goes away. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, mkdirSync, cpSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REPO = "https://github.com/jspsych/metadata.git"; +const PACKAGE_SUBDIR = "packages/metadata"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const functionsDir = join(scriptDir, ".."); // functions/ +const vendorDir = join(functionsDir, "metadata"); // functions/metadata/ + +function parseArgs(argv) { + const args = { ref: "main", repo: process.env.METADATA_REPO || DEFAULT_REPO }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--ref") args.ref = argv[++i]; + else if (argv[i] === "--repo") args.repo = argv[++i]; + else throw new Error(`Unknown argument: ${argv[i]}`); + } + return args; +} + +// Run a command, streaming its output; throws on non-zero exit. shell:true so npm/npm.cmd +// resolves on Windows as well as POSIX. +function run(cmd, cmdArgs, cwd) { + console.log(`\n$ ${cmd} ${cmdArgs.join(" ")} (in ${cwd})`); + return execFileSync(cmd, cmdArgs, { cwd, stdio: "inherit", shell: true }); +} + +function capture(cmd, cmdArgs, cwd) { + return execFileSync(cmd, cmdArgs, { cwd, encoding: "utf8", shell: true }).trim(); +} + +function main() { + const { ref, repo } = parseArgs(process.argv.slice(2)); + const tmp = mkdtempSync(join(tmpdir(), "metadata-sync-")); + const checkout = join(tmp, "metadata"); + + try { + console.log(`Vendoring @jspsych/metadata from ${repo} @ ${ref}`); + + // 1. Clone + pin. Cloning a local path works too (copies committed history only). + run("git", ["clone", "--no-single-branch", repo, checkout], tmp); + run("git", ["checkout", ref], checkout); + const commit = capture("git", ["rev-parse", "HEAD"], checkout); + const commitDate = capture("git", ["show", "-s", "--format=%cI", "HEAD"], checkout); + const shortSha = commit.slice(0, 12); + + // 2. Install the monorepo, then build just the metadata package. + const pkgDir = join(checkout, PACKAGE_SUBDIR); + if (!existsSync(pkgDir)) throw new Error(`${PACKAGE_SUBDIR} not found in ${repo}@${ref}`); + const installCmd = existsSync(join(checkout, "package-lock.json")) ? "ci" : "install"; + run("npm", [installCmd, "--no-audit", "--no-fund"], checkout); + run("npm", ["run", "build"], pkgDir); + + const builtDist = join(pkgDir, "dist"); + if (!existsSync(builtDist)) throw new Error("build did not produce a dist/ directory"); + const pkg = JSON.parse(capture("node", ["-p", "JSON.stringify(require('./package.json'))"], pkgDir)); + const version = pkg.version; + + // 3. Replace functions/metadata/ with the fresh build. Wipe first so removed upstream + // files don't linger. Keep only what runtime/deploy needs: dist, package.json, LICENSE. + rmSync(vendorDir, { recursive: true, force: true }); + mkdirSync(vendorDir, { recursive: true }); + cpSync(builtDist, join(vendorDir, "dist"), { recursive: true }); + + // Sanitize package.json: we ship only the built dist, so strip everything that would + // run or resolve against source at install time. Crucially, drop `scripts` — upstream + // has a `prepare: "npm run build"` that npm runs for `file:` deps on install; with no + // src/build-config/build-deps here that would fail `npm install` in functions/. Keep the + // runtime `dependencies` (e.g. csv-parse, which dist/index.js imports, not bundled). + delete pkg.scripts; + delete pkg.devDependencies; + pkg.files = ["dist"]; + writeFileSync(join(vendorDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); + // Preserve the upstream license for attribution. It may sit in the package dir or, in a + // monorepo, only at the repo root — check the package first, then fall back to the root. + for (const lic of ["LICENSE", "LICENSE.md", "license"]) { + const src = existsSync(join(pkgDir, lic)) ? join(pkgDir, lic) + : existsSync(join(checkout, lic)) ? join(checkout, lic) + : null; + if (src) { cpSync(src, join(vendorDir, "LICENSE")); break; } + } + + // 4. Provenance + generated README. + const provenance = { + source: repo === process.env.METADATA_REPO ? DEFAULT_REPO : repo, + package: "@jspsych/metadata", + version, + ref, + commit, + commitDate, + syncedAt: new Date().toISOString(), + note: "Generated by functions/scripts/sync-metadata.mjs — do not edit dist/ by hand.", + }; + writeFileSync(join(vendorDir, "VENDORED_FROM.json"), JSON.stringify(provenance, null, 2) + "\n"); + writeFileSync(join(vendorDir, "README.md"), renderReadme(provenance, shortSha)); + + console.log(`\n✔ Vendored @jspsych/metadata ${version} @ ${shortSha} (${commitDate})`); + console.log(" Review the diff, run the functions tests, then commit functions/metadata/."); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +function renderReadme(p, shortSha) { + return `# Vendored @jspsych/metadata (GENERATED — do not hand-edit) + +This directory is a **built, committed copy** of \`@jspsych/metadata\`, produced by +\`functions/scripts/sync-metadata.mjs\`. \`functions/package.json\` references it as +\`"file:metadata"\` and \`functions/src\` imports \`../metadata/dist/index.js\` directly, so +deploys need no metadata build step. + +## Why vendored instead of an npm dependency? +The version published to npm lags the fixes on the upstream \`main\` branch (nested +object/array expansion, the \`getExtractedArrays\`/\`getExtractedObjects\` APIs, etc.). We +pin to a specific upstream commit and rebuild from it until upstream cuts a fresh release. + +## Current pin +- **Package:** ${p.package} ${p.version} +- **Source:** ${p.source} +- **Commit:** \`${p.commit}\` (${shortSha}), ${p.commitDate} +- **Synced:** ${p.syncedAt} + +(Machine-readable: \`VENDORED_FROM.json\`.) + +## Re-syncing with upstream +\`\`\` +cd functions +npm run sync:metadata # from upstream main +npm run sync:metadata -- --ref # from a specific commit +\`\`\` +Then review the diff, run \`npm test\` at the repo root, and commit \`functions/metadata/\`. +A scheduled CI job (\`.github/workflows/metadata-drift-check.yml\`) flags when upstream main +has moved past this pin. + +## Exit plan +When \`@jspsych/metadata\` ships a released npm version with these fixes, delete this +directory and the sync script, and set the dependency to the published \`"^x.y.z"\`. +`; +} + +main(); diff --git a/functions/src/__tests__/metadata-production.test.js b/functions/src/__tests__/metadata-production.test.js index b4fad2f..1ca3c80 100644 --- a/functions/src/__tests__/metadata-production.test.js +++ b/functions/src/__tests__/metadata-production.test.js @@ -7,6 +7,13 @@ var sampleData = `[{ "time_elapsed": 776 }]` +// Golden output from the vendored @jspsych/metadata (functions/metadata/). +// NOTE: the data-derived fields below (trial_type.levels, the min/max values) are what make +// this a regression guard, not just a format check: produceMetadata pre-parses the JSON into +// an array before calling generate(). If a future `npm run sync:metadata` ever pulls a build +// whose generate() rejects a pre-parsed array (an earlier published version did — it silently +// returned only the empty default template), these levels/min/max would disappear and this +// toEqual would fail. Keep the derived values in the expectation. var sampleMetadata = { "@context": "https://schema.org", @@ -17,28 +24,28 @@ var sampleMetadata = "schemaVersion": "Psych-DS 0.4.0", "variableMeasured": [ { + "@type": "PropertyValue", "description": "The name of the plugin used to run the trial.", "levels": ["html-keyboard-response"], "name": "trial_type", - "type": "PropertyValue", "value": "string" }, { + "@type": "PropertyValue", "description": "The index of the current trial across the whole experiment.", "maxValue": 1, "minValue": 1, "name": "trial_index", - "type": "PropertyValue", - "value": "numeric" + "value": "number" }, { + "@type": "PropertyValue", "description": "The number of milliseconds between the start of the experiment and when the trial ended.", "maxValue": 776, "minValue": 776, "name": "time_elapsed", - "type": "PropertyValue", - "value": "numeric" + "value": "number" } ] } @@ -56,9 +63,11 @@ describe('produceMetadata', () => { const result = await produceMetadata(sampleData, options); - const optionMetadata = sampleMetadata; - optionMetadata.randomField = "this is a field" + // Build the expectation from a deep copy so we don't mutate the shared sampleMetadata + // fixture used by the test above (the previous version aliased it). + const optionMetadata = structuredClone(sampleMetadata); + optionMetadata.randomField = "this is a field"; expect(result).toEqual(optionMetadata); }); -}); \ No newline at end of file +}); diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index b37cb72..a15f5eb 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -1,5 +1,4 @@ -// Will likely change once in production. -import jsPsychMetadata from '../metadata/dist/index.js'; +import jsPsychMetadata from '@jspsych/metadata'; import { Metadata } from './interfaces'; export default async function produceMetadata(data: string, options: object | null = null) { @@ -16,7 +15,11 @@ export default async function produceMetadata(data: string, options: object | nu if(!csvFlag) data = JSON.parse(data); // Generates the metadata, using the options if they are provided. - options ? await metadata.generate(data, options, csvFlag) : await metadata.generate(data, {}, csvFlag); + // The vendored @jspsych/metadata (see functions/metadata/) changed generate()'s + // signature to generate(data, metadata={}, ext='json'|'csv', options={}) — the 3rd + // arg is now a string extension, not the boolean csv flag the old fork used. + const ext: 'json' | 'csv' = csvFlag ? 'csv' : 'json'; + options ? await metadata.generate(data, options, ext) : await metadata.generate(data, {}, ext); const incomingMetadata: Metadata = metadata.getMetadata() as Metadata; From 5b53084d7e4115d58e85134b092527875bbdc29a Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Thu, 2 Jul 2026 12:02:13 -0400 Subject: [PATCH 004/181] fix(ci): drop obsolete metadata build step from workflows functions/metadata is now a pre-built vendored dist with no lockfile or build script, so the "npm ci && npm run build" step in that directory fails with EUSAGE. The dist is committed and installed as a file: dependency by the existing functions npm ci step. Co-Authored-By: Claude Fable 5 --- .github/workflows/firebase-deploy-test.yml | 7 ++----- .github/workflows/firebase-deploy.yml | 7 ++----- .github/workflows/node.js.yml | 7 ++----- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index 087b592..430eee3 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -46,11 +46,8 @@ jobs: run: firebase experiments:enable webframeworks - name: Install dependencies run: npm ci - - name: Install dependencies and build metadata - working-directory: functions/metadata - run: | - npm ci - npm run build + # functions/metadata is a pre-built vendored dist (see functions/metadata/README.md); + # it is installed as a file: dependency by the functions npm ci below. - name: Create functions environment file working-directory: functions run: | diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml index 39f945f..c4f7538 100644 --- a/.github/workflows/firebase-deploy.yml +++ b/.github/workflows/firebase-deploy.yml @@ -46,11 +46,8 @@ jobs: run: firebase experiments:enable webframeworks - name: Install dependencies run: npm ci - - name: Install dependencies and build metadata - working-directory: functions/metadata - run: | - npm ci - npm run build + # functions/metadata is a pre-built vendored dist (see functions/metadata/README.md); + # it is installed as a file: dependency by the functions npm ci below. - name: Create functions environment file working-directory: functions run: | diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 23284f2..611bdfd 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -49,11 +49,8 @@ jobs: - name: Enable firebase webframeworks run: firebase experiments:enable webframeworks - run: npm ci - - name: Install dependencies and build metadata - working-directory: functions/metadata - run: | - npm ci - npm run build + # functions/metadata is a pre-built vendored dist (see functions/metadata/README.md); + # it is installed as a file: dependency by the functions npm ci below. - name: Install dependencies and build functions working-directory: functions run: | From d392ec5102a5d274bc1da515f36736b5b1982663 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Thu, 2 Jul 2026 12:24:52 -0400 Subject: [PATCH 005/181] fix(test): stop base64 and data emulator suites sharing one log doc Both suites used logs/testlog and each deletes it at test start; jest runs them in parallel workers, so the base64 suite's delete could wipe the data suite's saveData counter between write and read (doc exists via the base64 increment, saveData undefined). Rename the base64 suite's doc to base64-testlog, matching its other doc IDs. Co-Authored-By: Claude Fable 5 --- functions/src/__tests__/base64data-emulator.test.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/functions/src/__tests__/base64data-emulator.test.js b/functions/src/__tests__/base64data-emulator.test.js index 4443193..7c35f3a 100644 --- a/functions/src/__tests__/base64data-emulator.test.js +++ b/functions/src/__tests__/base64data-emulator.test.js @@ -78,21 +78,24 @@ describe("apiData", () => { it("should increment the write request log for the experiment when there is a complete request", async () => { const db = getFirestore(); - await db.collection("logs").doc("testlog").delete(); + // Log doc ID must be unique to this suite: data-emulator.test.js runs in a + // parallel jest worker and deletes its own log doc, so sharing "testlog" + // let each suite wipe the other's counters mid-test. + await db.collection("logs").doc("base64-testlog").delete(); await saveData({ - experimentID: "testlog", + experimentID: "base64-testlog", data: "test", filename: "test", }); - let doc = await waitForLog(db, "testlog", "saveBase64Data", 1); + let doc = await waitForLog(db, "base64-testlog", "saveBase64Data", 1); expect(doc.data().saveBase64Data).toBe(1); await saveData({ - experimentID: "testlog", + experimentID: "base64-testlog", data: "test", filename: "test", }); - doc = await waitForLog(db, "testlog", "saveBase64Data", 2); + doc = await waitForLog(db, "base64-testlog", "saveBase64Data", 2); expect(doc.data().saveBase64Data).toBe(2); }); From a55858382c8cc78d76cfea356e305aa8491cfc5f Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Thu, 2 Jul 2026 12:48:34 -0400 Subject: [PATCH 006/181] refactor(metadata): make the metadata transaction Firestore-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entire metadata block (OSF list/download/upload calls included) ran inside db.runTransaction. Firestore retries the transaction callback on contention, which would re-run those OSF network calls — a latent duplicate-write risk. The transaction now only reads, merges, and writes the Firestore metadata doc; OSF I/O happens before (existence check) and after (mirror upload) the transaction. When the merge needs the OSF copy as its base (Firestore empty, OSF populated), the transaction aborts via a sentinel, the copy is downloaded outside it, and the transaction re-runs. Also fixes two pre-existing bugs in the process: - The Firestore-only branch checked putFileOSF's result with `errorCode !== 210`, which threw even on success (success returns errorCode null); now checks `!response.success`. - The OSF-only branch uploaded the unmerged incoming metadata to OSF while Firestore got the merged version; both now get the merged one. - The create branch's putFileOSF result was silently ignored; it is now checked like the other branches. blockMetadata now receives the OSF token api-data already resolved via resolveToken, instead of re-deriving it with duplicated logic that could trigger a second token refresh per submission. Co-Authored-By: Claude Fable 5 --- functions/src/api-data.ts | 2 +- functions/src/metadata-block.ts | 214 +++++++++++++------------------- 2 files changed, 85 insertions(+), 131 deletions(-) diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 3c14c3a..27f7f5a 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -131,7 +131,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //Creates or references a document containing the metadata for the experiment in the metdata collection on Firestore. const metadata_doc_ref: DocumentReference = db.collection("metadata").doc(experimentID); - const metadataResponse: MetadataResponse = await blockMetadata(exp_data, user_data, metadata_doc_ref, data, metadataOptions); + const metadataResponse: MetadataResponse = await blockMetadata(exp_data, token, metadata_doc_ref, data, metadataOptions); if (metadataResponse.success === false) { await cleanupPending(pendingPath); diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index d607c3e..8b7de60 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -7,15 +7,17 @@ import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; -import { decrypt } from "./crypto-utils.js"; -import { refreshAndUpdateUser } from "./refresh-token.js"; -import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; - +import { ExperimentData, Metadata, MetadataResponse } from './interfaces'; +// Sentinel thrown inside the transaction when the merge needs the OSF copy of +// the metadata as its base. The OSF download must happen outside the +// transaction (Firestore retries the transaction callback on contention, which +// would re-run any network call inside it), so we abort, download, and re-run. +const NEEDS_OSF_METADATA = new Error("needs-osf-metadata"); export default async function blockMetadata( exp_data: ExperimentData, - user_data: UserData, + osfToken: string, metadata_doc_ref: DocumentReference, data: string, metadataOptions: object, @@ -23,138 +25,90 @@ export default async function blockMetadata( let metadataMessage: {metadataMessage: string} = {metadataMessage: ''}; -let decryptedOsfToken: string; -if (user_data.usingPersonalToken) { - decryptedOsfToken = decrypt(user_data.osfToken); -} else { - if (Date.now() > user_data.authTokenExpires) { - const refreshResult = await refreshAndUpdateUser(exp_data.owner, decrypt(user_data.refreshToken)); - if (!refreshResult.success) { - // Fall back to PAT if available - if (user_data.osfTokenValid && user_data.osfToken) { - decryptedOsfToken = decrypt(user_data.osfToken); - } else { - return { success: false, metadataMessage: "OAuth token refresh failed" }; - } +try { + + //Only run if metadata collection is enabled. + if (!exp_data.metadataActive) { + metadataMessage = MESSAGES.METADATA_NOT_ACTIVE; + const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; + return metadataResponse; + } + + //Metadata is produced from the incoming data using the metadata module. + const incomingMetadata: Metadata = await produceMetadata(data, metadataOptions); + + //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: + //dataset_description.json exists in the OSF project. + const osfMetadataId: string | undefined = (await processMetadata(exp_data.osfFilesLink, osfToken)).metadataId; + + //Populated only when Firestore has no metadata but OSF does (see sentinel above). + let osfMetadata: Metadata | undefined; + + //The transaction is Firestore-only: read the metadata doc, merge, write it + //back. All OSF network I/O happens before or after, so a transaction retry + //can never repeat an OSF call. + const runMergeTransaction = () => db.runTransaction(async (t) => { + const firestoreMetadata: Metadata | undefined = (await t.get(metadata_doc_ref)).data()?.metadata; + + //Record which of the four states we are in. This is set before any + //failure point below so that error responses still report the state. + if (firestoreMetadata) { + metadataMessage = osfMetadataId ? MESSAGES.METADATA_IN_OSF_AND_FIRESTORE : MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; } else { - decryptedOsfToken = refreshResult.accessToken!; + metadataMessage = osfMetadataId ? MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE : MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; } - } else { - decryptedOsfToken = decrypt(user_data.authToken); - } -} -try { + if (!firestoreMetadata && osfMetadataId && !osfMetadata) { + throw NEEDS_OSF_METADATA; + } - //Only run if metadata collection is enabled. - if (exp_data.metadataActive) { - - //All metadata processing is done within a transaction to ensure consistency. - await db.runTransaction(async (t) => { - - //Metadata is produced from the incoming data using the metdata module. - const incomingMetadata: Metadata = (await produceMetadata(data, metadataOptions)); - - //Retrieves the metadata from the Firestore metadata document. - const firestoreMetadataObj: DocumentData | undefined = (await t.get(metadata_doc_ref)).data(); - - const firestoreMetadata: Metadata | undefined = firestoreMetadataObj ? firestoreMetadataObj.metadata : undefined; - - //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: - //dataset_description.json exists in the OSF project. - const osfMetadataId: string | undefined = (await processMetadata(exp_data.osfFilesLink, decryptedOsfToken)).metadataId; - - //When firestore and OSF both have metadata, updating is done with respect to firestore. - //When firestore has metadata but OSF does not, updating is done with respect to firestore. - if ( (osfMetadataId && firestoreMetadata) || (!osfMetadataId && firestoreMetadata) ) { - - // Sets the metadata message. - if (osfMetadataId) metadataMessage = MESSAGES.METADATA_IN_OSF_AND_FIRESTORE; - else metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; - - // Incoming metadata is used to update firestore metadata. - const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); - - t.update(metadata_doc_ref, {metadata: updatedMetadata}); - - //If a metadata file exists in OSF, it is updated with the above metadata. - if (osfMetadataId){ - await updateFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, - JSON.stringify(updatedMetadata, null, 2), - osfMetadataId - ) - - } - //If a metadata file does not exist in OSF, it is created with the above metadata. - else { - - const response = await putFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, - JSON.stringify(updatedMetadata, null, 2), - `dataset_description.json` - ); - - if (response.errorCode !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); - - } - } - //When OSF has metadata but firestore does not, updating is done with respect to OSF. - if (osfMetadataId && !firestoreMetadata) { - - metadataMessage = MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE; - - //Metadata is downloaded from OSF, and is compared to incoming metadata to produce an updated version. - // ********[IMPORTANT]*********** - // Since Metadata is in OSF as evidenced by the metadata ID, it is downloaded, and the type is asserted. - const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, osfMetadataId); - - const osfMetadata: Metadata = downloadResponse.metadata; - - const updatedMetadata = await updateMetadata(osfMetadata, incomingMetadata); - - //Up to date metadata is uploaded to firestore. - t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); - - //Since metadata exists in OSF, it is updated and not set. - await updateFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, - JSON.stringify(incomingMetadata, null, 2), - osfMetadataId - ); - - } - // When neither OSF nor firestore have metadata, the metadata is created in OSF and firestore. - if (!osfMetadataId && !firestoreMetadata) { - - metadataMessage = MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; - - //Incoming metadata is uploaded to firestore and OSF. - - t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); - - await putFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, - JSON.stringify(incomingMetadata, null, 2), - `dataset_description.json` - ); - - } + //When Firestore has metadata, updating is done with respect to Firestore. + //When only OSF has metadata, the downloaded OSF copy is the base instead. + //When neither has metadata, the incoming metadata is used as-is. + const baseMetadata: Metadata | undefined = firestoreMetadata ?? osfMetadata; + const updatedMetadata = baseMetadata ? await updateMetadata(baseMetadata, incomingMetadata) : incomingMetadata; + + t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); + + return updatedMetadata; }); - + + let updatedMetadata; + try { + updatedMetadata = await runMergeTransaction(); + } catch (error) { + if (error !== NEEDS_OSF_METADATA) throw error; + //Metadata is in OSF as evidenced by the metadata ID, so it is downloaded + //to serve as the merge base, and the transaction is re-run. + osfMetadata = (await downloadMetadata(exp_data.osfFilesLink, osfToken, osfMetadataId as string)).metadata; + updatedMetadata = await runMergeTransaction(); + } + + //Firestore is now up to date; mirror the merged metadata to OSF. + const metadataFileContents = JSON.stringify(updatedMetadata, null, 2); + + //If a metadata file exists in OSF, it is updated. Otherwise it is created. + if (osfMetadataId) { + await updateFileOSF( + exp_data.osfFilesLink, + osfToken, + metadataFileContents, + osfMetadataId + ); + } else { + const response = await putFileOSF( + exp_data.osfFilesLink, + osfToken, + metadataFileContents, + `dataset_description.json` + ); + + if (!response.success) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); + } + const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; return metadataResponse; } -else { - metadataMessage = MESSAGES.METADATA_NOT_ACTIVE; - const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; - return metadataResponse; - } -} catch (error) { let errorMessage: string; @@ -169,4 +123,4 @@ catch (error) { return metadataResponse; //METADATA BLOCK END }; -} \ No newline at end of file +} From 5b299d7ee08eb43ef64a65e8b3dd34a44e3c8870 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Thu, 2 Jul 2026 19:30:02 -0400 Subject: [PATCH 007/181] feat(metadata): write sidecar CSVs for nested array/object columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-vendored @jspsych/metadata expands nested object/array trial fields (survey responses, mouse-tracking samples, ...) into dotted sub-variables and exposes the per-row data behind them. Until now DataPipe only used the variable descriptions, so the described data was not actually retrievable in tabular form. This mirrors what the standalone metadata CLI writes per data file: one sidecar CSV per extracted array column (rows keyed by the join keys + element_index) and per object column (one row per trial), named with the library's own Psych-DS helpers (deriveFallbackBase/deriveArrayFilename), placed in the same subfolder as the data file. Because the CLI's sidecars are per source file, per-submission sidecars are the exact incremental equivalent — no cross-session accumulation or dedup state is needed. Flow: produceMetadata() now returns the extraction results alongside the metadata; blockMetadata() builds the sidecar payloads and returns them; api-data uploads them only after the participant's data file itself lands in OSF (no orphan sidecars on 409), best-effort — a sidecar failure is queued in the existing uploadQueue (the retry worker already handles arbitrary filenames and treats 409 as done) and logged, never failing the submission. When the main file is queued because OSF is down, the sidecars are queued alongside it. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/metadata-production.test.js | 59 +++++++++++- .../src/__tests__/metadata-sidecars.test.js | 90 +++++++++++++++++++ functions/src/api-data.ts | 22 ++++- functions/src/metadata-block.ts | 16 +++- functions/src/metadata-production.ts | 22 +++-- functions/src/metadata-sidecar-upload.ts | 67 ++++++++++++++ functions/src/metadata-sidecars.ts | 69 ++++++++++++++ 7 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 functions/src/__tests__/metadata-sidecars.test.js create mode 100644 functions/src/metadata-sidecar-upload.ts create mode 100644 functions/src/metadata-sidecars.ts diff --git a/functions/src/__tests__/metadata-production.test.js b/functions/src/__tests__/metadata-production.test.js index 1ca3c80..f58f9ce 100644 --- a/functions/src/__tests__/metadata-production.test.js +++ b/functions/src/__tests__/metadata-production.test.js @@ -55,7 +55,7 @@ describe('produceMetadata', () => { const result = await produceMetadata(sampleData); - expect(result).toEqual(sampleMetadata); + expect(result.metadata).toEqual(sampleMetadata); }); it('should generate metadata with provided options', async () => { @@ -68,6 +68,61 @@ describe('produceMetadata', () => { const optionMetadata = structuredClone(sampleMetadata); optionMetadata.randomField = "this is a field"; - expect(result).toEqual(optionMetadata); + expect(result.metadata).toEqual(optionMetadata); + }); + + it('should report no extracted columns for flat data', async () => { + const result = await produceMetadata(sampleData); + + expect(result.extractedArrays.size).toBe(0); + expect(result.extractedObjects.size).toBe(0); + expect(result.joinKeys).toEqual(['trial_index']); + }); + + it('should extract nested object and array columns with per-row data', async () => { + const nestedData = JSON.stringify([ + { + trial_type: "survey-text", + trial_index: 0, + time_elapsed: 500, + response: { Q0: "hello", Q1: "world" }, + }, + { + trial_type: "mouse-tracking", + trial_index: 1, + time_elapsed: 900, + mouse_tracking_data: [ + { x: 1, y: 2, t: 10 }, + { x: 3, y: 4, t: 20 }, + ], + }, + ]); + + const result = await produceMetadata(nestedData); + + // The nested columns are expanded into dotted sub-variables... + const variableNames = result.metadata.variableMeasured.map((v) => v.name); + expect(variableNames).toEqual(expect.arrayContaining([ + 'response.Q0', 'response.Q1', + 'mouse_tracking_data.x', 'mouse_tracking_data.y', 'mouse_tracking_data.t', + ])); + + // ...and their per-row data is available for sidecar CSVs. + expect([...result.extractedObjects.keys()]).toEqual(['response']); + expect([...result.extractedArrays.keys()]).toEqual(['mouse_tracking_data']); + + const arrayRows = result.extractedArrays.get('mouse_tracking_data'); + expect(arrayRows).toHaveLength(2); + expect(arrayRows[0]).toMatchObject({ + trial_index: 1, + element_index: 0, + 'mouse_tracking_data.x': 1, + 'mouse_tracking_data.y': 2, + 'mouse_tracking_data.t': 10, + }); + + const objectRows = result.extractedObjects.get('response'); + expect(objectRows).toHaveLength(1); + expect(objectRows[0]).toMatchObject({ trial_index: 0, 'response.Q0': 'hello', 'response.Q1': 'world' }); }); }); diff --git a/functions/src/__tests__/metadata-sidecars.test.js b/functions/src/__tests__/metadata-sidecars.test.js new file mode 100644 index 0000000..d314b05 --- /dev/null +++ b/functions/src/__tests__/metadata-sidecars.test.js @@ -0,0 +1,90 @@ +import buildSidecars from '../../lib/metadata-sidecars.js'; + +const extraction = () => ({ + extractedArrays: new Map([ + ['mouse_tracking_data', [ + { trial_index: 1, element_index: 0, x: 1, y: 2, t: 10 }, + { trial_index: 1, element_index: 1, x: 3, y: 4, t: 20 }, + ]], + ]), + extractedObjects: new Map([ + ['response', [ + { trial_index: 0, 'response.Q0': 'hello', 'response.Q1': 'world' }, + ]], + ]), + joinKeys: ['trial_index'], +}); + +describe('buildSidecars', () => { + it('returns no sidecars when nothing was extracted', () => { + const sidecars = buildSidecars('data.json', { + extractedArrays: new Map(), + extractedObjects: new Map(), + joinKeys: ['trial_index'], + }); + expect(sidecars).toEqual([]); + }); + + it('builds one Psych-DS-named CSV per extracted column', () => { + const sidecars = buildSidecars('abc123.json', extraction()); + + expect(sidecars).toHaveLength(2); + const filenames = sidecars.map((s) => s.filename); + // Names come from the library's own Psych-DS helpers; pin the convention + // (keyword-value pairs ending in _data.csv), not the exact spelling. + for (const filename of filenames) { + expect(filename).toMatch(/_data\.csv$/); + expect(filename).toContain('measure-'); + } + expect(new Set(filenames).size).toBe(2); + }); + + it('writes array rows with join keys and element_index leading', () => { + const sidecars = buildSidecars('abc123.json', extraction()); + const arraySidecar = sidecars[0]; + + const [header, ...rows] = arraySidecar.content.trim().split('\n'); + expect(header.startsWith('trial_index,element_index')).toBe(true); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain('1,0'); + expect(arraySidecar.content).toContain('10'); + }); + + it('writes object rows keyed by the join keys with dotted columns', () => { + const sidecars = buildSidecars('abc123.json', extraction()); + const objectSidecar = sidecars[1]; + + const [header, ...rows] = objectSidecar.content.trim().split('\n'); + expect(header.startsWith('trial_index')).toBe(true); + expect(header).toContain('response.Q0'); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain('hello'); + }); + + it('places sidecars in the same one-level subfolder as the data file', () => { + const sidecars = buildSidecars('session1/abc123.json', extraction()); + for (const sidecar of sidecars) { + expect(sidecar.filename.startsWith('session1/')).toBe(true); + expect(sidecar.filename.slice('session1/'.length)).not.toContain('/'); + } + }); + + it('handles filenames without an extension', () => { + const sidecars = buildSidecars('test', extraction()); + expect(sidecars).toHaveLength(2); + for (const sidecar of sidecars) { + expect(sidecar.filename).toMatch(/_data\.csv$/); + } + }); + + it('disambiguates columns that normalize to the same filename', () => { + const rows = [{ trial_index: 0, a: 1 }]; + const sidecars = buildSidecars('abc.json', { + extractedArrays: new Map([['my_column', rows], ['my column', rows]]), + extractedObjects: new Map(), + joinKeys: ['trial_index'], + }); + expect(sidecars).toHaveLength(2); + expect(new Set(sidecars.map((s) => s.filename)).size).toBe(2); + }); +}); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 27f7f5a..724db1f 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -7,10 +7,12 @@ import { db } from "./app.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; import blockMetadata from "./metadata-block.js"; +import { SidecarFile } from "./metadata-sidecars.js"; +import { uploadSidecars, queueSidecars } from "./metadata-sidecar-upload.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { ExperimentData, UserData, MetadataResponse, OSFResult, RequestBody } from './interfaces'; +import { ExperimentData, UserData, OSFResult, RequestBody } from './interfaces'; export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { const { experimentID, data, filename, metadataOptions }: RequestBody = req.body; @@ -126,23 +128,29 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //METADATA BLOCK START let metadataMessage: string = ''; + //Sidecar CSVs for nested array/object columns, produced by the metadata + //block and uploaded only after the participant's data file lands in OSF. + let sidecars: SidecarFile[] = []; if (exp_data.metadataActive) { //Creates or references a document containing the metadata for the experiment in the metdata collection on Firestore. const metadata_doc_ref: DocumentReference = db.collection("metadata").doc(experimentID); - const metadataResponse: MetadataResponse = await blockMetadata(exp_data, token, metadata_doc_ref, data, metadataOptions); + const metadataResponse = await blockMetadata(exp_data, token, metadata_doc_ref, data, filename, metadataOptions); if (metadataResponse.success === false) { await cleanupPending(pendingPath); - res.status(400).json(metadataResponse); + res.status(400).json({...metadataResponse, sidecars: undefined}); await writeLog(experimentID, "logError", {...MESSAGES.METADATA_ERROR, detail: metadataResponse.message}); return; } metadataMessage = metadataResponse.metadataMessage; + sidecars = metadataResponse.sidecars ?? []; } + const sidecarTarget = { experimentID, owner: exp_data.owner, osfFilesLink: exp_data.osfFilesLink }; + //METADATA BLOCK END let result: OSFResult; @@ -165,6 +173,8 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy + // OSF is unreachable, so queue the sidecars alongside the data file. + await queueSidecars(sidecars, sidecarTarget, `Queued alongside data file: ${detail}`); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail}); return; @@ -191,6 +201,8 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy + // OSF is failing, so queue the sidecars alongside the data file. + await queueSidecars(sidecars, sidecarTarget, `Queued alongside data file: OSF error ${result.errorCode}`); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); return; @@ -206,5 +218,9 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // Data successfully uploaded to OSF — clean up the pending copy. await cleanupPending(pendingPath); + // The data file is safely in OSF; upload its sidecar CSVs (best-effort — + // failures are queued for retry and logged, never failing the submission). + await uploadSidecars(sidecars, sidecarTarget, token); + res.status(201).json({...MESSAGES.SUCCESS, metadataMessage}); }); diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 8b7de60..3d716d1 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -7,8 +7,11 @@ import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; +import buildSidecars, { SidecarFile } from "./metadata-sidecars.js"; import { ExperimentData, Metadata, MetadataResponse } from './interfaces'; +export type MetadataBlockResult = MetadataResponse & { sidecars?: SidecarFile[] }; + // Sentinel thrown inside the transaction when the merge needs the OSF copy of // the metadata as its base. The OSF download must happen outside the // transaction (Firestore retries the transaction callback on contention, which @@ -20,8 +23,9 @@ export default async function blockMetadata( osfToken: string, metadata_doc_ref: DocumentReference, data: string, + filename: string, metadataOptions: object, - ) { + ): Promise { let metadataMessage: {metadataMessage: string} = {metadataMessage: ''}; @@ -35,7 +39,13 @@ try { } //Metadata is produced from the incoming data using the metadata module. - const incomingMetadata: Metadata = await produceMetadata(data, metadataOptions); + const produced = await produceMetadata(data, metadataOptions); + const incomingMetadata: Metadata = produced.metadata; + + //Sidecar CSVs for nested array/object columns, mirroring the CLI's per-file + //output. Built here; uploaded by the caller only after the participant's + //data file itself lands in OSF, so sidecars can never precede their data. + const sidecars: SidecarFile[] = buildSidecars(filename, produced); //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: //dataset_description.json exists in the OSF project. @@ -106,7 +116,7 @@ try { if (!response.success) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); } - const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; + const metadataResponse: MetadataBlockResult = {success: true, ...metadataMessage, sidecars}; return metadataResponse; } catch (error) { diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index a15f5eb..97f17a3 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -1,8 +1,13 @@ import jsPsychMetadata from '@jspsych/metadata'; import { Metadata } from './interfaces'; +import { ExtractionResult } from './metadata-sidecars.js'; + +export interface ProducedMetadata extends ExtractionResult { + metadata: Metadata; +} + +export default async function produceMetadata(data: string, options: object | null = null): Promise { -export default async function produceMetadata(data: string, options: object | null = null) { - // Initializes the metadata object. var metadata = new jsPsychMetadata(); // eslint-disable-line no-var @@ -26,7 +31,14 @@ export default async function produceMetadata(data: string, options: object | nu if (!incomingMetadata.variableMeasured || !incomingMetadata.variableMeasured[0].name) { throw new Error('Invalid metadata generated'); } - - return incomingMetadata; + + // Nested array/object columns that generate() expanded into dotted + // sub-variables; their per-row data is returned so callers can write + // sidecar CSVs (see metadata-sidecars.ts). + return { + metadata: incomingMetadata, + extractedArrays: metadata.getExtractedArrays(), + extractedObjects: metadata.getExtractedObjects(), + joinKeys: metadata.getArrayJoinKeys(), + }; } - \ No newline at end of file diff --git a/functions/src/metadata-sidecar-upload.ts b/functions/src/metadata-sidecar-upload.ts new file mode 100644 index 0000000..2381f6b --- /dev/null +++ b/functions/src/metadata-sidecar-upload.ts @@ -0,0 +1,67 @@ +import putFileOSF from "./put-file-osf.js"; +import queueUpload from "./queue-upload.js"; +import writeLog from "./write-log.js"; +import MESSAGES from "./api-messages.js"; +import { SidecarFile } from "./metadata-sidecars.js"; + +export interface SidecarUploadTarget { + experimentID: string; + owner: string; + osfFilesLink: string; +} + +/** + * Uploads sidecar CSVs to OSF, best-effort: the participant's data file is + * already safely in OSF by the time this runs, and sidecars are derivable + * from it, so a sidecar failure is queued for retry (the same uploadQueue + * the data files use) and logged — it never fails the submission. + * A 409 means an earlier attempt already landed the file; nothing to do. + */ +export async function uploadSidecars( + sidecars: SidecarFile[], + target: SidecarUploadTarget, + osfToken: string, +): Promise { + for (const sidecar of sidecars) { + try { + const result = await putFileOSF(target.osfFilesLink, osfToken, sidecar.content, sidecar.filename); + if (result.success || result.errorCode === 409) continue; + await queueSidecars([sidecar], target, `Sidecar OSF error ${result.errorCode}: ${result.errorText}`); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + await queueSidecars([sidecar], target, `Sidecar upload exception: ${detail}`); + } + } +} + +/** + * Queues sidecar CSVs for retried upload without attempting one first — used + * when the main data file itself just failed to reach OSF (it was queued, so + * OSF is known to be unavailable). sessionIncremented is true because only + * the main data file accounts for the session count. + */ +export async function queueSidecars( + sidecars: SidecarFile[], + target: SidecarUploadTarget, + failureReason: string, +): Promise { + for (const sidecar of sidecars) { + try { + await queueUpload({ + experimentID: target.experimentID, + owner: target.owner, + filename: sidecar.filename, + data: sidecar.content, + dataType: "data", + osfFilesLink: target.osfFilesLink, + errorCode: 0, + sessionIncremented: true, + failureReason, + }); + await writeLog(target.experimentID, "logError", {...MESSAGES.OSF_UPLOAD_QUEUED, detail: `sidecar ${sidecar.filename}: ${failureReason}`}); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + await writeLog(target.experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, detail: `sidecar ${sidecar.filename} could not be queued: ${detail}`}); + } + } +} diff --git a/functions/src/metadata-sidecars.ts b/functions/src/metadata-sidecars.ts new file mode 100644 index 0000000..867095d --- /dev/null +++ b/functions/src/metadata-sidecars.ts @@ -0,0 +1,69 @@ +import { deriveFallbackBase, deriveArrayFilename, disambiguateArrayFilename, objectsToCSV } from '@jspsych/metadata'; + +export interface SidecarFile { + filename: string; + content: string; +} + +export interface ExtractionResult { + extractedArrays: Map>>; + extractedObjects: Map>>; + joinKeys: string[]; +} + +/** + * Builds the sidecar CSV files for one submission's extracted nested-data + * columns, mirroring what the @jspsych/metadata CLI writes per data file: + * one CSV per array-of-objects column (rows keyed by the join keys plus + * element_index) and one per plain-object column (one row per trial, keyed + * by the join keys only). Naming reuses the library's own Psych-DS helpers + * (deriveFallbackBase + deriveArrayFilename) so DataPipe's sidecar names + * match the CLI's for the same data. + * + * Sidecars are placed in the same one-level subfolder as the data file, + * matching how putFileOSF resolves "folder/name" filenames. + */ +export default function buildSidecars( + dataFilename: string, + extraction: ExtractionResult, +): SidecarFile[] { + const { extractedArrays, extractedObjects, joinKeys } = extraction; + + if (extractedArrays.size === 0 && extractedObjects.size === 0) return []; + + const slashIndex = dataFilename.indexOf('/'); + const folder = slashIndex === -1 ? '' : dataFilename.slice(0, slashIndex + 1); + const name = slashIndex === -1 ? dataFilename : dataFilename.slice(slashIndex + 1); + + const dotIndex = name.lastIndexOf('.'); + const stem = dotIndex <= 0 ? name : name.slice(0, dotIndex); + + const base = deriveFallbackBase(stem); + + //Distinct columns can normalize to the same Psych-DS name; the library's + //disambiguation appends a counter, exactly as the CLI does. + const usedFilenames = new Set(); + const reserve = (filename: string): string => { + const resolved = disambiguateArrayFilename(filename, usedFilenames); + usedFilenames.add(resolved); + return resolved; + }; + + const sidecars: SidecarFile[] = []; + + for (const [column, rows] of extractedArrays) { + sidecars.push({ + filename: folder + reserve(deriveArrayFilename(base, column)), + content: objectsToCSV(rows, [...joinKeys, 'element_index']), + }); + } + + for (const [column, rows] of extractedObjects) { + sidecars.push({ + filename: folder + reserve(deriveArrayFilename(base, column)), + content: objectsToCSV(rows, joinKeys), + }); + } + + return sidecars; +} From 9208d43a5187b77bfe5f310d98ae0e16dcebfa9a Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Fri, 3 Jul 2026 15:10:20 -0400 Subject: [PATCH 008/181] refactor(metadata): delegate sidecar CSVs to buildPsychDSDataFiles + surface mainRows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled sidecar builder in metadata-sidecars.ts with a call to the library's shared buildPsychDSDataFiles (the same function the @jspsych/metadata CLI and browser flows use), filtering to the sidecar files (kind 'array'/'object') and keeping today's placement next to the data file. This deletes the duplicated deriveArrayFilename/disambiguate/objectsToCSV logic; sidecar output is byte-identical (verified: arrays lead with [...joinKeys, 'element_index'], objects with joinKeys). produceMetadata now also returns mainRows (the parsed JSON trial array, or parseCSV records for CSV) so a later change can emit the main Psych-DS data CSV via the same builder. generate() leaves nested columns intact in these rows; the main CSV serialises them as JSON-in-cell (lossless), with the dotted expansion in the sidecars. The main CSV (kind 'main') is discarded for now — it is wired up when api-data.ts adopts the data/ + data/raw/ layout. tsc clean; 25/25 metadata unit tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/metadata-production.test.js | 22 ++++++++ .../src/__tests__/metadata-sidecars.test.js | 21 +++++--- functions/src/metadata-block.ts | 2 +- functions/src/metadata-production.ts | 19 ++++++- functions/src/metadata-sidecars.ts | 50 ++++++++----------- 5 files changed, 75 insertions(+), 39 deletions(-) diff --git a/functions/src/__tests__/metadata-production.test.js b/functions/src/__tests__/metadata-production.test.js index f58f9ce..51053ee 100644 --- a/functions/src/__tests__/metadata-production.test.js +++ b/functions/src/__tests__/metadata-production.test.js @@ -79,6 +79,28 @@ describe('produceMetadata', () => { expect(result.joinKeys).toEqual(['trial_index']); }); + it('should surface the parsed main rows for JSON data', async () => { + const result = await produceMetadata(sampleData); + + expect(Array.isArray(result.mainRows)).toBe(true); + expect(result.mainRows).toHaveLength(1); + expect(result.mainRows[0]).toMatchObject({ + trial_type: 'html-keyboard-response', + trial_index: 1, + time_elapsed: 776, + }); + }); + + it('should parse CSV submissions into main rows', async () => { + const csv = 'trial_index,rt\n0,250\n1,300'; + const result = await produceMetadata(csv); + + expect(result.mainRows).toHaveLength(2); + // parseCSV treats every cell as a string (columns:true, no coercion). + expect(result.mainRows[0]).toMatchObject({ trial_index: '0', rt: '250' }); + expect(result.mainRows[1]).toMatchObject({ trial_index: '1', rt: '300' }); + }); + it('should extract nested object and array columns with per-row data', async () => { const nestedData = JSON.stringify([ { diff --git a/functions/src/__tests__/metadata-sidecars.test.js b/functions/src/__tests__/metadata-sidecars.test.js index d314b05..3dd951d 100644 --- a/functions/src/__tests__/metadata-sidecars.test.js +++ b/functions/src/__tests__/metadata-sidecars.test.js @@ -15,18 +15,25 @@ const extraction = () => ({ joinKeys: ['trial_index'], }); +// The main table rows produceMetadata surfaces; buildSidecars only serialises +// nested columns, so the exact main rows don't affect the sidecar assertions. +const mainRows = () => ([ + { trial_index: 0, trial_type: 'survey-text' }, + { trial_index: 1, trial_type: 'mouse-tracking' }, +]); + describe('buildSidecars', () => { it('returns no sidecars when nothing was extracted', () => { const sidecars = buildSidecars('data.json', { extractedArrays: new Map(), extractedObjects: new Map(), joinKeys: ['trial_index'], - }); + }, mainRows()); expect(sidecars).toEqual([]); }); it('builds one Psych-DS-named CSV per extracted column', () => { - const sidecars = buildSidecars('abc123.json', extraction()); + const sidecars = buildSidecars('abc123.json', extraction(), mainRows()); expect(sidecars).toHaveLength(2); const filenames = sidecars.map((s) => s.filename); @@ -40,7 +47,7 @@ describe('buildSidecars', () => { }); it('writes array rows with join keys and element_index leading', () => { - const sidecars = buildSidecars('abc123.json', extraction()); + const sidecars = buildSidecars('abc123.json', extraction(), mainRows()); const arraySidecar = sidecars[0]; const [header, ...rows] = arraySidecar.content.trim().split('\n'); @@ -51,7 +58,7 @@ describe('buildSidecars', () => { }); it('writes object rows keyed by the join keys with dotted columns', () => { - const sidecars = buildSidecars('abc123.json', extraction()); + const sidecars = buildSidecars('abc123.json', extraction(), mainRows()); const objectSidecar = sidecars[1]; const [header, ...rows] = objectSidecar.content.trim().split('\n'); @@ -62,7 +69,7 @@ describe('buildSidecars', () => { }); it('places sidecars in the same one-level subfolder as the data file', () => { - const sidecars = buildSidecars('session1/abc123.json', extraction()); + const sidecars = buildSidecars('session1/abc123.json', extraction(), mainRows()); for (const sidecar of sidecars) { expect(sidecar.filename.startsWith('session1/')).toBe(true); expect(sidecar.filename.slice('session1/'.length)).not.toContain('/'); @@ -70,7 +77,7 @@ describe('buildSidecars', () => { }); it('handles filenames without an extension', () => { - const sidecars = buildSidecars('test', extraction()); + const sidecars = buildSidecars('test', extraction(), mainRows()); expect(sidecars).toHaveLength(2); for (const sidecar of sidecars) { expect(sidecar.filename).toMatch(/_data\.csv$/); @@ -83,7 +90,7 @@ describe('buildSidecars', () => { extractedArrays: new Map([['my_column', rows], ['my column', rows]]), extractedObjects: new Map(), joinKeys: ['trial_index'], - }); + }, mainRows()); expect(sidecars).toHaveLength(2); expect(new Set(sidecars.map((s) => s.filename)).size).toBe(2); }); diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 3d716d1..5a1be52 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -45,7 +45,7 @@ try { //Sidecar CSVs for nested array/object columns, mirroring the CLI's per-file //output. Built here; uploaded by the caller only after the participant's //data file itself lands in OSF, so sidecars can never precede their data. - const sidecars: SidecarFile[] = buildSidecars(filename, produced); + const sidecars: SidecarFile[] = buildSidecars(filename, produced, produced.mainRows); //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: //dataset_description.json exists in the OSF project. diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index 97f17a3..06f10af 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -1,9 +1,17 @@ -import jsPsychMetadata from '@jspsych/metadata'; +import jsPsychMetadata, { parseCSV } from '@jspsych/metadata'; import { Metadata } from './interfaces'; import { ExtractionResult } from './metadata-sidecars.js'; export interface ProducedMetadata extends ExtractionResult { metadata: Metadata; + // Parsed rows of the main data table, used as buildPsychDSDataFiles' mainRows + // (and, for CSV, for unnamed-column detection). generate() does NOT flatten + // nested columns here: the rows keep their nested object/array values, which + // objectsToCSV then serialises as JSON strings in the main CSV (lossless) — + // the dotted expansion (response.Q0, mouse_tracking_data.x) lives only in + // variableMeasured and the sidecars. For JSON this is the parsed trial array; + // for CSV it is parsed from the original text via the library's parseCSV. + mainRows: Array>; } export default async function produceMetadata(data: string, options: object | null = null): Promise { @@ -32,6 +40,14 @@ export default async function produceMetadata(data: string, options: object | nu throw new Error('Invalid metadata generated'); } + // Main data rows for the Psych-DS main CSV. For JSON, `data` is the parsed + // array (nested columns left intact — see mainRows doc above); for CSV, + // parse the original text (the string `data` is untouched — generate() + // parses its own copy internally). + const mainRows: Array> = csvFlag + ? (await parseCSV(data)) as Array> + : (data as unknown as Array>); + // Nested array/object columns that generate() expanded into dotted // sub-variables; their per-row data is returned so callers can write // sidecar CSVs (see metadata-sidecars.ts). @@ -40,5 +56,6 @@ export default async function produceMetadata(data: string, options: object | nu extractedArrays: metadata.getExtractedArrays(), extractedObjects: metadata.getExtractedObjects(), joinKeys: metadata.getArrayJoinKeys(), + mainRows, }; } diff --git a/functions/src/metadata-sidecars.ts b/functions/src/metadata-sidecars.ts index 867095d..8c2c440 100644 --- a/functions/src/metadata-sidecars.ts +++ b/functions/src/metadata-sidecars.ts @@ -1,4 +1,4 @@ -import { deriveFallbackBase, deriveArrayFilename, disambiguateArrayFilename, objectsToCSV } from '@jspsych/metadata'; +import { deriveFallbackBase, buildPsychDSDataFiles } from '@jspsych/metadata'; export interface SidecarFile { filename: string; @@ -16,9 +16,13 @@ export interface ExtractionResult { * columns, mirroring what the @jspsych/metadata CLI writes per data file: * one CSV per array-of-objects column (rows keyed by the join keys plus * element_index) and one per plain-object column (one row per trial, keyed - * by the join keys only). Naming reuses the library's own Psych-DS helpers - * (deriveFallbackBase + deriveArrayFilename) so DataPipe's sidecar names - * match the CLI's for the same data. + * by the join keys only). + * + * The naming and CSV serialisation are delegated to the library's shared + * buildPsychDSDataFiles (the same function the CLI and browser flows use), so + * DataPipe's sidecar output stays byte-identical to theirs for the same data. + * We keep only the sidecar files here (kind 'array'/'object'); the main data + * CSV (kind 'main') is wired up when api-data adopts the Psych-DS data/ layout. * * Sidecars are placed in the same one-level subfolder as the data file, * matching how putFileOSF resolves "folder/name" filenames. @@ -26,6 +30,7 @@ export interface ExtractionResult { export default function buildSidecars( dataFilename: string, extraction: ExtractionResult, + mainRows: Array>, ): SidecarFile[] { const { extractedArrays, extractedObjects, joinKeys } = extraction; @@ -40,30 +45,15 @@ export default function buildSidecars( const base = deriveFallbackBase(stem); - //Distinct columns can normalize to the same Psych-DS name; the library's - //disambiguation appends a counter, exactly as the CLI does. - const usedFilenames = new Set(); - const reserve = (filename: string): string => { - const resolved = disambiguateArrayFilename(filename, usedFilenames); - usedFilenames.add(resolved); - return resolved; - }; - - const sidecars: SidecarFile[] = []; - - for (const [column, rows] of extractedArrays) { - sidecars.push({ - filename: folder + reserve(deriveArrayFilename(base, column)), - content: objectsToCSV(rows, [...joinKeys, 'element_index']), - }); - } - - for (const [column, rows] of extractedObjects) { - sidecars.push({ - filename: folder + reserve(deriveArrayFilename(base, column)), - content: objectsToCSV(rows, joinKeys), - }); - } - - return sidecars; + const files = buildPsychDSDataFiles({ + base, + mainRows, + extractedArrays, + extractedObjects, + joinKeys, + }); + + return files + .filter((file) => file.kind !== 'main') + .map((file) => ({ filename: folder + file.filename, content: file.content })); } From 0dbe4bc7824b0ddb1c4b45ccd5318ca736feff6f Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Fri, 3 Jul 2026 15:52:36 -0400 Subject: [PATCH 009/181] feat(osf): walk arbitrary-depth paths in putFileOSF (data/raw/) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit putFileOSF previously handled only a single subfolder level (split on '/' and used components[0]/components[1]), so a nested path like "data/raw/abc.json" would break. Generalise it to split the filename into folder segments plus the file name and walk each level in turn. subfolder.ts's parsePath is replaced by resolveFolder(parentUrl, token, name): it lists the children of any level — the storage root or a parent folder's WaterButler link — and returns the named child folder's link, creating it if absent. The link chains, so callers walk nested paths one level at a time. Folder creation treats a 409 Conflict as success: concurrent submissions can race to create the same folder (now every metadata submission needs data/ and data/raw/), and the loser re-lists and returns the winner's folder. putFileOSF also switches from node-fetch to the Node 22 global fetch, matching subfolder.ts and making the whole path unit-testable via a global.fetch mock. New put-file-osf.test.js covers depth 0/1/2, folder creation, the 409 race, and upload failures (10 tests). tsc clean. NOTE: the WaterButler move-link recursion (listing/creating inside a subfolder via its move link) and the node-fetch->global fetch body handling still need live verification against a real OSF component + token. Co-Authored-By: Claude Opus 4.8 --- functions/src/__tests__/put-file-osf.test.js | 164 +++++++++++++++++++ functions/src/put-file-osf.ts | 51 +++--- functions/src/subfolder.ts | 132 ++++++++------- 3 files changed, 261 insertions(+), 86 deletions(-) create mode 100644 functions/src/__tests__/put-file-osf.test.js diff --git a/functions/src/__tests__/put-file-osf.test.js b/functions/src/__tests__/put-file-osf.test.js new file mode 100644 index 0000000..c21c5ef --- /dev/null +++ b/functions/src/__tests__/put-file-osf.test.js @@ -0,0 +1,164 @@ +import putFileOSF from '../../lib/put-file-osf.js'; + +const ROOT = 'https://files.osf.io/v1/resources/abc/providers/osfstorage/'; +const TOKEN = 'test-token'; + +// The WaterButler link a folder resolves to; used both as the returned link and +// as the parent URL for the next level down. +const move = (name) => `https://files.osf.io/v1/folder/${name}/`; + +// GET ?meta= listing containing the named child folders. +const listing = (folderNames) => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + data: folderNames.map((n) => ({ attributes: { name: n, kind: 'folder' }, links: { move: move(n) } })), + }), +}); +// Successful folder-create PUT. +const folderCreated = (name) => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: { links: { move: move(name) } } }), +}); +// Folder-create PUT that conflicts (another submission made it first). +const conflict = () => Promise.resolve({ ok: false, status: 409, statusText: 'Conflict' }); +// File-upload PUT outcomes (put-file-osf checks status === 201, not ok). +const fileOk = () => Promise.resolve({ status: 201 }); +const fileFail = (status, statusText, retryAfter = null) => Promise.resolve({ + status, + statusText, + headers: { get: (h) => (h === 'Retry-After' ? retryAfter : null) }, +}); + +const callUrls = () => fetch.mock.calls.map((c) => c[0]); + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +describe('putFileOSF', () => { + it('uploads a bare filename straight to the storage root', async () => { + fetch.mockReturnValueOnce(fileOk()); + + const result = await putFileOSF(ROOT, TOKEN, 'hello', 'subject01.csv'); + + expect(result).toEqual({ success: true, errorCode: null, errorText: null }); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, opts] = fetch.mock.calls[0]; + expect(url).toBe(`${ROOT}?kind=file&name=subject01.csv`); + expect(opts.method).toBe('PUT'); + expect(opts.body).toBe('hello'); + }); + + it('uploads into an existing one-level subfolder', async () => { + fetch + .mockReturnValueOnce(listing(['session1', 'other'])) + .mockReturnValueOnce(fileOk()); + + const result = await putFileOSF(ROOT, TOKEN, 'data', 'session1/abc.json'); + + expect(result.success).toBe(true); + expect(callUrls()).toEqual([ + `${ROOT}?meta=`, + `${move('session1')}?kind=file&name=abc.json`, + ]); + }); + + it('creates a missing one-level subfolder before uploading', async () => { + fetch + .mockReturnValueOnce(listing([])) + .mockReturnValueOnce(folderCreated('session1')) + .mockReturnValueOnce(fileOk()); + + await putFileOSF(ROOT, TOKEN, 'data', 'session1/abc.json'); + + expect(callUrls()).toEqual([ + `${ROOT}?meta=`, + `${ROOT}?kind=folder&name=session1`, + `${move('session1')}?kind=file&name=abc.json`, + ]); + expect(fetch.mock.calls[1][1].method).toBe('PUT'); + }); + + it('walks and creates a two-level path (data/raw/)', async () => { + fetch + .mockReturnValueOnce(listing([])) // root has no data/ + .mockReturnValueOnce(folderCreated('data')) + .mockReturnValueOnce(listing([])) // data/ has no raw/ + .mockReturnValueOnce(folderCreated('raw')) + .mockReturnValueOnce(fileOk()); + + await putFileOSF(ROOT, TOKEN, '{}', 'data/raw/abc123.json'); + + expect(callUrls()).toEqual([ + `${ROOT}?meta=`, + `${ROOT}?kind=folder&name=data`, + `${move('data')}?meta=`, + `${move('data')}?kind=folder&name=raw`, + `${move('raw')}?kind=file&name=abc123.json`, + ]); + }); + + it('walks an existing two-level path without creating folders', async () => { + fetch + .mockReturnValueOnce(listing(['data'])) + .mockReturnValueOnce(listing(['raw'])) + .mockReturnValueOnce(fileOk()); + + await putFileOSF(ROOT, TOKEN, '{}', 'data/raw/abc123.json'); + + expect(callUrls()).toEqual([ + `${ROOT}?meta=`, + `${move('data')}?meta=`, + `${move('raw')}?kind=file&name=abc123.json`, + ]); + }); + + it('treats a 409 on folder creation as already-exists and re-resolves', async () => { + fetch + .mockReturnValueOnce(listing([])) // not found on first list + .mockReturnValueOnce(conflict()) // create loses the race + .mockReturnValueOnce(listing(['session1'])) // re-list finds the winner's folder + .mockReturnValueOnce(fileOk()); + + const result = await putFileOSF(ROOT, TOKEN, 'data', 'session1/abc.json'); + + expect(result.success).toBe(true); + expect(callUrls()).toEqual([ + `${ROOT}?meta=`, + `${ROOT}?kind=folder&name=session1`, + `${ROOT}?meta=`, + `${move('session1')}?kind=file&name=abc.json`, + ]); + }); + + it('returns the OSF error when the file upload fails', async () => { + fetch.mockReturnValueOnce(fileFail(409, 'Conflict')); + + const result = await putFileOSF(ROOT, TOKEN, 'data', 'dup.json'); + + expect(result).toEqual({ success: false, errorCode: 409, errorText: 'Conflict', retryAfter: null }); + }); + + it('parses Retry-After on a throttled upload', async () => { + fetch.mockReturnValueOnce(fileFail(503, 'Service Unavailable', '30')); + + const result = await putFileOSF(ROOT, TOKEN, 'data', 'x.json'); + + expect(result).toEqual({ success: false, errorCode: 503, errorText: 'Service Unavailable', retryAfter: 30 }); + }); + + it('throws when a folder listing request fails', async () => { + fetch.mockReturnValueOnce(Promise.resolve({ ok: false, status: 500, statusText: 'Server Error' })); + + await expect(putFileOSF(ROOT, TOKEN, 'd', 'session1/a.json')).rejects.toThrow(/Failed to list files/); + }); + + it('throws when a 409 folder conflict cannot be re-resolved', async () => { + fetch + .mockReturnValueOnce(listing([])) + .mockReturnValueOnce(conflict()) + .mockReturnValueOnce(listing([])); // still not there on re-list + + await expect(putFileOSF(ROOT, TOKEN, 'd', 'session1/a.json')).rejects.toThrow(/conflicted on creation/); + }); +}); diff --git a/functions/src/put-file-osf.ts b/functions/src/put-file-osf.ts index df87782..ea4aeac 100644 --- a/functions/src/put-file-osf.ts +++ b/functions/src/put-file-osf.ts @@ -1,5 +1,4 @@ -import fetch from "node-fetch"; -import parsePath from "./subfolder.js"; +import resolveFolder from "./subfolder.js"; export default async function putFileOSF( osfComponent: string, @@ -8,41 +7,33 @@ export default async function putFileOSF( filename: string ) { - //if a filepath is detected in the filename, we need to create the subfolder or find the subfolder. - let path; - - if (filename.includes('/')) { - // Split filename argument into subfolder name and datafile name. - const components = filename.split('/'); - - // Waterbutler API requires folders to be referenced with trailing slashes. - - const queryParams = new URLSearchParams({ - kind: "file", - name: components[1], - }); - - path = `${(await parsePath(osfComponent, osfToken, components[0]))}?${queryParams.toString()}`; - - } - else { - // If no subfolder is detected, we just upload the file to the default storage component root. - - const queryParams = new URLSearchParams({ - kind: "file", - name: filename, - }); - - path = `${osfComponent}?${queryParams.toString()}`; + // A filename may carry a path prefix (e.g. "data/raw/abc123.json"). Split it + // into folder segments and the file name; each folder level is found-or-created + // in turn (WaterButler has no atomic deep-path create), walking down to the + // folder that will hold the file. A bare "abc123.json" has no segments and + // uploads straight to the storage root. + const segments = filename.split('/'); + const fileName = segments.pop() as string; + + let targetUrl = osfComponent; + for (const folder of segments) { + targetUrl = await resolveFolder(targetUrl, osfToken, folder); } - const osfResult = await fetch(`${path}`, { + const queryParams = new URLSearchParams({ + kind: "file", + name: fileName, + }); + + const osfResult = await fetch(`${targetUrl}?${queryParams.toString()}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${osfToken}`, }, - body: filedata, + // Buffer is a valid fetch body at runtime; the cast sidesteps a @types/node + // generic-Buffer vs BodyInit mismatch. + body: filedata as BodyInit, }); if (osfResult.status !== 201) { diff --git a/functions/src/subfolder.ts b/functions/src/subfolder.ts index 5da718a..b063561 100644 --- a/functions/src/subfolder.ts +++ b/functions/src/subfolder.ts @@ -1,63 +1,83 @@ import { OSFFile } from './interfaces'; -export default async function parsePath(osfComponent: string, osfToken: string, subName: string) { - //Gets the metadata of the data storage element in the OSF project. - const osfResult = await fetch(`${osfComponent}?meta=`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - } - }); - - if (!osfResult.ok) { - throw new Error(`Failed to list files in OSF component (status ${osfResult.status}: ${osfResult.statusText})`); +/** + * Resolves a single folder level inside an OSF osfstorage location and returns + * that folder's WaterButler link. `parentUrl` is either the storage root URL + * (exp_data.osfFilesLink) or a parent folder's link; the child folder named + * `name` is listed and returned, or created if it does not exist. + * + * The returned link is itself a valid `parentUrl`, so calls chain to walk a + * nested path one level at a time (WaterButler has no atomic deep-path create): + * const dataUrl = await resolveFolder(root, token, 'data'); + * const rawUrl = await resolveFolder(dataUrl, token, 'raw'); + * + * Folder creation treats a 409 Conflict as success: under concurrent + * submissions two requests can both find the folder missing and race to create + * it, so the loser re-lists and returns the folder the winner made. + */ +export default async function resolveFolder( + parentUrl: string, + osfToken: string, + name: string, +): Promise { + const existing = await findChildFolder(parentUrl, osfToken, name); + if (existing) return existing; + + const created = await fetch(`${parentUrl}?${new URLSearchParams({ kind: 'folder', name }).toString()}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${osfToken}`, + }, + }); + + if (created.ok) { + const body = await created.json(); + const move = body?.data?.links?.move; + if (!move) { + throw new Error(`OSF subfolder creation response missing expected 'data.links.move' path`); } + return move; + } - const folder = await osfResult.json(); //Gets the json portion of the response - // The JSON portion has a property called 'data' that contains an array of objects, each of which - // corresponds to a data file in the OSF project. We access this array. + // Another concurrent submission created the folder between our list and PUT. + if (created.status === 409) { + const raced = await findChildFolder(parentUrl, osfToken, name); + if (raced) return raced; + throw new Error(`OSF folder '${name}' conflicted on creation but was not found on re-list`); + } - const listOfFiles = folder['data']; - - if (!Array.isArray(listOfFiles)) { - throw new Error("OSF component response did not contain a 'data' array"); - } - - // Every file object has an 'attributes' property which contains an object of information about the file, - // including a name property. We use this to find the object of the subfolder if it exists. - - const metadataFile: OSFFile[] = listOfFiles.filter((file) => (file.attributes.name === subName)); - - // Create a subfolder at the specified path if it does not exist, and return the upload link. - if (metadataFile.length === 0) { - - const queryParams = new URLSearchParams({ - kind: "folder", - name: subName, - }); - - const subResponse = await fetch(`${osfComponent}?${queryParams.toString()}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - } - }); - - if (!subResponse.ok) { - throw new Error(`Failed to create subfolder '${subName}' in OSF (status ${subResponse.status}: ${subResponse.statusText})`); - } - - const subData = await subResponse.json(); - - if (!subData?.data?.links?.move) { - throw new Error(`OSF subfolder creation response missing expected 'data.links.move' path`); - } - - return subData.data.links.move; - } - - return metadataFile[0].links.move; + throw new Error(`Failed to create subfolder '${name}' in OSF (status ${created.status}: ${created.statusText})`); +} +/** + * Lists the children of `parentUrl` and returns the `move` link of the child + * folder named `name`, or null when no such folder exists. Matches on both name + * and kind so a file sharing a folder's name is never mistaken for it. + */ +async function findChildFolder(parentUrl: string, osfToken: string, name: string): Promise { + const osfResult = await fetch(`${parentUrl}?meta=`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${osfToken}`, + }, + }); + + if (!osfResult.ok) { + throw new Error(`Failed to list files in OSF folder (status ${osfResult.status}: ${osfResult.statusText})`); + } + + const folder = await osfResult.json(); + const listOfFiles = folder['data']; + + if (!Array.isArray(listOfFiles)) { + throw new Error("OSF component response did not contain a 'data' array"); + } + + const matches: OSFFile[] = listOfFiles.filter( + (file) => file.attributes.name === name && file.attributes.kind === 'folder', + ); + + return matches.length === 0 ? null : matches[0].links.move; } From 92dd9eb675bd5972c10bef1c6c1178faea4cb054 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 09:50:01 -0400 Subject: [PATCH 010/181] feat(metadata): ship the Psych-DS layout to OSF (raw under data/raw/, CSVs under data/) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With metadata on, the participant's raw submission is now the critical upload and lands byte-verbatim at data/raw/; session counting and queue-on-failure key off it. The full derived set — main data CSV (byte-verbatim for CSV submissions via mainContent), sidecar CSVs for nested columns, and .psychds-ignore at the component root — is built by the library's shared buildPsychDSDataFiles and uploaded best-effort under data/ after the raw file lands. Researcher subfolders are flattened, matching the CLI. Metadata off is unchanged. Also: - dataset_description.json's OSF mirror is now best-effort: create failures queue for retry (409 = concurrent create, done), update failures self-heal on the next submission — they no longer reject the participant's data. - produceMetadata routes JSON through the library's parseJsonData, so a nonstandard { "trials": [...] } wrapper is unwrapped exactly as the CLI does; bare arrays are untouched. - metadata-sidecars.ts is deleted: metadata-derived-files.ts consumes the full buildPsychDSDataFiles set directly, and the upload module is renamed metadata-derived-upload.ts to match. Co-Authored-By: Claude Fable 5 --- .../__tests__/metadata-derived-files.test.js | 132 ++++++++++++++++++ .../src/__tests__/metadata-production.test.js | 17 +++ .../src/__tests__/metadata-sidecars.test.js | 97 ------------- functions/src/api-data.ts | 44 +++--- functions/src/metadata-block.ts | 75 ++++++---- functions/src/metadata-derived-files.ts | 85 +++++++++++ functions/src/metadata-derived-upload.ts | 68 +++++++++ functions/src/metadata-production.ts | 21 ++- functions/src/metadata-sidecar-upload.ts | 67 --------- functions/src/metadata-sidecars.ts | 59 -------- 10 files changed, 394 insertions(+), 271 deletions(-) create mode 100644 functions/src/__tests__/metadata-derived-files.test.js delete mode 100644 functions/src/__tests__/metadata-sidecars.test.js create mode 100644 functions/src/metadata-derived-files.ts create mode 100644 functions/src/metadata-derived-upload.ts delete mode 100644 functions/src/metadata-sidecar-upload.ts delete mode 100644 functions/src/metadata-sidecars.ts diff --git a/functions/src/__tests__/metadata-derived-files.test.js b/functions/src/__tests__/metadata-derived-files.test.js new file mode 100644 index 0000000..9837beb --- /dev/null +++ b/functions/src/__tests__/metadata-derived-files.test.js @@ -0,0 +1,132 @@ +import buildDerivedFiles, { rawDataPath } from '../../lib/metadata-derived-files.js'; + +const extraction = () => ({ + extractedArrays: new Map([ + ['mouse_tracking_data', [ + { trial_index: 1, element_index: 0, x: 1, y: 2, t: 10 }, + { trial_index: 1, element_index: 1, x: 3, y: 4, t: 20 }, + ]], + ]), + extractedObjects: new Map([ + ['response', [ + { trial_index: 0, 'response.Q0': 'hello', 'response.Q1': 'world' }, + ]], + ]), + joinKeys: ['trial_index'], +}); + +const noExtraction = () => ({ + extractedArrays: new Map(), + extractedObjects: new Map(), + joinKeys: ['trial_index'], +}); + +const mainRows = () => ([ + { trial_index: 0, trial_type: 'survey-text' }, + { trial_index: 1, trial_type: 'mouse-tracking' }, +]); + +const source = (overrides = {}) => ({ ...extraction(), mainRows: mainRows(), ...overrides }); + +describe('rawDataPath', () => { + it('places the original file under data/raw/', () => { + expect(rawDataPath('abc123.json')).toBe('data/raw/abc123.json'); + }); + + it('flattens researcher subfolders', () => { + expect(rawDataPath('condition-A/abc123.json')).toBe('data/raw/abc123.json'); + expect(rawDataPath('a/b/abc123.json')).toBe('data/raw/abc123.json'); + }); +}); + +describe('buildDerivedFiles', () => { + it('always emits the main data CSV and .psychds-ignore, even with nothing extracted', () => { + const files = buildDerivedFiles('abc123.json', source(noExtraction())); + + expect(files.map((f) => f.filename)).toEqual([ + 'data/subject-abc123_data.csv', + '.psychds-ignore', + ]); + const main = files[0]; + expect(main.content).toContain('trial_index'); + expect(main.content).toContain('survey-text'); + }); + + it('excludes raw/ and itself from validation via .psychds-ignore', () => { + const files = buildDerivedFiles('abc123.json', source(noExtraction())); + const ignore = files.find((f) => f.filename === '.psychds-ignore'); + expect(ignore.content).toContain('**/raw/'); + expect(ignore.content).toContain('.psychds-ignore'); + }); + + it('builds one Psych-DS-named sidecar CSV per extracted column, under data/', () => { + const files = buildDerivedFiles('abc123.json', source()); + + // main + 2 sidecars + ignore + expect(files).toHaveLength(4); + const sidecars = files.filter((f) => f.filename.includes('measure-')); + expect(sidecars).toHaveLength(2); + // Names come from the library's own Psych-DS helpers; pin the convention + // (data/ placement, keyword-value pairs ending in _data.csv), not the spelling. + for (const sidecar of sidecars) { + expect(sidecar.filename).toMatch(/^data\/[^/]+_data\.csv$/); + } + expect(new Set(files.map((f) => f.filename)).size).toBe(4); + }); + + it('writes array rows with join keys and element_index leading', () => { + const files = buildDerivedFiles('abc123.json', source()); + const arraySidecar = files.find((f) => f.filename.includes('mouseTracking')); + + const [header, ...rows] = arraySidecar.content.trim().split('\n'); + expect(header.startsWith('trial_index,element_index')).toBe(true); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain('1,0'); + expect(arraySidecar.content).toContain('10'); + }); + + it('writes object rows keyed by the join keys with dotted columns', () => { + const files = buildDerivedFiles('abc123.json', source()); + const objectSidecar = files.find((f) => f.filename.includes('response')); + + const [header, ...rows] = objectSidecar.content.trim().split('\n'); + expect(header.startsWith('trial_index')).toBe(true); + expect(header).toContain('response.Q0'); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain('hello'); + }); + + it('flattens researcher subfolders into the same flat data/ layout', () => { + const flat = buildDerivedFiles('abc123.json', source()); + const nested = buildDerivedFiles('session1/abc123.json', source()); + + expect(nested.map((f) => f.filename)).toEqual(flat.map((f) => f.filename)); + for (const file of nested) { + expect(file.filename).not.toContain('session1'); + } + }); + + it('keeps a CSV submission byte-verbatim via mainContent', () => { + const csv = 'rt,trial_index\n250,0\n300,1\n'; + const files = buildDerivedFiles('abc.csv', source({ ...noExtraction(), mainContent: csv })); + + const main = files.find((f) => f.filename.endsWith('subject-abc_data.csv')); + expect(main.content).toBe(csv); + }); + + it('handles filenames without an extension', () => { + const files = buildDerivedFiles('test', source()); + expect(files.map((f) => f.filename)).toContain('data/subject-test_data.csv'); + }); + + it('disambiguates columns that normalize to the same filename', () => { + const rows = [{ trial_index: 0, a: 1 }]; + const files = buildDerivedFiles('abc.json', source({ + extractedArrays: new Map([['my_column', rows], ['my column', rows]]), + extractedObjects: new Map(), + })); + const sidecars = files.filter((f) => f.filename.includes('measure-')); + expect(sidecars).toHaveLength(2); + expect(new Set(sidecars.map((s) => s.filename)).size).toBe(2); + }); +}); diff --git a/functions/src/__tests__/metadata-production.test.js b/functions/src/__tests__/metadata-production.test.js index 51053ee..4e18df2 100644 --- a/functions/src/__tests__/metadata-production.test.js +++ b/functions/src/__tests__/metadata-production.test.js @@ -89,6 +89,20 @@ describe('produceMetadata', () => { trial_index: 1, time_elapsed: 776, }); + // JSON submissions have no verbatim CSV to preserve. + expect(result.mainContent).toBeUndefined(); + }); + + it('should unwrap a nonstandard { "trials": [...] } submission like the CLI does', async () => { + const wrapped = `{"trials": ${sampleData}}`; + + const bare = await produceMetadata(sampleData); + const result = await produceMetadata(wrapped); + + // Parity with the library's parseJsonData: the wrapper is unwrapped, so + // metadata and main rows match the bare-array submission exactly. + expect(result.metadata).toEqual(bare.metadata); + expect(result.mainRows).toEqual(bare.mainRows); }); it('should parse CSV submissions into main rows', async () => { @@ -99,6 +113,9 @@ describe('produceMetadata', () => { // parseCSV treats every cell as a string (columns:true, no coercion). expect(result.mainRows[0]).toMatchObject({ trial_index: '0', rt: '250' }); expect(result.mainRows[1]).toMatchObject({ trial_index: '1', rt: '300' }); + // The original text is surfaced verbatim so the main data CSV can keep + // its exact bytes (column order, quoting). + expect(result.mainContent).toBe(csv); }); it('should extract nested object and array columns with per-row data', async () => { diff --git a/functions/src/__tests__/metadata-sidecars.test.js b/functions/src/__tests__/metadata-sidecars.test.js deleted file mode 100644 index 3dd951d..0000000 --- a/functions/src/__tests__/metadata-sidecars.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import buildSidecars from '../../lib/metadata-sidecars.js'; - -const extraction = () => ({ - extractedArrays: new Map([ - ['mouse_tracking_data', [ - { trial_index: 1, element_index: 0, x: 1, y: 2, t: 10 }, - { trial_index: 1, element_index: 1, x: 3, y: 4, t: 20 }, - ]], - ]), - extractedObjects: new Map([ - ['response', [ - { trial_index: 0, 'response.Q0': 'hello', 'response.Q1': 'world' }, - ]], - ]), - joinKeys: ['trial_index'], -}); - -// The main table rows produceMetadata surfaces; buildSidecars only serialises -// nested columns, so the exact main rows don't affect the sidecar assertions. -const mainRows = () => ([ - { trial_index: 0, trial_type: 'survey-text' }, - { trial_index: 1, trial_type: 'mouse-tracking' }, -]); - -describe('buildSidecars', () => { - it('returns no sidecars when nothing was extracted', () => { - const sidecars = buildSidecars('data.json', { - extractedArrays: new Map(), - extractedObjects: new Map(), - joinKeys: ['trial_index'], - }, mainRows()); - expect(sidecars).toEqual([]); - }); - - it('builds one Psych-DS-named CSV per extracted column', () => { - const sidecars = buildSidecars('abc123.json', extraction(), mainRows()); - - expect(sidecars).toHaveLength(2); - const filenames = sidecars.map((s) => s.filename); - // Names come from the library's own Psych-DS helpers; pin the convention - // (keyword-value pairs ending in _data.csv), not the exact spelling. - for (const filename of filenames) { - expect(filename).toMatch(/_data\.csv$/); - expect(filename).toContain('measure-'); - } - expect(new Set(filenames).size).toBe(2); - }); - - it('writes array rows with join keys and element_index leading', () => { - const sidecars = buildSidecars('abc123.json', extraction(), mainRows()); - const arraySidecar = sidecars[0]; - - const [header, ...rows] = arraySidecar.content.trim().split('\n'); - expect(header.startsWith('trial_index,element_index')).toBe(true); - expect(rows).toHaveLength(2); - expect(rows[0]).toContain('1,0'); - expect(arraySidecar.content).toContain('10'); - }); - - it('writes object rows keyed by the join keys with dotted columns', () => { - const sidecars = buildSidecars('abc123.json', extraction(), mainRows()); - const objectSidecar = sidecars[1]; - - const [header, ...rows] = objectSidecar.content.trim().split('\n'); - expect(header.startsWith('trial_index')).toBe(true); - expect(header).toContain('response.Q0'); - expect(rows).toHaveLength(1); - expect(rows[0]).toContain('hello'); - }); - - it('places sidecars in the same one-level subfolder as the data file', () => { - const sidecars = buildSidecars('session1/abc123.json', extraction(), mainRows()); - for (const sidecar of sidecars) { - expect(sidecar.filename.startsWith('session1/')).toBe(true); - expect(sidecar.filename.slice('session1/'.length)).not.toContain('/'); - } - }); - - it('handles filenames without an extension', () => { - const sidecars = buildSidecars('test', extraction(), mainRows()); - expect(sidecars).toHaveLength(2); - for (const sidecar of sidecars) { - expect(sidecar.filename).toMatch(/_data\.csv$/); - } - }); - - it('disambiguates columns that normalize to the same filename', () => { - const rows = [{ trial_index: 0, a: 1 }]; - const sidecars = buildSidecars('abc.json', { - extractedArrays: new Map([['my_column', rows], ['my column', rows]]), - extractedObjects: new Map(), - joinKeys: ['trial_index'], - }, mainRows()); - expect(sidecars).toHaveLength(2); - expect(new Set(sidecars.map((s) => s.filename)).size).toBe(2); - }); -}); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 724db1f..98abc4d 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -7,8 +7,8 @@ import { db } from "./app.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; import blockMetadata from "./metadata-block.js"; -import { SidecarFile } from "./metadata-sidecars.js"; -import { uploadSidecars, queueSidecars } from "./metadata-sidecar-upload.js"; +import { DerivedFile, rawDataPath } from "./metadata-derived-files.js"; +import { uploadDerivedFiles, queueDerivedFiles } from "./metadata-derived-upload.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; @@ -128,9 +128,10 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //METADATA BLOCK START let metadataMessage: string = ''; - //Sidecar CSVs for nested array/object columns, produced by the metadata - //block and uploaded only after the participant's data file lands in OSF. - let sidecars: SidecarFile[] = []; + //Psych-DS files derived from this submission (main data CSV, sidecar CSVs + //for nested columns, .psychds-ignore), produced by the metadata block and + //uploaded only after the participant's raw data file lands in OSF. + let derivedFiles: DerivedFile[] = []; if (exp_data.metadataActive) { //Creates or references a document containing the metadata for the experiment in the metdata collection on Firestore. @@ -140,41 +141,47 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 if (metadataResponse.success === false) { await cleanupPending(pendingPath); - res.status(400).json({...metadataResponse, sidecars: undefined}); + res.status(400).json({...metadataResponse, derivedFiles: undefined}); await writeLog(experimentID, "logError", {...MESSAGES.METADATA_ERROR, detail: metadataResponse.message}); return; } metadataMessage = metadataResponse.metadataMessage; - sidecars = metadataResponse.sidecars ?? []; + derivedFiles = metadataResponse.derivedFiles ?? []; } - const sidecarTarget = { experimentID, owner: exp_data.owner, osfFilesLink: exp_data.osfFilesLink }; + const derivedTarget = { experimentID, owner: exp_data.owner, osfFilesLink: exp_data.osfFilesLink }; //METADATA BLOCK END + //With metadata on, the raw submission is the critical upload and lives at + //data/raw/ in the Psych-DS layout (the CSVs above are derived + //from it). Session counting and queue-on-failure key off this file. With + //metadata off, the layout is unchanged: the raw file goes to the root. + const uploadFilename = exp_data.metadataActive ? rawDataPath(filename) : filename; + let result: OSFResult; try { result = await putFileOSF( exp_data.osfFilesLink, token, data, - filename + uploadFilename ); } catch (e) { // Network errors, timeouts, etc. — queue for retry const detail = e instanceof Error ? e.message : "Unknown error"; try { await queueUpload({ - experimentID, owner: exp_data.owner, filename, data, + experimentID, owner: exp_data.owner, filename: uploadFilename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, errorCode: 0, sessionIncremented: true, failureReason: `Upload exception: ${detail}`, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy - // OSF is unreachable, so queue the sidecars alongside the data file. - await queueSidecars(sidecars, sidecarTarget, `Queued alongside data file: ${detail}`); + // OSF is unreachable, so queue the derived files alongside the raw data. + await queueDerivedFiles(derivedFiles, derivedTarget, `Queued alongside data file: ${detail}`); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail}); return; @@ -194,15 +201,15 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // Queue all other failures for retry try { await queueUpload({ - experimentID, owner: exp_data.owner, filename, data, + experimentID, owner: exp_data.owner, filename: uploadFilename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, errorCode: result.errorCode || 0, sessionIncremented: true, failureReason: `OSF error ${result.errorCode}: ${result.errorText}`, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy - // OSF is failing, so queue the sidecars alongside the data file. - await queueSidecars(sidecars, sidecarTarget, `Queued alongside data file: OSF error ${result.errorCode}`); + // OSF is failing, so queue the derived files alongside the raw data. + await queueDerivedFiles(derivedFiles, derivedTarget, `Queued alongside data file: OSF error ${result.errorCode}`); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); return; @@ -218,9 +225,10 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // Data successfully uploaded to OSF — clean up the pending copy. await cleanupPending(pendingPath); - // The data file is safely in OSF; upload its sidecar CSVs (best-effort — - // failures are queued for retry and logged, never failing the submission). - await uploadSidecars(sidecars, sidecarTarget, token); + // The raw data file is safely in OSF; upload the files derived from it + // (main data CSV, sidecar CSVs, .psychds-ignore — best-effort: failures are + // queued for retry and logged, never failing the submission). + await uploadDerivedFiles(derivedFiles, derivedTarget, token); res.status(201).json({...MESSAGES.SUCCESS, metadataMessage}); }); diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 5a1be52..f365843 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -7,10 +7,11 @@ import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; -import buildSidecars, { SidecarFile } from "./metadata-sidecars.js"; +import buildDerivedFiles, { DerivedFile } from "./metadata-derived-files.js"; +import { queueDerivedFiles } from "./metadata-derived-upload.js"; import { ExperimentData, Metadata, MetadataResponse } from './interfaces'; -export type MetadataBlockResult = MetadataResponse & { sidecars?: SidecarFile[] }; +export type MetadataBlockResult = MetadataResponse & { derivedFiles?: DerivedFile[] }; // Sentinel thrown inside the transaction when the merge needs the OSF copy of // the metadata as its base. The OSF download must happen outside the @@ -42,10 +43,12 @@ try { const produced = await produceMetadata(data, metadataOptions); const incomingMetadata: Metadata = produced.metadata; - //Sidecar CSVs for nested array/object columns, mirroring the CLI's per-file - //output. Built here; uploaded by the caller only after the participant's - //data file itself lands in OSF, so sidecars can never precede their data. - const sidecars: SidecarFile[] = buildSidecars(filename, produced, produced.mainRows); + //The full Psych-DS file set derived from this submission (main data CSV, + //sidecar CSVs for nested columns, .psychds-ignore), mirroring the CLI's + //per-file output. Built here; uploaded by the caller only after the + //participant's raw data file itself lands in OSF under data/raw/, so + //derived files can never precede the data they are derived from. + const derivedFiles: DerivedFile[] = buildDerivedFiles(filename, produced); //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: //dataset_description.json exists in the OSF project. @@ -94,29 +97,51 @@ try { updatedMetadata = await runMergeTransaction(); } - //Firestore is now up to date; mirror the merged metadata to OSF. + //Firestore is now up to date; mirror the merged metadata to OSF. The mirror + //is best-effort: Firestore is the source of truth and every submission + //re-merges and re-mirrors, so a failure here must not reject the + //participant's data — it is queued for retry (create) or left for the next + //submission to repair (update). const metadataFileContents = JSON.stringify(updatedMetadata, null, 2); + const queueTarget = { + experimentID: metadata_doc_ref.id, + owner: exp_data.owner, + osfFilesLink: exp_data.osfFilesLink, + }; - //If a metadata file exists in OSF, it is updated. Otherwise it is created. - if (osfMetadataId) { - await updateFileOSF( - exp_data.osfFilesLink, - osfToken, - metadataFileContents, - osfMetadataId - ); - } else { - const response = await putFileOSF( - exp_data.osfFilesLink, - osfToken, - metadataFileContents, - `dataset_description.json` - ); - - if (!response.success) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); + try { + //If a metadata file exists in OSF, it is updated. Otherwise it is created. + if (osfMetadataId) { + //Result intentionally unchecked: the queue can only PUT (which would 409 + //against the existing file), and the next submission updates OSF anyway. + await updateFileOSF( + exp_data.osfFilesLink, + osfToken, + metadataFileContents, + osfMetadataId + ); + } else { + const response = await putFileOSF( + exp_data.osfFilesLink, + osfToken, + metadataFileContents, + `dataset_description.json` + ); + + //A 409 means a concurrent submission created the file first; the next + //submission will fold this one's merge (already in Firestore) into it. + if (!response.success && response.errorCode !== 409) { + await queueDerivedFiles([{ filename: "dataset_description.json", content: metadataFileContents }], + queueTarget, `dataset_description OSF error ${response.errorCode}: ${response.errorText}`); + } + } + } catch (error) { + const detail = error instanceof Error ? error.message : "Unknown error"; + await queueDerivedFiles([{ filename: "dataset_description.json", content: metadataFileContents }], + queueTarget, `dataset_description upload exception: ${detail}`); } - const metadataResponse: MetadataBlockResult = {success: true, ...metadataMessage, sidecars}; + const metadataResponse: MetadataBlockResult = {success: true, ...metadataMessage, derivedFiles}; return metadataResponse; } catch (error) { diff --git a/functions/src/metadata-derived-files.ts b/functions/src/metadata-derived-files.ts new file mode 100644 index 0000000..4b9965c --- /dev/null +++ b/functions/src/metadata-derived-files.ts @@ -0,0 +1,85 @@ +import { + deriveFallbackBase, + buildPsychDSDataFiles, + PSYCHDS_IGNORE_FILENAME, + PSYCHDS_IGNORE_CONTENT, +} from '@jspsych/metadata'; + +// A file derived from one submission's data, with its full path relative to +// the OSF component root (e.g. "data/subject-abc123_data.csv"). +export interface DerivedFile { + filename: string; + content: string; +} + +export interface ExtractionResult { + extractedArrays: Map>>; + extractedObjects: Map>>; + joinKeys: string[]; +} + +// What buildDerivedFiles needs from produceMetadata's result. +export interface DerivedFileSource extends ExtractionResult { + mainRows: Array>; + mainContent?: string; +} + +/** + * Researcher-supplied folder prefixes (e.g. "condition-A/abc.json") are + * flattened away in the Psych-DS layout: the CLI converts whole directories + * into a flat data/ folder, and DataPipe matches it, so only the last path + * segment names the file. Grouping by subfolder is lost under data/. + */ +function flattenName(dataFilename: string): string { + const slashIndex = dataFilename.lastIndexOf('/'); + return slashIndex === -1 ? dataFilename : dataFilename.slice(slashIndex + 1); +} + +/** + * The OSF path for the byte-verbatim original submission when metadata is on: + * data/raw/. This is the critical upload — every other file is + * derived from it — and .psychds-ignore excludes raw/ from Psych-DS validation. + */ +export function rawDataPath(dataFilename: string): string { + return `data/raw/${flattenName(dataFilename)}`; +} + +/** + * Builds the full set of Psych-DS files derived from one submission, mirroring + * what the @jspsych/metadata CLI writes per data file: the main data table as + * data/_data.csv, one sidecar CSV per extracted array-of-objects or + * plain-object column (data/_measure-_data.csv), and .psychds-ignore + * at the component root so validators skip data/raw/. + * + * Naming and CSV serialisation are delegated to the library's shared + * buildPsychDSDataFiles (the same function the CLI and browser flows use), so + * DataPipe's output stays byte-identical to theirs for the same data. A CSV + * submission passes mainContent so the main file keeps its exact original + * bytes; JSON submissions get their main table serialised from mainRows. + * + * All of these are derivable from the raw file, so callers upload them + * best-effort after the raw file itself lands (see rawDataPath above). + */ +export default function buildDerivedFiles( + dataFilename: string, + source: DerivedFileSource, +): DerivedFile[] { + const name = flattenName(dataFilename); + + const dotIndex = name.lastIndexOf('.'); + const stem = dotIndex <= 0 ? name : name.slice(0, dotIndex); + + const files = buildPsychDSDataFiles({ + base: deriveFallbackBase(stem), + mainRows: source.mainRows, + mainContent: source.mainContent, + extractedArrays: source.extractedArrays, + extractedObjects: source.extractedObjects, + joinKeys: source.joinKeys, + }); + + return [ + ...files.map((file) => ({ filename: `data/${file.filename}`, content: file.content })), + { filename: PSYCHDS_IGNORE_FILENAME, content: PSYCHDS_IGNORE_CONTENT }, + ]; +} diff --git a/functions/src/metadata-derived-upload.ts b/functions/src/metadata-derived-upload.ts new file mode 100644 index 0000000..9586d74 --- /dev/null +++ b/functions/src/metadata-derived-upload.ts @@ -0,0 +1,68 @@ +import putFileOSF from "./put-file-osf.js"; +import queueUpload from "./queue-upload.js"; +import writeLog from "./write-log.js"; +import MESSAGES from "./api-messages.js"; +import { DerivedFile } from "./metadata-derived-files.js"; + +export interface DerivedUploadTarget { + experimentID: string; + owner: string; + osfFilesLink: string; +} + +/** + * Uploads derived Psych-DS files (main data CSV, sidecar CSVs, .psychds-ignore) + * to OSF, best-effort: the participant's raw data file is already safely in + * OSF by the time this runs, and every derived file is reproducible from it, + * so a failure is queued for retry (the same uploadQueue the data files use) + * and logged — it never fails the submission. + * A 409 means an earlier attempt already landed the file; nothing to do. + */ +export async function uploadDerivedFiles( + files: DerivedFile[], + target: DerivedUploadTarget, + osfToken: string, +): Promise { + for (const file of files) { + try { + const result = await putFileOSF(target.osfFilesLink, osfToken, file.content, file.filename); + if (result.success || result.errorCode === 409) continue; + await queueDerivedFiles([file], target, `Derived file OSF error ${result.errorCode}: ${result.errorText}`); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + await queueDerivedFiles([file], target, `Derived file upload exception: ${detail}`); + } + } +} + +/** + * Queues derived files for retried upload without attempting one first — used + * when the raw data file itself just failed to reach OSF (it was queued, so + * OSF is known to be unavailable). sessionIncremented is true because only + * the raw data file accounts for the session count. + */ +export async function queueDerivedFiles( + files: DerivedFile[], + target: DerivedUploadTarget, + failureReason: string, +): Promise { + for (const file of files) { + try { + await queueUpload({ + experimentID: target.experimentID, + owner: target.owner, + filename: file.filename, + data: file.content, + dataType: "data", + osfFilesLink: target.osfFilesLink, + errorCode: 0, + sessionIncremented: true, + failureReason, + }); + await writeLog(target.experimentID, "logError", {...MESSAGES.OSF_UPLOAD_QUEUED, detail: `derived file ${file.filename}: ${failureReason}`}); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + await writeLog(target.experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, detail: `derived file ${file.filename} could not be queued: ${detail}`}); + } + } +} diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index 06f10af..efae6a4 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -1,6 +1,6 @@ -import jsPsychMetadata, { parseCSV } from '@jspsych/metadata'; +import jsPsychMetadata, { parseCSV, parseJsonData } from '@jspsych/metadata'; import { Metadata } from './interfaces'; -import { ExtractionResult } from './metadata-sidecars.js'; +import { ExtractionResult } from './metadata-derived-files.js'; export interface ProducedMetadata extends ExtractionResult { metadata: Metadata; @@ -12,6 +12,11 @@ export interface ProducedMetadata extends ExtractionResult { // variableMeasured and the sidecars. For JSON this is the parsed trial array; // for CSV it is parsed from the original text via the library's parseCSV. mainRows: Array>; + // The original CSV text, verbatim, when the submission was CSV — passed to + // buildPsychDSDataFiles as mainContent so the main data CSV keeps its exact + // bytes (column order, quoting) instead of being re-serialised from mainRows. + // Undefined for JSON submissions. + mainContent?: string; } export default async function produceMetadata(data: string, options: object | null = null): Promise { @@ -24,8 +29,12 @@ export default async function produceMetadata(data: string, options: object | nu const csvFlag: boolean = isCsv(data); - // Parses the data if it is JSON object in string format. - if(!csvFlag) data = JSON.parse(data); + // Parses the data if it is JSON in string format. parseJsonData is the + // library's own parser (the CLI and frontend run it too): a bare trial + // array — the standard jsPsych/DataPipe payload — passes through unchanged, + // and the nonstandard-but-possible { "trials": [...] } wrapper is unwrapped + // to its array, keeping DataPipe's parsing at parity with the CLI's. + if(!csvFlag) data = parseJsonData(data); // Generates the metadata, using the options if they are provided. // The vendored @jspsych/metadata (see functions/metadata/) changed generate()'s @@ -50,12 +59,14 @@ export default async function produceMetadata(data: string, options: object | nu // Nested array/object columns that generate() expanded into dotted // sub-variables; their per-row data is returned so callers can write - // sidecar CSVs (see metadata-sidecars.ts). + // sidecar CSVs (see metadata-derived-files.ts). return { metadata: incomingMetadata, extractedArrays: metadata.getExtractedArrays(), extractedObjects: metadata.getExtractedObjects(), joinKeys: metadata.getArrayJoinKeys(), mainRows, + // For CSV, `data` was never reassigned and is still the original text. + mainContent: csvFlag ? (data as string) : undefined, }; } diff --git a/functions/src/metadata-sidecar-upload.ts b/functions/src/metadata-sidecar-upload.ts deleted file mode 100644 index 2381f6b..0000000 --- a/functions/src/metadata-sidecar-upload.ts +++ /dev/null @@ -1,67 +0,0 @@ -import putFileOSF from "./put-file-osf.js"; -import queueUpload from "./queue-upload.js"; -import writeLog from "./write-log.js"; -import MESSAGES from "./api-messages.js"; -import { SidecarFile } from "./metadata-sidecars.js"; - -export interface SidecarUploadTarget { - experimentID: string; - owner: string; - osfFilesLink: string; -} - -/** - * Uploads sidecar CSVs to OSF, best-effort: the participant's data file is - * already safely in OSF by the time this runs, and sidecars are derivable - * from it, so a sidecar failure is queued for retry (the same uploadQueue - * the data files use) and logged — it never fails the submission. - * A 409 means an earlier attempt already landed the file; nothing to do. - */ -export async function uploadSidecars( - sidecars: SidecarFile[], - target: SidecarUploadTarget, - osfToken: string, -): Promise { - for (const sidecar of sidecars) { - try { - const result = await putFileOSF(target.osfFilesLink, osfToken, sidecar.content, sidecar.filename); - if (result.success || result.errorCode === 409) continue; - await queueSidecars([sidecar], target, `Sidecar OSF error ${result.errorCode}: ${result.errorText}`); - } catch (e) { - const detail = e instanceof Error ? e.message : "Unknown error"; - await queueSidecars([sidecar], target, `Sidecar upload exception: ${detail}`); - } - } -} - -/** - * Queues sidecar CSVs for retried upload without attempting one first — used - * when the main data file itself just failed to reach OSF (it was queued, so - * OSF is known to be unavailable). sessionIncremented is true because only - * the main data file accounts for the session count. - */ -export async function queueSidecars( - sidecars: SidecarFile[], - target: SidecarUploadTarget, - failureReason: string, -): Promise { - for (const sidecar of sidecars) { - try { - await queueUpload({ - experimentID: target.experimentID, - owner: target.owner, - filename: sidecar.filename, - data: sidecar.content, - dataType: "data", - osfFilesLink: target.osfFilesLink, - errorCode: 0, - sessionIncremented: true, - failureReason, - }); - await writeLog(target.experimentID, "logError", {...MESSAGES.OSF_UPLOAD_QUEUED, detail: `sidecar ${sidecar.filename}: ${failureReason}`}); - } catch (e) { - const detail = e instanceof Error ? e.message : "Unknown error"; - await writeLog(target.experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, detail: `sidecar ${sidecar.filename} could not be queued: ${detail}`}); - } - } -} diff --git a/functions/src/metadata-sidecars.ts b/functions/src/metadata-sidecars.ts deleted file mode 100644 index 8c2c440..0000000 --- a/functions/src/metadata-sidecars.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { deriveFallbackBase, buildPsychDSDataFiles } from '@jspsych/metadata'; - -export interface SidecarFile { - filename: string; - content: string; -} - -export interface ExtractionResult { - extractedArrays: Map>>; - extractedObjects: Map>>; - joinKeys: string[]; -} - -/** - * Builds the sidecar CSV files for one submission's extracted nested-data - * columns, mirroring what the @jspsych/metadata CLI writes per data file: - * one CSV per array-of-objects column (rows keyed by the join keys plus - * element_index) and one per plain-object column (one row per trial, keyed - * by the join keys only). - * - * The naming and CSV serialisation are delegated to the library's shared - * buildPsychDSDataFiles (the same function the CLI and browser flows use), so - * DataPipe's sidecar output stays byte-identical to theirs for the same data. - * We keep only the sidecar files here (kind 'array'/'object'); the main data - * CSV (kind 'main') is wired up when api-data adopts the Psych-DS data/ layout. - * - * Sidecars are placed in the same one-level subfolder as the data file, - * matching how putFileOSF resolves "folder/name" filenames. - */ -export default function buildSidecars( - dataFilename: string, - extraction: ExtractionResult, - mainRows: Array>, -): SidecarFile[] { - const { extractedArrays, extractedObjects, joinKeys } = extraction; - - if (extractedArrays.size === 0 && extractedObjects.size === 0) return []; - - const slashIndex = dataFilename.indexOf('/'); - const folder = slashIndex === -1 ? '' : dataFilename.slice(0, slashIndex + 1); - const name = slashIndex === -1 ? dataFilename : dataFilename.slice(slashIndex + 1); - - const dotIndex = name.lastIndexOf('.'); - const stem = dotIndex <= 0 ? name : name.slice(0, dotIndex); - - const base = deriveFallbackBase(stem); - - const files = buildPsychDSDataFiles({ - base, - mainRows, - extractedArrays, - extractedObjects, - joinKeys, - }); - - return files - .filter((file) => file.kind !== 'main') - .map((file) => ({ filename: folder + file.filename, content: file.content })); -} From c4edecaec6d79ccf60c26e7d2201eb10a31e5ccb Mon Sep 17 00:00:00 2001 From: Mandyx22 <1915537307@qq.com> Date: Mon, 6 Jul 2026 14:36:42 -0400 Subject: [PATCH 011/181] feat: metadata popover docs link + status-label tooltips - Point the metadata help popover's "Learn more" link at the Psych-DS docs - Add hover tooltips to the experiment-list status labels (Data, Base64, Conditions, Metadata) describing each feature and its enabled state Co-Authored-By: Claude Opus 4.8 --- pages/admin/[experiment_id].js | 5 ++-- pages/admin/index.js | 42 +++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/pages/admin/[experiment_id].js b/pages/admin/[experiment_id].js index f3a4a38..9aed7be 100644 --- a/pages/admin/[experiment_id].js +++ b/pages/admin/[experiment_id].js @@ -6,7 +6,6 @@ import { db, auth } from "../../lib/firebase"; import { doc, collection, query, where, orderBy } from "firebase/firestore"; import { Spinner, Flex, VStack, HStack, Text, Badge, Separator, Popover, IconButton, Link } from "@chakra-ui/react"; -import NextLink from "next/link"; import { CircleHelp } from "lucide-react"; import Title from "../../components/dashboard/Title"; @@ -142,8 +141,8 @@ function ExperimentPageDashboard({ experiment_id }) { Generates Psych-DS metadata describing your data's columns (descriptions, value ranges, and levels), making your dataset easier to share and reuse.{" "} - - Learn more + + Learn more diff --git a/pages/admin/index.js b/pages/admin/index.js index c478329..5930b66 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -19,6 +19,7 @@ import { Card, CloseButton, Link as ChakraLink, + Tooltip, } from "@chakra-ui/react"; import { Trash2, Pencil } from "lucide-react"; @@ -202,10 +203,10 @@ function ExperimentItem({ exp }) { - - - - + + + + {exp.sessions > 0 && ( {exp.sessions} {exp.sessions === 1 ? "session" : "sessions"} @@ -219,19 +220,28 @@ function ExperimentItem({ exp }) { ); } -function StatusLabel({ label, on }) { +function StatusLabel({ label, on, feature }) { return ( - - - - {label} - - + + + + + + {label} + + + + + + {feature} is {on ? "enabled" : "disabled"} for this experiment. + + + ); } From 3f4621a3dcccff3d41382bdde8587f9cdde6151f Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:05:36 -0400 Subject: [PATCH 012/181] fix(recovery): make crash recovery layout-aware Recovered submissions for metadata-active experiments were re-enqueued under their original filename instead of data/raw/, producing no derived files and never merging into dataset_description.json. Worse, the queue doc was keyed off the original filename while api-data.ts keys off the transformed one, so a crash between queueUpload and cleanupPending could upload the same file twice under two names. Extract the metadataActive ? rawDataPath(filename) : filename decision into uploadPathFor() and use it in both api-data.ts and scheduled-pending-recovery.ts's promoteToQueue, for both the queued filename and the dedup key, eliminating the double-upload window as a side effect. Metadata/derived files are not regenerated for recovered sessions (documented as a known limitation): recovery has no metadata pipeline and the raw file is the source of truth. --- ...cheduled-pending-recovery-emulator.test.js | 83 +++++++++++++++++++ functions/src/api-data.ts | 4 +- functions/src/metadata-derived-files.ts | 10 +++ functions/src/scheduled-pending-recovery.ts | 21 ++++- 4 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 functions/src/__tests__/scheduled-pending-recovery-emulator.test.js diff --git a/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js b/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js new file mode 100644 index 0000000..4d834f6 --- /dev/null +++ b/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js @@ -0,0 +1,83 @@ +/** + * @jest-environment node + */ + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +// app.js (imported transitively by the lib modules below) calls +// initializeApp() with no args, which reads the default bucket from +// FIREBASE_CONFIG — set it before those imports run so storage.bucket() +// resolves to the same emulator bucket this test uses directly. +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +const { getFirestore } = require("firebase-admin/firestore"); +const { getStorage } = require("firebase-admin/storage"); +const { promoteToQueue } = require("../../lib/scheduled-pending-recovery.js"); +const { persistPending } = require("../../lib/persist-pending.js"); + +jest.setTimeout(30000); + +const db = getFirestore(); +const bucket = getStorage().bucket(); + +async function seedExperiment(experimentID, metadataActive) { + await db.collection("experiments").doc(experimentID).set({ + active: true, + metadataActive, + owner: "recovery-test-user", + osfFilesLink: "http://localhost:0/endpoint", + }); +} + +afterEach(async () => { + const docs = await db.collection("uploadQueue").get(); + const batch = db.batch(); + docs.forEach((doc) => batch.delete(doc.ref)); + await batch.commit(); +}); + +describe("scheduled-pending-recovery layout awareness", () => { + it("queues the raw-data path and matching dedup key when metadata is active", async () => { + const experimentID = "recovery-test-metadata-on"; + await seedExperiment(experimentID, true); + + const storagePath = await persistPending( + experimentID, + "condition-A/data.json", + "[]" + ); + const file = bucket.file(storagePath); + + await promoteToQueue(file); + + const expectedDedupKey = `${experimentID}:data/raw/data.json`; + const docId = expectedDedupKey.replace(/[/\\]/g, "_"); + const doc = await db.collection("uploadQueue").doc(docId).get(); + + expect(doc.exists).toBe(true); + expect(doc.data().filename).toBe("data/raw/data.json"); + expect(doc.data().deduplicationKey).toBe(expectedDedupKey); + }); + + it("queues the original filename and matching dedup key when metadata is off", async () => { + const experimentID = "recovery-test-metadata-off"; + await seedExperiment(experimentID, false); + + const storagePath = await persistPending(experimentID, "data.json", "[]"); + const file = bucket.file(storagePath); + + await promoteToQueue(file); + + const expectedDedupKey = `${experimentID}:data.json`; + const docId = expectedDedupKey.replace(/[/\\]/g, "_"); + const doc = await db.collection("uploadQueue").doc(docId).get(); + + expect(doc.exists).toBe(true); + expect(doc.data().filename).toBe("data.json"); + expect(doc.data().deduplicationKey).toBe(expectedDedupKey); + }); +}); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 98abc4d..dbeb0a5 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -7,7 +7,7 @@ import { db } from "./app.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; import blockMetadata from "./metadata-block.js"; -import { DerivedFile, rawDataPath } from "./metadata-derived-files.js"; +import { DerivedFile, uploadPathFor } from "./metadata-derived-files.js"; import { uploadDerivedFiles, queueDerivedFiles } from "./metadata-derived-upload.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; @@ -158,7 +158,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //data/raw/ in the Psych-DS layout (the CSVs above are derived //from it). Session counting and queue-on-failure key off this file. With //metadata off, the layout is unchanged: the raw file goes to the root. - const uploadFilename = exp_data.metadataActive ? rawDataPath(filename) : filename; + const uploadFilename = uploadPathFor(exp_data.metadataActive, filename); let result: OSFResult; try { diff --git a/functions/src/metadata-derived-files.ts b/functions/src/metadata-derived-files.ts index 4b9965c..37d6cb8 100644 --- a/functions/src/metadata-derived-files.ts +++ b/functions/src/metadata-derived-files.ts @@ -44,6 +44,16 @@ export function rawDataPath(dataFilename: string): string { return `data/raw/${flattenName(dataFilename)}`; } +/** + * The OSF path a raw submission should actually be uploaded to: `data/raw/` + * when metadata is on, unchanged at the root otherwise. Callers that key + * queue/dedup entries off the upload filename (api-data's request path and + * scheduled-pending-recovery) must agree on this, so the rule lives here once. + */ +export function uploadPathFor(metadataActive: boolean | undefined, dataFilename: string): string { + return metadataActive ? rawDataPath(dataFilename) : dataFilename; +} + /** * Builds the full set of Psych-DS files derived from one submission, mirroring * what the @jspsych/metadata CLI writes per data file: the main data table as diff --git a/functions/src/scheduled-pending-recovery.ts b/functions/src/scheduled-pending-recovery.ts index 15c9800..f17d5f9 100644 --- a/functions/src/scheduled-pending-recovery.ts +++ b/functions/src/scheduled-pending-recovery.ts @@ -3,6 +3,7 @@ import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; import { readPendingEnvelope, cleanupPending } from "./persist-pending.js"; import { ExperimentData } from "./interfaces.js"; +import { uploadPathFor } from "./metadata-derived-files.js"; const PENDING_PREFIX = "pending-data/"; @@ -87,7 +88,7 @@ async function recoverPendingUploads() { * 4. Create an uploadQueue Firestore document * 5. Clean up the pending-data/ file */ -async function promoteToQueue( +export async function promoteToQueue( file: ReturnType["file"]> ) { // Read the envelope @@ -119,9 +120,21 @@ async function promoteToQueue( return; } + // Layout-aware upload path: metadata-active experiments store their raw + // file at data/raw/, same as api-data.ts's live-submission path. Recovered + // sessions get no metadata/derived files regenerated here (recovery has no + // metadata pipeline and the raw file is the source of truth; the next live + // submission re-merges Firestore metadata into dataset_description.json + // anyway) — full parity would be separate work. + const uploadFilename = uploadPathFor(expData.metadataActive, filename); + // Check for deduplication and atomically create the queue entry via transaction. - // This prevents duplicate entries if two recovery runs overlap. - const deduplicationKey = `${experimentID}:${filename}`; + // This prevents duplicate entries if two recovery runs overlap. Keyed off + // uploadFilename (not the envelope's original filename) so this matches the + // key api-data.ts uses for the same eventual OSF path — otherwise a crash + // between queueUpload and cleanupPending could upload the same submission + // twice, once under each filename. + const deduplicationKey = `${experimentID}:${uploadFilename}`; const docId = deduplicationKey.replace(/[/\\]/g, "_"); const docRef = db.collection("uploadQueue").doc(docId); @@ -142,7 +155,7 @@ async function promoteToQueue( transaction.set(docRef, { experimentID, owner: expData.owner, - filename, + filename: uploadFilename, storagePath, dataType: "data", osfFilesLink: expData.osfFilesLink, From f7dd07945e2d4ac71ac8e7b4cfa281e254b04cfc Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:07:27 -0400 Subject: [PATCH 013/181] fix(metadata): stop widened metadata failures from destroying data Two cheap hardening fixes, plus the D2 decision (option b): keep returning 400 on a metadata-block failure, but stop deleting the pending-data copy so scheduled-pending-recovery can salvage it later instead of losing the submission outright. Graceful-degrade (accepting the data anyway) is a product decision left for a follow-up. - metadata-production.ts: variableMeasured is now length-checked (`?.length`) so an empty array throws the intended clean error instead of a TypeError on `variableMeasured[0]`. - metadata-production.ts: parseJsonData's result is checked for Array.isArray so a bare JSON object throws a clear "Data must be an array of trials" instead of flowing into generate()/mainRows and failing deeper with a confusing message. --- functions/src/__tests__/metadata-production.test.js | 9 +++++++++ functions/src/api-data.ts | 6 ++++-- functions/src/metadata-production.ts | 9 +++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/functions/src/__tests__/metadata-production.test.js b/functions/src/__tests__/metadata-production.test.js index 4e18df2..cdd0694 100644 --- a/functions/src/__tests__/metadata-production.test.js +++ b/functions/src/__tests__/metadata-production.test.js @@ -164,4 +164,13 @@ describe('produceMetadata', () => { expect(objectRows).toHaveLength(1); expect(objectRows[0]).toMatchObject({ trial_index: 0, 'response.Q0': 'hello', 'response.Q1': 'world' }); }); + + it('throws a clean error instead of a TypeError when the trial array is empty', async () => { + await expect(produceMetadata('[]')).rejects.toThrow('Invalid metadata generated'); + }); + + it('throws a clean error for a bare JSON object instead of letting it reach generate()', async () => { + await expect(produceMetadata('{"trial_type": "html-keyboard-response"}')) + .rejects.toThrow('Data must be an array of trials'); + }); }); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index dbeb0a5..c9c36f9 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -140,8 +140,10 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 const metadataResponse = await blockMetadata(exp_data, token, metadata_doc_ref, data, filename, metadataOptions); if (metadataResponse.success === false) { - await cleanupPending(pendingPath); - res.status(400).json({...metadataResponse, derivedFiles: undefined}); + // The pending-data copy is deliberately kept (not cleaned up) here: the + // participant's raw data never made it to OSF, so scheduled-pending-recovery + // salvages it later instead of losing it outright. + res.status(400).json(metadataResponse); await writeLog(experimentID, "logError", {...MESSAGES.METADATA_ERROR, detail: metadataResponse.message}); return; } diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index efae6a4..01bde1a 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -34,7 +34,12 @@ export default async function produceMetadata(data: string, options: object | nu // array — the standard jsPsych/DataPipe payload — passes through unchanged, // and the nonstandard-but-possible { "trials": [...] } wrapper is unwrapped // to its array, keeping DataPipe's parsing at parity with the CLI's. - if(!csvFlag) data = parseJsonData(data); + if (!csvFlag) { + data = parseJsonData(data); + if (!Array.isArray(data)) { + throw new Error('Data must be an array of trials'); + } + } // Generates the metadata, using the options if they are provided. // The vendored @jspsych/metadata (see functions/metadata/) changed generate()'s @@ -45,7 +50,7 @@ export default async function produceMetadata(data: string, options: object | nu const incomingMetadata: Metadata = metadata.getMetadata() as Metadata; - if (!incomingMetadata.variableMeasured || !incomingMetadata.variableMeasured[0].name) { + if (!incomingMetadata.variableMeasured?.length || !incomingMetadata.variableMeasured[0].name) { throw new Error('Invalid metadata generated'); } From 79016a6ad21d57adf1cbe1c12ca3551ecfc01db8 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:08:44 -0400 Subject: [PATCH 014/181] fix(metadata): collision-proof flattened filenames (D1: encode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two submissions with the same leaf name in different subfolders collided at data/raw/ (the second got a 409 -> 400 OSF_FILE_EXISTS rejection): flattenName discarded the subfolder prefix instead of encoding it. flattenName now encodes path separators as `-` (condition-A/data.json -> condition-A-data.json) instead of dropping everything before the last `/`. The derived main CSV/sidecar stem follows the same encoded name automatically, so those stay collision-free too. Flat data/raw/ still matches the CLI's layout (the alternative — nesting subfolders under data/raw/ — was considered and rejected: it would diverge from the CLI and still need the encoded stem for derived files). --- .../__tests__/metadata-derived-files.test.js | 31 ++++++++++++++----- functions/src/metadata-derived-files.ts | 12 ++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/functions/src/__tests__/metadata-derived-files.test.js b/functions/src/__tests__/metadata-derived-files.test.js index 9837beb..0138dc5 100644 --- a/functions/src/__tests__/metadata-derived-files.test.js +++ b/functions/src/__tests__/metadata-derived-files.test.js @@ -33,9 +33,13 @@ describe('rawDataPath', () => { expect(rawDataPath('abc123.json')).toBe('data/raw/abc123.json'); }); - it('flattens researcher subfolders', () => { - expect(rawDataPath('condition-A/abc123.json')).toBe('data/raw/abc123.json'); - expect(rawDataPath('a/b/abc123.json')).toBe('data/raw/abc123.json'); + it('encodes researcher subfolders into the flattened name', () => { + expect(rawDataPath('condition-A/abc123.json')).toBe('data/raw/condition-A-abc123.json'); + expect(rawDataPath('a/b/abc123.json')).toBe('data/raw/a-b-abc123.json'); + }); + + it('keeps two same-leaf-name submissions from different subfolders collision-free', () => { + expect(rawDataPath('condition-A/data.json')).not.toBe(rawDataPath('condition-B/data.json')); }); }); @@ -96,13 +100,26 @@ describe('buildDerivedFiles', () => { expect(rows[0]).toContain('hello'); }); - it('flattens researcher subfolders into the same flat data/ layout', () => { + it('encodes researcher subfolders into distinct, flat data/ filenames', () => { const flat = buildDerivedFiles('abc123.json', source()); const nested = buildDerivedFiles('session1/abc123.json', source()); - expect(nested.map((f) => f.filename)).toEqual(flat.map((f) => f.filename)); - for (const file of nested) { - expect(file.filename).not.toContain('session1'); + expect(nested.map((f) => f.filename)).not.toEqual(flat.map((f) => f.filename)); + for (const file of nested.filter((f) => f.filename !== '.psychds-ignore')) { + expect(file.filename).toContain('session1'); + expect(file.filename).not.toContain('/session1/'); + } + }); + + it('two submissions with the same leaf name in different subfolders no longer collide', () => { + const a = buildDerivedFiles('condition-A/data.json', source()); + const b = buildDerivedFiles('condition-B/data.json', source()); + + const aNames = new Set(a.map((f) => f.filename)); + const bNames = new Set(b.map((f) => f.filename)); + for (const name of aNames) { + if (name === '.psychds-ignore') continue; // shared file, not a collision + expect(bNames.has(name)).toBe(false); } }); diff --git a/functions/src/metadata-derived-files.ts b/functions/src/metadata-derived-files.ts index 37d6cb8..9da4af2 100644 --- a/functions/src/metadata-derived-files.ts +++ b/functions/src/metadata-derived-files.ts @@ -26,13 +26,15 @@ export interface DerivedFileSource extends ExtractionResult { /** * Researcher-supplied folder prefixes (e.g. "condition-A/abc.json") are - * flattened away in the Psych-DS layout: the CLI converts whole directories - * into a flat data/ folder, and DataPipe matches it, so only the last path - * segment names the file. Grouping by subfolder is lost under data/. + * flattened into the Psych-DS layout: the CLI converts whole directories into + * a flat data/ folder, and DataPipe matches it, so the path is encoded into a + * single filename rather than nested. Encoding (instead of discarding) the + * prefix keeps two submissions with the same leaf name in different + * subfolders from colliding at data/raw/ (and keeps the derived main + * CSV/sidecar stems, which follow the same encoded name, collision-free too). */ function flattenName(dataFilename: string): string { - const slashIndex = dataFilename.lastIndexOf('/'); - return slashIndex === -1 ? dataFilename : dataFilename.slice(slashIndex + 1); + return dataFilename.replace(/[/\\]+/g, '-'); } /** From 1ade20fdeef3aa9c706688ea6629accc57aea6d6 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:12:36 -0400 Subject: [PATCH 015/181] fix(metadata): stop the create-only queue from dropping dataset_description updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related defects, one root cause: the uploadQueue was designed for immutable per-submission files, but dataset_description.json is mutable. - metadata-block.ts: when updateFileOSF throws, the catch no longer queues a create-PUT — it was guaranteed to 409 against the existing file, and the retry worker would mark that dead entry completed without ever applying the update. Firestore is the source of truth and every submission re-merges and re-mirrors, so the next submission repairs OSF instead (matches the code's own existing comment). - queue-upload.ts: while an entry is "pending", a newer submission with fresher content used to return early without ever queueing it, so the eventual retry pushed stale metadata. Now a "pending" re-queue overwrites the Cloud Storage payload (keeping the Firestore doc/status/retry schedule), so the retry uploads the freshest content. "processing" entries are left alone since the retry worker owns that payload right now. --- functions/src/metadata-block.ts | 27 +++++++++++++++++---------- functions/src/queue-upload.ts | 21 +++++++++++++++++---- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index f365843..b84ccba 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -109,18 +109,25 @@ try { osfFilesLink: exp_data.osfFilesLink, }; - try { - //If a metadata file exists in OSF, it is updated. Otherwise it is created. - if (osfMetadataId) { - //Result intentionally unchecked: the queue can only PUT (which would 409 - //against the existing file), and the next submission updates OSF anyway. + //If a metadata file exists in OSF, it is updated. Otherwise it is created. + if (osfMetadataId) { + try { await updateFileOSF( exp_data.osfFilesLink, osfToken, metadataFileContents, osfMetadataId ); - } else { + } catch { + //Result intentionally unchecked and NOT queued: the uploadQueue only + //PUTs (create), which is guaranteed to 409 against a file that already + //exists — queueing here would just leave a dead entry the retry worker + //marks completed without ever applying the update. Firestore is the + //source of truth and every submission re-merges and re-mirrors, so the + //next submission repairs OSF instead. + } + } else { + try { const response = await putFileOSF( exp_data.osfFilesLink, osfToken, @@ -134,11 +141,11 @@ try { await queueDerivedFiles([{ filename: "dataset_description.json", content: metadataFileContents }], queueTarget, `dataset_description OSF error ${response.errorCode}: ${response.errorText}`); } + } catch (error) { + const detail = error instanceof Error ? error.message : "Unknown error"; + await queueDerivedFiles([{ filename: "dataset_description.json", content: metadataFileContents }], + queueTarget, `dataset_description upload exception: ${detail}`); } - } catch (error) { - const detail = error instanceof Error ? error.message : "Unknown error"; - await queueDerivedFiles([{ filename: "dataset_description.json", content: metadataFileContents }], - queueTarget, `dataset_description upload exception: ${detail}`); } const metadataResponse: MetadataBlockResult = {success: true, ...metadataMessage, derivedFiles}; diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index 286de8c..a8b5460 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -24,13 +24,26 @@ export default async function queueUpload(params: QueueUploadParams): Promise Date: Mon, 6 Jul 2026 16:15:55 -0400 Subject: [PATCH 016/181] perf(metadata): parallelize derived uploads, resolve data/ once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadDerivedFiles previously uploaded N derived files serially, each independently walking (and possibly racing to create) the OSF data/ folder path — ~2(N+2) sequential OSF round-trips added to the response path before the participant's 201. - uploadDerivedFiles now resolves the data/ folder once up front and fans out the actual uploads with Promise.allSettled. Per-file 409 and queue-on-failure handling was already concurrency-safe; folder-create races among concurrent submissions still resolve via subfolder.ts's existing 409-re-list branch. - putFileOSF takes an optional pre-resolved startUrl to upload directly into (or walk any remaining segments from), skipping the redundant walk for files that share an already-resolved folder. --- .../metadata-derived-upload-emulator.test.js | 84 +++++++++++++++++++ functions/src/__tests__/put-file-osf.test.js | 24 ++++++ functions/src/metadata-derived-upload.ts | 25 +++++- functions/src/put-file-osf.ts | 11 ++- 4 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 functions/src/__tests__/metadata-derived-upload-emulator.test.js diff --git a/functions/src/__tests__/metadata-derived-upload-emulator.test.js b/functions/src/__tests__/metadata-derived-upload-emulator.test.js new file mode 100644 index 0000000..090a743 --- /dev/null +++ b/functions/src/__tests__/metadata-derived-upload-emulator.test.js @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +const { initializeApp, getApp } = require("firebase-admin/app"); +const { getFirestore } = require("firebase-admin/firestore"); +const { uploadDerivedFiles } = require("../../lib/metadata-derived-upload.js"); + +let app; +try { + app = getApp(); +} catch { + app = initializeApp(); +} + +jest.setTimeout(30000); + +const db = getFirestore(app); + +const ROOT = "https://files.osf.io/v1/resources/abc/providers/osfstorage/"; +const TOKEN = "test-token"; + +const move = (name) => `https://files.osf.io/v1/folder/${name}/`; + +const listing = (folderNames) => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + data: folderNames.map((n) => ({ attributes: { name: n, kind: "folder" }, links: { move: move(n) } })), + }), +}); +const fileOk = () => Promise.resolve({ status: 201 }); +const fileFail = (status, statusText) => Promise.resolve({ + status, + statusText, + headers: { get: () => null }, +}); + +const experimentID = "derived-upload-test-exp"; +const target = { experimentID, owner: "derived-upload-test-user", osfFilesLink: ROOT }; + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +afterEach(async () => { + const docs = await db.collection("uploadQueue").where("experimentID", "==", experimentID).get(); + const batch = db.batch(); + docs.forEach((doc) => batch.delete(doc.ref)); + await batch.commit(); +}); + +describe("uploadDerivedFiles", () => { + it("resolves data/ once and queues exactly the file that failed", async () => { + const files = [ + { filename: "data/subject-abc_data.csv", content: "main" }, + { filename: "data/measure-x_data.csv", content: "sidecar" }, + { filename: ".psychds-ignore", content: "ignore" }, + ]; + + fetch + .mockReturnValueOnce(listing(["data"])) // resolve data/ once, up front + .mockReturnValueOnce(fileOk()) // main CSV upload + .mockReturnValueOnce(fileFail(500, "Server Error")) // sidecar CSV upload fails + .mockReturnValueOnce(fileOk()); // .psychds-ignore upload + + await uploadDerivedFiles(files, target, TOKEN); + + // Only one "data/"-folder resolution call, despite two files living under data/. + const dataMetaCalls = fetch.mock.calls.filter(([url]) => url === `${ROOT}?meta=`); + expect(dataMetaCalls).toHaveLength(1); + + const docs = await db.collection("uploadQueue").where("experimentID", "==", experimentID).get(); + expect(docs.docs).toHaveLength(1); + expect(docs.docs[0].data().filename).toBe("data/measure-x_data.csv"); + }); +}); diff --git a/functions/src/__tests__/put-file-osf.test.js b/functions/src/__tests__/put-file-osf.test.js index c21c5ef..1ff75d9 100644 --- a/functions/src/__tests__/put-file-osf.test.js +++ b/functions/src/__tests__/put-file-osf.test.js @@ -131,6 +131,30 @@ describe('putFileOSF', () => { ]); }); + it('uploads straight into a pre-resolved startUrl, skipping the walk entirely', async () => { + fetch.mockReturnValueOnce(fileOk()); + + const result = await putFileOSF(ROOT, TOKEN, 'data', 'abc123.json', move('data')); + + expect(result.success).toBe(true); + expect(callUrls()).toEqual([`${move('data')}?kind=file&name=abc123.json`]); + }); + + it('walks remaining segments starting from a pre-resolved startUrl', async () => { + fetch + .mockReturnValueOnce(listing([])) + .mockReturnValueOnce(folderCreated('raw')) + .mockReturnValueOnce(fileOk()); + + await putFileOSF(ROOT, TOKEN, '{}', 'raw/abc123.json', move('data')); + + expect(callUrls()).toEqual([ + `${move('data')}?meta=`, + `${move('data')}?kind=folder&name=raw`, + `${move('raw')}?kind=file&name=abc123.json`, + ]); + }); + it('returns the OSF error when the file upload fails', async () => { fetch.mockReturnValueOnce(fileFail(409, 'Conflict')); diff --git a/functions/src/metadata-derived-upload.ts b/functions/src/metadata-derived-upload.ts index 9586d74..6990a62 100644 --- a/functions/src/metadata-derived-upload.ts +++ b/functions/src/metadata-derived-upload.ts @@ -3,6 +3,9 @@ import queueUpload from "./queue-upload.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; import { DerivedFile } from "./metadata-derived-files.js"; +import resolveFolder from "./subfolder.js"; + +const DATA_PREFIX = "data/"; export interface DerivedUploadTarget { experimentID: string; @@ -23,16 +26,30 @@ export async function uploadDerivedFiles( target: DerivedUploadTarget, osfToken: string, ): Promise { - for (const file of files) { + // Every derived file under data/ shares that one folder; resolve it once up + // front instead of each of the N uploads below independently walking (and + // possibly racing to create) the same path. Folder-create races among + // concurrent submissions still resolve safely via subfolder.ts's own + // 409-re-list branch. + const needsDataFolder = files.some((file) => file.filename.startsWith(DATA_PREFIX)); + const dataFolderLink = needsDataFolder + ? await resolveFolder(target.osfFilesLink, osfToken, "data") + : undefined; + + await Promise.allSettled(files.map(async (file) => { + const underData = file.filename.startsWith(DATA_PREFIX); + const uploadFilename = underData ? file.filename.slice(DATA_PREFIX.length) : file.filename; + const startUrl = underData ? dataFolderLink : undefined; + try { - const result = await putFileOSF(target.osfFilesLink, osfToken, file.content, file.filename); - if (result.success || result.errorCode === 409) continue; + const result = await putFileOSF(target.osfFilesLink, osfToken, file.content, uploadFilename, startUrl); + if (result.success || result.errorCode === 409) return; await queueDerivedFiles([file], target, `Derived file OSF error ${result.errorCode}: ${result.errorText}`); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await queueDerivedFiles([file], target, `Derived file upload exception: ${detail}`); } - } + })); } /** diff --git a/functions/src/put-file-osf.ts b/functions/src/put-file-osf.ts index ea4aeac..66b38e6 100644 --- a/functions/src/put-file-osf.ts +++ b/functions/src/put-file-osf.ts @@ -4,18 +4,23 @@ export default async function putFileOSF( osfComponent: string, osfToken: string, filedata: string | Buffer, - filename: string + filename: string, + // Optional pre-resolved link for the folder `filename`'s remaining segments + // are relative to (e.g. already resolved "data/" for a caller uploading many + // files under data/). Skips re-walking that portion of the path. Defaults to + // osfComponent (the component root) when omitted. + startUrl?: string, ) { // A filename may carry a path prefix (e.g. "data/raw/abc123.json"). Split it // into folder segments and the file name; each folder level is found-or-created // in turn (WaterButler has no atomic deep-path create), walking down to the // folder that will hold the file. A bare "abc123.json" has no segments and - // uploads straight to the storage root. + // uploads straight to the storage root (or to startUrl, if given). const segments = filename.split('/'); const fileName = segments.pop() as string; - let targetUrl = osfComponent; + let targetUrl = startUrl ?? osfComponent; for (const folder of segments) { targetUrl = await resolveFolder(targetUrl, osfToken, folder); } From 76b68047aaf76b0ee0dbd862259a05e06b422137 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:17:49 -0400 Subject: [PATCH 017/181] perf(metadata): parse each payload exactly once produceMetadata parsed JSON payloads twice (an isCsv() probe, then parseJsonData) and CSV payloads twice (once inside generate(), once via parseCSV for mainRows). Replace the isCsv probe with parseJsonData itself in a try/catch: success is the JSON path with the parsed array already in hand; a throw means CSV. For CSV, parseCSV runs once up front and its rows are passed into generate() as a pre-parsed array (confirmed in functions/metadata/dist/index.js: generate() short-circuits on Array.isArray(data) for both formats before any internal parsing) and reused as mainRows, instead of generate() re-parsing the same text. Golden metadata-production.test.js output (including byte-verbatim CSV mainContent) is unchanged. --- functions/src/metadata-production.ts | 55 +++++++++++++++------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index 01bde1a..fc391c9 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -24,29 +24,41 @@ export default async function produceMetadata(data: string, options: object | nu // Initializes the metadata object. var metadata = new jsPsychMetadata(); // eslint-disable-line no-var - // Checks if the data is in CSV format. - const isCsv = (str: string) => { try { JSON.parse(str); return false; } catch (e) { return true; } }; - - const csvFlag: boolean = isCsv(data); - - // Parses the data if it is JSON in string format. parseJsonData is the - // library's own parser (the CLI and frontend run it too): a bare trial - // array — the standard jsPsych/DataPipe payload — passes through unchanged, - // and the nonstandard-but-possible { "trials": [...] } wrapper is unwrapped - // to its array, keeping DataPipe's parsing at parity with the CLI's. - if (!csvFlag) { - data = parseJsonData(data); - if (!Array.isArray(data)) { + // Parse the payload exactly once. parseJsonData is the library's own + // parser (the CLI and frontend run it too): a bare trial array — the + // standard jsPsych/DataPipe payload — passes through unchanged, and the + // nonstandard-but-possible { "trials": [...] } wrapper is unwrapped to its + // array. If that parse fails, the payload is CSV text instead. + let csvFlag: boolean; + let jsonRows: Array> | undefined; + try { + const parsed = parseJsonData(data); + if (!Array.isArray(parsed)) { throw new Error('Data must be an array of trials'); } + jsonRows = parsed as Array>; + csvFlag = false; + } catch (e) { + if (e instanceof Error && e.message === 'Data must be an array of trials') throw e; + csvFlag = true; } + // For CSV, parse once up front too and hand generate() the same rows used + // for mainRows below, instead of generate() re-parsing the text itself. + const csvRows: Array> | undefined = csvFlag + ? (await parseCSV(data)) as Array> + : undefined; + // Generates the metadata, using the options if they are provided. // The vendored @jspsych/metadata (see functions/metadata/) changed generate()'s // signature to generate(data, metadata={}, ext='json'|'csv', options={}) — the 3rd // arg is now a string extension, not the boolean csv flag the old fork used. + // Passing a pre-parsed array (rather than the raw string) skips generate()'s + // own internal parse for both formats; ext is still passed for its other + // format-dependent behavior (e.g. id-column detection). const ext: 'json' | 'csv' = csvFlag ? 'csv' : 'json'; - options ? await metadata.generate(data, options, ext) : await metadata.generate(data, {}, ext); + const rows = (csvFlag ? csvRows : jsonRows) as Array>; + options ? await metadata.generate(rows, options, ext) : await metadata.generate(rows, {}, ext); const incomingMetadata: Metadata = metadata.getMetadata() as Metadata; @@ -54,14 +66,6 @@ export default async function produceMetadata(data: string, options: object | nu throw new Error('Invalid metadata generated'); } - // Main data rows for the Psych-DS main CSV. For JSON, `data` is the parsed - // array (nested columns left intact — see mainRows doc above); for CSV, - // parse the original text (the string `data` is untouched — generate() - // parses its own copy internally). - const mainRows: Array> = csvFlag - ? (await parseCSV(data)) as Array> - : (data as unknown as Array>); - // Nested array/object columns that generate() expanded into dotted // sub-variables; their per-row data is returned so callers can write // sidecar CSVs (see metadata-derived-files.ts). @@ -70,8 +74,9 @@ export default async function produceMetadata(data: string, options: object | nu extractedArrays: metadata.getExtractedArrays(), extractedObjects: metadata.getExtractedObjects(), joinKeys: metadata.getArrayJoinKeys(), - mainRows, - // For CSV, `data` was never reassigned and is still the original text. - mainContent: csvFlag ? (data as string) : undefined, + // Same rows array passed to generate() above — see the mainRows doc + // comment for why sharing (rather than re-parsing) is intentional. + mainRows: rows, + mainContent: csvFlag ? data : undefined, }; } From 7b4d00a3993269c1c9d041a33ca7a74c2eea3326 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:20:13 -0400 Subject: [PATCH 018/181] refactor(metadata): replace the sentinel-abort transaction with a pre-read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blockMetadata threw a module-level Error singleton out of the merge transaction and compared it by reference identity to detect the bootstrap case (Firestore empty, OSF populated) needing an OSF download before merging — discarding and re-running the whole transaction in that case. osfMetadataId is already known before the transaction even starts, so the download can be decided up front. Replaced with a non-transactional pre-read of metadata_doc_ref: if Firestore is empty and osfMetadataId exists, downloadMetadata runs first; the transaction then runs exactly once. It still re-reads Firestore inside, so a concurrent populate between the pre-read and the transaction still resolves correctly via firestoreMetadata ?? osfMetadata. Deletes NEEDS_OSF_METADATA, the inner runMergeTransaction closure, and the identity-check try/catch. The existing metadata-emulator suite's "in OSF but not in firestore" case already covers the bootstrap path. --- functions/src/metadata-block.ts | 45 +++++++++++++-------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index b84ccba..8e54efd 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -13,12 +13,6 @@ import { ExperimentData, Metadata, MetadataResponse } from './interfaces'; export type MetadataBlockResult = MetadataResponse & { derivedFiles?: DerivedFile[] }; -// Sentinel thrown inside the transaction when the merge needs the OSF copy of -// the metadata as its base. The OSF download must happen outside the -// transaction (Firestore retries the transaction callback on contention, which -// would re-run any network call inside it), so we abort, download, and re-run. -const NEEDS_OSF_METADATA = new Error("needs-osf-metadata"); - export default async function blockMetadata( exp_data: ExperimentData, osfToken: string, @@ -54,13 +48,23 @@ try { //dataset_description.json exists in the OSF project. const osfMetadataId: string | undefined = (await processMetadata(exp_data.osfFilesLink, osfToken)).metadataId; - //Populated only when Firestore has no metadata but OSF does (see sentinel above). + //Non-transactional pre-read to decide whether the OSF copy of the metadata + //needs downloading as the merge base (the bootstrap case: Firestore empty, + //OSF populated). Done outside the transaction below since Firestore retries + //a transaction callback on contention, which would otherwise repeat this + //network call on every retry. let osfMetadata: Metadata | undefined; + const preReadFirestoreMetadata: Metadata | undefined = (await metadata_doc_ref.get()).data()?.metadata; + if (!preReadFirestoreMetadata && osfMetadataId) { + osfMetadata = (await downloadMetadata(exp_data.osfFilesLink, osfToken, osfMetadataId)).metadata; + } //The transaction is Firestore-only: read the metadata doc, merge, write it - //back. All OSF network I/O happens before or after, so a transaction retry - //can never repeat an OSF call. - const runMergeTransaction = () => db.runTransaction(async (t) => { + //back. All OSF network I/O happened above, so a transaction retry (e.g. a + //concurrent submission populating Firestore between the pre-read and here) + //can never repeat an OSF call — firestoreMetadata is re-read here, so that + //race still resolves correctly via firestoreMetadata ?? osfMetadata. + const updatedMetadata = await db.runTransaction(async (t) => { const firestoreMetadata: Metadata | undefined = (await t.get(metadata_doc_ref)).data()?.metadata; //Record which of the four states we are in. This is set before any @@ -71,32 +75,17 @@ try { metadataMessage = osfMetadataId ? MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE : MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; } - if (!firestoreMetadata && osfMetadataId && !osfMetadata) { - throw NEEDS_OSF_METADATA; - } - //When Firestore has metadata, updating is done with respect to Firestore. //When only OSF has metadata, the downloaded OSF copy is the base instead. //When neither has metadata, the incoming metadata is used as-is. const baseMetadata: Metadata | undefined = firestoreMetadata ?? osfMetadata; - const updatedMetadata = baseMetadata ? await updateMetadata(baseMetadata, incomingMetadata) : incomingMetadata; + const updated = baseMetadata ? await updateMetadata(baseMetadata, incomingMetadata) : incomingMetadata; - t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); + t.set(metadata_doc_ref, {metadata: updated}, {merge: true}); - return updatedMetadata; + return updated; }); - let updatedMetadata; - try { - updatedMetadata = await runMergeTransaction(); - } catch (error) { - if (error !== NEEDS_OSF_METADATA) throw error; - //Metadata is in OSF as evidenced by the metadata ID, so it is downloaded - //to serve as the merge base, and the transaction is re-run. - osfMetadata = (await downloadMetadata(exp_data.osfFilesLink, osfToken, osfMetadataId as string)).metadata; - updatedMetadata = await runMergeTransaction(); - } - //Firestore is now up to date; mirror the merged metadata to OSF. The mirror //is best-effort: Firestore is the source of truth and every submission //re-merges and re-mirrors, so a failure here must not reject the From a75661858eeb23070be2d5e28d3d0f1c5748d80a Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:22:37 -0400 Subject: [PATCH 019/181] fix(faq): make deep-link scroll robust to Chakra internals and in-page hash changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scroll targeting relied on Chakra v3's private data-controls id format and a 350ms timer, and only ran on mount, so in-page hash changes (e.g. clicking another #item-N link without a full navigation) did nothing. Each FAQItem now wraps its Accordion.Item in a — a DOM node we own that's present regardless of expand/collapse state, so scrollIntoView-style positioning no longer needs to wait on the accordion's expand animation or depend on Chakra's internal attribute naming. The scroll effect also listens for hashchange, not just mount. --- pages/faq.js | 60 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/pages/faq.js b/pages/faq.js index ab0c9df..76c5454 100644 --- a/pages/faq.js +++ b/pages/faq.js @@ -13,18 +13,26 @@ export default function FAQ() { const [openItems, setOpenItems] = useState(["item-0"]); useEffect(() => { - const hash = window.location.hash.replace("#", ""); - if (!hash) return; - setOpenItems((prev) => (prev.includes(hash) ? prev : [...prev, hash])); - // Wait for the accordion to expand, then bring the item's trigger into view. - // Chakra doesn't forward `id` to the DOM, so target the trigger via its - // data-controls attribute and offset for the fixed navbar. - setTimeout(() => { - const el = document.querySelector(`[data-controls$=":content:${hash}"]`); - if (!el) return; - const top = el.getBoundingClientRect().top + window.scrollY - 80; - window.scrollTo({ top, behavior: "smooth" }); - }, 350); + function scrollToHash() { + const hash = window.location.hash.replace("#", ""); + if (!hash) return; + setOpenItems((prev) => (prev.includes(hash) ? prev : [...prev, hash])); + // Each FAQItem owns a Box with id={value} that isn't part of the + // collapsible content, so it's always in the DOM to target — no need to + // wait on the accordion's expand animation. Offset for the fixed navbar. + requestAnimationFrame(() => { + const el = document.getElementById(hash); + if (!el) return; + const top = el.getBoundingClientRect().top + window.scrollY - 80; + window.scrollTo({ top, behavior: "smooth" }); + }); + } + + scrollToHash(); + // Also handle hash changes while already on this page (e.g. clicking + // another #item-N link without a full navigation/mount). + window.addEventListener("hashchange", scrollToHash); + return () => window.removeEventListener("hashchange", scrollToHash); }, []); return ( @@ -325,18 +333,20 @@ export default function FAQ() { function FAQItem({ question, children, value }) { return ( - - - - {question} - - - - - - {children} - - - + + + + + {question} + + + + + + {children} + + + + ); } From 56f281cc58494da1dacb0ad3363cf49583a006ac Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:45:55 -0400 Subject: [PATCH 020/181] fix(metadata): restore metadataMessage on the pre-read download path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the emulator suite surfaced a regression from the prior sentinel-abort-transaction removal: the OSF download (which can throw, e.g. on a 404) now happened before metadataMessage was ever set, so a download failure produced an empty metadataMessage instead of reporting which of the four Firestore/OSF states triggered it — unlike before, where the transaction always got to set metadataMessage on its first pass before the sentinel throw. metadataMessage is now set from the pre-read immediately, before the download attempt. The transaction still recomputes it from a fresh read (harmless, and correct if a concurrent write races the pre-read). Caught by functions/src/__tests__/metadata-emulator.test.js's "in OSF but not in Firestore" case under the full emulator suite. --- functions/src/metadata-block.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 8e54efd..33349b8 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -53,8 +53,18 @@ try { //OSF populated). Done outside the transaction below since Firestore retries //a transaction callback on contention, which would otherwise repeat this //network call on every retry. - let osfMetadata: Metadata | undefined; const preReadFirestoreMetadata: Metadata | undefined = (await metadata_doc_ref.get()).data()?.metadata; + + //Record which of the four states we are in before the download below (which + //can throw) so error responses still report the state, same as a successful + //response would. + if (preReadFirestoreMetadata) { + metadataMessage = osfMetadataId ? MESSAGES.METADATA_IN_OSF_AND_FIRESTORE : MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; + } else { + metadataMessage = osfMetadataId ? MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE : MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; + } + + let osfMetadata: Metadata | undefined; if (!preReadFirestoreMetadata && osfMetadataId) { osfMetadata = (await downloadMetadata(exp_data.osfFilesLink, osfToken, osfMetadataId)).metadata; } From 891312a6bb613befc5beeae44a191db9d0287ef0 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 16:46:10 -0400 Subject: [PATCH 021/181] test(recovery): fix expected path in the emulator test for D1 encoding The recovery test's expected raw-data path was written before the D1 (encode) commit landed and still expected the old lossy-flatten behavior (data/raw/data.json). Update it to the encoded name (data/raw/condition-A-data.json), matching flattenName's actual current behavior. Caught by running the full emulator suite. --- .../src/__tests__/scheduled-pending-recovery-emulator.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js b/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js index 4d834f6..5c13a0f 100644 --- a/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js +++ b/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js @@ -54,12 +54,12 @@ describe("scheduled-pending-recovery layout awareness", () => { await promoteToQueue(file); - const expectedDedupKey = `${experimentID}:data/raw/data.json`; + const expectedDedupKey = `${experimentID}:data/raw/condition-A-data.json`; const docId = expectedDedupKey.replace(/[/\\]/g, "_"); const doc = await db.collection("uploadQueue").doc(docId).get(); expect(doc.exists).toBe(true); - expect(doc.data().filename).toBe("data/raw/data.json"); + expect(doc.data().filename).toBe("data/raw/condition-A-data.json"); expect(doc.data().deduplicationKey).toBe(expectedDedupKey); }); From 76d1b9576c68532770f44c91fb5629f29f79b259 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 17:10:30 -0400 Subject: [PATCH 022/181] fix(metadata): keep up-front data/ resolution from failing the submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "resolve data/ once up front" change moved resolveFolder outside the per-file try/catch, so an OSF/network failure threw out of uploadDerivedFiles — which api-data awaits un-try/caught after the raw file already landed and pending was cleaned up. That lost the derived files (never queued) and returned 500 instead of 201, breaking the best-effort "never fails the submission" contract. Wrap the up-front resolveFolder in try/catch and fall back to an undefined folder link, so each under-data/ file re-walks the path inside its own per-file catch (which queues on failure). Add an emulator test that drives an unreachable OSF and asserts every file is queued. Co-Authored-By: Claude Opus 4.8 --- .../metadata-derived-upload-emulator.test.js | 19 +++++++++++++++++++ functions/src/metadata-derived-upload.ts | 18 +++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/functions/src/__tests__/metadata-derived-upload-emulator.test.js b/functions/src/__tests__/metadata-derived-upload-emulator.test.js index 090a743..d93e54b 100644 --- a/functions/src/__tests__/metadata-derived-upload-emulator.test.js +++ b/functions/src/__tests__/metadata-derived-upload-emulator.test.js @@ -81,4 +81,23 @@ describe("uploadDerivedFiles", () => { expect(docs.docs).toHaveLength(1); expect(docs.docs[0].data().filename).toBe("data/measure-x_data.csv"); }); + + it("does not throw when the up-front data/ resolution fails — every file is queued instead", async () => { + const files = [ + { filename: "data/subject-abc_data.csv", content: "main" }, + { filename: "data/measure-x_data.csv", content: "sidecar" }, + { filename: ".psychds-ignore", content: "ignore" }, + ]; + + // OSF is unreachable: the up-front resolveFolder rejects, and so does every + // per-file re-walk. The best-effort contract requires this never throws and + // every file ends up queued for retry rather than lost. + fetch.mockRejectedValue(new Error("network down")); + + await expect(uploadDerivedFiles(files, target, TOKEN)).resolves.toBeUndefined(); + + const docs = await db.collection("uploadQueue").where("experimentID", "==", experimentID).get(); + const queued = docs.docs.map((d) => d.data().filename).sort(); + expect(queued).toEqual([".psychds-ignore", "data/measure-x_data.csv", "data/subject-abc_data.csv"]); + }); }); diff --git a/functions/src/metadata-derived-upload.ts b/functions/src/metadata-derived-upload.ts index 6990a62..b640d0d 100644 --- a/functions/src/metadata-derived-upload.ts +++ b/functions/src/metadata-derived-upload.ts @@ -31,10 +31,22 @@ export async function uploadDerivedFiles( // possibly racing to create) the same path. Folder-create races among // concurrent submissions still resolve safely via subfolder.ts's own // 409-re-list branch. + // + // This resolution is best-effort: if it throws (OSF list/create error or a + // network failure), fall back to undefined so each under-data/ file re-walks + // the path itself inside its own per-file try/catch below — that path still + // queues on failure. Letting the throw escape here would instead lose the + // derived files entirely (never uploaded, never queued) and fail an already- + // successful submission, breaking this function's best-effort contract. const needsDataFolder = files.some((file) => file.filename.startsWith(DATA_PREFIX)); - const dataFolderLink = needsDataFolder - ? await resolveFolder(target.osfFilesLink, osfToken, "data") - : undefined; + let dataFolderLink: string | undefined; + if (needsDataFolder) { + try { + dataFolderLink = await resolveFolder(target.osfFilesLink, osfToken, "data"); + } catch { + dataFolderLink = undefined; + } + } await Promise.allSettled(files.map(async (file) => { const underData = file.filename.startsWith(DATA_PREFIX); From 509e848b72fb3b5d53d83d9acfcbb3aeeb41d938 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 17:11:09 -0400 Subject: [PATCH 023/181] fix(queue): don't lose a refreshed payload when the doc races to completion The pending-refresh path read status once, then saved to Cloud Storage non-atomically. If the retry worker finished the doc (-> completed/failed, which deletes the storage object) in that window, the fresh payload was written to an orphaned path and silently lost. Re-read after the save: if the doc is still pending/processing we're done; otherwise fall through to a clean full re-queue so the fresh data isn't dropped. Co-Authored-By: Claude Opus 4.8 --- functions/src/queue-upload.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index a8b5460..7e75621 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -44,7 +44,18 @@ export default async function queueUpload(params: QueueUploadParams): Promise processing) or finish (-> completed/failed, which + // deletes the storage object) the doc in between. Re-read to confirm. + // Still pending/processing: the worker either hasn't started or now owns + // the doc and will read the payload we just wrote — either way we're + // done. Otherwise the doc finished out from under us and our fresh + // payload is orphaned, so fall through to re-queue a clean pending doc. + const recheck = await docRef.get(); + const recheckStatus = recheck.exists ? recheck.data()?.status : undefined; + if (recheckStatus === "pending" || recheckStatus === "processing") { + return docId; + } } } From 1c72ef640077ce39678f2f3015bec2d8c5be3c19 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Mon, 6 Jul 2026 17:12:09 -0400 Subject: [PATCH 024/181] refactor(metadata): mark the not-an-array case with a class, not a message The parse-once path signalled "valid JSON but not a trial array" by throwing an Error and re-matching its message text, which is fragile if the vendored library ever throws that same string. Use a private NotATrialArrayError class instead; external behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- functions/src/metadata-production.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/functions/src/metadata-production.ts b/functions/src/metadata-production.ts index fc391c9..0904c0c 100644 --- a/functions/src/metadata-production.ts +++ b/functions/src/metadata-production.ts @@ -19,6 +19,10 @@ export interface ProducedMetadata extends ExtractionResult { mainContent?: string; } +// Internal marker: the payload parsed as JSON but wasn't a trial array. Kept +// private so it can only be thrown/caught here, never matched by message text. +class NotATrialArrayError extends Error {} + export default async function produceMetadata(data: string, options: object | null = null): Promise { // Initializes the metadata object. @@ -34,12 +38,17 @@ export default async function produceMetadata(data: string, options: object | nu try { const parsed = parseJsonData(data); if (!Array.isArray(parsed)) { - throw new Error('Data must be an array of trials'); + // Valid JSON, but not a trial array (e.g. a bare object). This is a + // real input error, not a "fall back to CSV" signal, so flag it with a + // dedicated marker rather than a message string — that way a coincidental + // parse error from the library carrying the same text can't be mistaken + // for it below. + throw new NotATrialArrayError(); } jsonRows = parsed as Array>; csvFlag = false; } catch (e) { - if (e instanceof Error && e.message === 'Data must be an array of trials') throw e; + if (e instanceof NotATrialArrayError) throw new Error('Data must be an array of trials'); csvFlag = true; } From 22735ec74649aa939751772549b0b1e3ab87fb23 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Tue, 7 Jul 2026 10:27:55 -0400 Subject: [PATCH 025/181] refactor(metadata): make MetadataBlockResult a discriminated union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit derivedFiles can now only exist on a success result, so the 400 path in api-data can send the failure response verbatim without risking a leak — the guarantee is compiler-enforced rather than resting on a defensive strip. Co-Authored-By: Claude Opus 4.8 --- functions/src/metadata-block.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 33349b8..5dce4ae 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -11,7 +11,13 @@ import buildDerivedFiles, { DerivedFile } from "./metadata-derived-files.js"; import { queueDerivedFiles } from "./metadata-derived-upload.js"; import { ExperimentData, Metadata, MetadataResponse } from './interfaces'; -export type MetadataBlockResult = MetadataResponse & { derivedFiles?: DerivedFile[] }; +// Discriminated on `success` so the compiler guarantees derivedFiles can only +// ride on a success — a failure response structurally cannot carry them, so +// callers (api-data's 400 path) can send it verbatim with no risk of leaking a +// half-built derived-file set. +type MetadataSuccess = Omit & { success: true; derivedFiles?: DerivedFile[] }; +type MetadataFailure = Omit & { success: false }; +export type MetadataBlockResult = MetadataSuccess | MetadataFailure; export default async function blockMetadata( exp_data: ExperimentData, @@ -29,7 +35,7 @@ try { //Only run if metadata collection is enabled. if (!exp_data.metadataActive) { metadataMessage = MESSAGES.METADATA_NOT_ACTIVE; - const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; + const metadataResponse: MetadataSuccess = {success: true, ...metadataMessage}; return metadataResponse; } @@ -147,7 +153,7 @@ try { } } - const metadataResponse: MetadataBlockResult = {success: true, ...metadataMessage, derivedFiles}; + const metadataResponse: MetadataSuccess = {success: true, ...metadataMessage, derivedFiles}; return metadataResponse; } catch (error) { @@ -160,7 +166,7 @@ catch (error) { console.error("Metadata block error:", errorMessage); - const metadataResponse: MetadataResponse = {success: false, ...MESSAGES.METADATA_ERROR, message: errorMessage, ...metadataMessage}; + const metadataResponse: MetadataFailure = {success: false, ...MESSAGES.METADATA_ERROR, message: errorMessage, ...metadataMessage}; return metadataResponse; //METADATA BLOCK END }; From e4adf1085612f1b77ccbecdede5e45c16cd0b7c0 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 21 Jul 2026 13:38:22 -0400 Subject: [PATCH 026/181] docs: add storage provider migration design doc Captures the OSF dependency audit, provider evaluation (Box/S3/Dropbox/ OneDrive, GitHub, Dataverse ecosystem/ICPSR/Dryad/Databrary), and the pluggable-provider architecture targeting Google Drive, Figshare, and Dataverse, with OSF retained as a legacy adapter. Co-Authored-By: Claude Sonnet 5 --- docs/provider-migration-design.md | 250 ++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/provider-migration-design.md diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md new file mode 100644 index 0000000..c50d6f9 --- /dev/null +++ b/docs/provider-migration-design.md @@ -0,0 +1,250 @@ +# Storage Provider Migration — Design Doc + +## Background + +DataPipe currently depends on the Open Science Framework (OSF) as its sole storage +backend: researchers OAuth2-link (or paste a PAT for) their own OSF account, and +DataPipe writes experiment session data and a Psych-DS metadata file directly into +an OSF project/component on their behalf. DataPipe never retains a copy of +submitted data itself. + +This migration is driven by **deprecation/shutdown risk at OSF**, not cost or a +policy dispute. The goal is to decouple DataPipe from any single storage vendor by +introducing a pluggable storage-provider abstraction, and to ship initial support +for three concrete providers: **Google Drive, Figshare, and Dataverse**, alongside +OSF kept in place as a legacy adapter for existing connected users. + +## Current OSF dependency (summary) + +A full code-level audit found DataPipe's OSF dependency concentrated in a +identifiable set of modules, with a large amount of surrounding logic +(condition assignment, Psych-DS metadata generation/merge, token encryption, +CSRF/OAuth-state handling, and the GCS/Firestore retry-queue durability system) +already provider-agnostic and requiring no change. The OSF-specific surface: + +- **OAuth2 flow**: `oauth2-callback.ts`, `oauth2-regenerate.ts`, `refresh-token.ts`, + `resolve-token.ts`, `generate-oauth-state.ts`, plus a parallel static + Personal-Access-Token path (`save-osf-token.ts`, `get-osf-token.ts`). +- **File writes**: `put-file-osf.ts`, `update-file-osf.ts`, `subfolder.ts` — built + on OSF's Waterbutler API, and explicitly relying on OSF's `409 Conflict` response + as the sole collision-detection mechanism for per-session data files. +- **Metadata handling**: `metadata-block.ts`, `metadata-process.ts` — reconciles a + mutable `dataset_description.json` file against a live provider-side folder + listing on every update. +- **OSF concepts baked into the data model**: project + child "component", + `osfstorage` as an implicit provider, 4 hardcoded OSF storage regions, OSF file + IDs with an `osfstorage/` prefix. +- **Frontend**: `pages/admin/new.js` (OSF-shaped experiment creation form), + `ExperimentInfo.js` (hardcoded `osf.io` links), `QueuePanel.js` (OSF status-code + copy), all of `components/account/*`. +- **Config/rules**: OSF client ID/secret/redirect env vars, and `firestore.rules` + hardcoding OSF-shaped field whitelists. + +## Requirements for any replacement + +1. OAuth2 delegated authorization with refresh-token rotation — researcher owns + the account, DataPipe never holds a password, access must survive unattended + for the life of a study (months). +2. A file-write API (atomic create-if-absent is a nice-to-have, not a hard + requirement — see "Collision detection" below for why). +3. Support for updating one mutable file per project (the metadata file). +4. Folder/path support, or a documented fallback when absent. +5. A "project + child container" shape, or a flat namespace DataPipe's model can + be mapped onto. +6. Burst tolerance (a class of 30–100 students submitting within a minute). +7. Free-tier economics realistic for typical academic use, and — where possible — + DOI/citation support, since that's part of DataPipe's pitch to researchers, + not just plumbing. + +## Providers evaluated and ruled out + +- **GitHub** — technically the strongest fit (sha-gated Contents API naturally + solves collision detection and mutable updates), but using a source-control + platform as a silent, continuous data-dump backend runs against the spirit of + the service even though no explicit ToS clause forbids it, and there's no + precedent either way for this exact usage pattern. Ruled out on those grounds + rather than a technical one. +- **Zenodo, Figshare (as archival), Harvard Dataverse, ICPSR, Dryad, Databrary, + DANS** — the entire "research-data-specific repository" category is built + around a curate-once-publish-once-mint-a-DOI workflow, structurally mismatched + to DataPipe's hundreds-of-small-incremental-writes-over-months pattern. This + turned out to be a category-wide limitation, not specific to any one vendor — + confirmed across every Dataverse-software installation (Harvard, Borealis, + DataverseNL, DataverseNO, DANS all share the same open-source codebase, same + static-token-only auth, same silent-rename-on-duplicate-filename behavior). + ICPSR has no automated API at all. Dryad supports incremental draft writes but + only via a shared service-account grant (no per-researcher OAuth consent) and + charges a $150/dataset publishing fee. Databrary is architecturally a gated, + human-reviewed video library, not a general write target. +- **Box, Amazon S3, Dropbox, Microsoft OneDrive/Graph** — all technically solid, + generic-storage options with no platform-fit ambiguity. Box has the strongest + native atomic-write guarantee of anything evaluated and is already common at + universities; S3 has the best region control and longest clean API history but + breaks OAuth-onboarding simplicity (self-provisioned IAM). These remain + reasonable fallback options but were not selected as the initial three. + +**Selected for initial implementation: Google Drive, Figshare, Dataverse.** +OSF is retained as a legacy adapter — existing connected users and in-flight +studies keep working unmodified; new experiments default to one of the three +new providers. + +## Architecture + +### Provider interface + +``` +StorageProvider { + id: 'osf' | 'gdrive' | 'figshare' | 'dataverse' + authMethod: 'oauth2' | 'static-token' + + // auth + getAuthUrl(state) / exchangeCode(code) / refreshToken(rt) // oauth2 + validateStaticToken(token) // static-token + + // one-time setup at experiment creation + createDataContainer(auth, researcherInput) -> containerRef // opaque, provider-shaped + + // ongoing writes + writeSessionFile(auth, containerRef, filename, data) -> WriteResult + updateFile(auth, containerRef, existingFileRef, data) -> WriteResult + + // needed for collision-cache rehydration (see below) and dashboard file counts + listFiles(auth, containerRef) -> FileRef[] + + capabilities: { nativeSubfolders: bool, supportsRegion: bool } +} +``` + +`capabilities` is descriptive (UI hints, subfolder fallback behavior), not a +correctness gate — provider-side atomicity is no longer load-bearing (see below). + +### Collision detection: a cache, not a new system of record + +None of the three selected providers offer OSF's atomic `409`-on-duplicate +behavior (Drive allows same-name files silently, Dataverse silently renames, +Figshare's behavior is unconfirmed). Rather than build three different +reliability models behind one interface, collision detection moves entirely into +Firestore, decoupled from the provider: + +- Before any provider write, atomically claim `(experimentId, filenameHash)` in a + Firestore transaction. A failed claim means "duplicate filename" — no + provider round-trip needed to know that. +- **Retention-safe by design**: the claim stores a *salted hash* of the filename + (salt is per-experiment, generated once, kept indefinitely — a nonce, not + "file information"), never the raw filename. This keeps the "we don't retain + your file information" promise intact. +- **Cost-bounded by design**: claim records carry a TTL and expire after an + experiment goes inactive (proposed: ~90 days with no new submissions), so + storage stays bounded to currently-active studies rather than growing forever + across DataPipe's entire history. +- **Nothing is actually lost on expiry.** The claim-set is a cache over the + provider's own file listing, which remains the durable source of truth. If a + researcher resumes data collection on an experiment whose claim-set has + expired, DataPipe detects the cold cache, calls the adapter's `listFiles` + against the live container, hashes each returned filename with the + experiment's (permanently retained) salt, bulk-writes fresh claims with a new + TTL, then proceeds with the normal claim-and-write for the incoming + submission. This only costs anything for experiments that actually get + reactivated — the common case (experiment finishes, goes cold, stays cold) + never pays the rehydration cost. +- Edge cases to handle explicitly: rehydration should fail loudly (prompt the + researcher to reconnect) if the provider container is missing or access was + revoked, rather than silently accepting duplicates; large containers need + paginated listing. + +This also incidentally resolves a latent bug in the current OSF code, where +`metadata-block.ts` checks for a success status code (`210`) that +`putFileOSF` never actually returns — that check disappears along with the +code path it lives in. + +### Metadata-file tracking + +Store the provider-returned file ref (id/path/rev) on the experiment's Firestore +`metadata/{experimentID}` doc after first creation. Every later update reads +that ref back directly — no more per-adapter "list the folder and look for a +matching name" logic, which today only exists because OSF is queried as the +live source of truth for this check. + +### Data model + +``` +experiments/{id}: { + storageProvider: 'gdrive' | 'figshare' | 'dataverse' | 'osf', // 'osf' = legacy + providerContainer: { ...shape varies by storageProvider... }, + metadataFileRef: {...} | null, + collisionCache: { salt, warmUntil: Timestamp }, +} + +users/{uid}: { + connectedAccounts: { + gdrive?: { authMethod: 'oauth2', encryptedToken, encryptedRefreshToken, tokenExpiresAt, providerAccountId }, + figshare?: { authMethod: 'oauth2', ...same shape... }, + dataverse?: { authMethod: 'static-token', encryptedToken, serverUrl }, + } +} +``` + +`crypto-utils.ts` (AES-256-GCM) and `generate-oauth-state.ts` (CSRF state +handling) carry over unchanged — already provider-agnostic. + +### Per-provider adapter notes + +| | Google Drive | Figshare | Dataverse | +|---|---|---|---| +| Auth | OAuth2, `drive.file` scope | OAuth2, `authorization_code` + `refresh_token` | Static API token — same shape as today's OSF PAT fallback (`usingPersonalToken`) | +| Container | Subfolder under a researcher-picked parent folder | Article inside a Project (two levels only) | Dataset inside a Collection | +| Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | +| Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher | +| DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | +| Needs a pre-build spike | No | Confirm actual upload conflict behavior empirically (docs are silent/unconfirmed) | Confirm and handle the silent-rename response explicitly, even though Firestore is the real collision gate | + +### OAuth generalization + +Replace today's 4–5 near-duplicated OSF-auth-URL-building blocks and single +OSF-hardcoded `oauth2-callback` function with a small provider registry +(`{authorizeUrl, tokenUrl, clientId, clientSecret, scope}` per provider) and one +generic callback function parameterized by a `provider` field carried in the +existing CSRF state payload. + +### Frontend changes + +- `admin/new.js`: provider selector + provider-specific sub-form. +- `account/*`: collapse per-provider components (`SignUpWithOSF`, `OSFToken`, + `OAuthTokenStatus`, etc.) into one generic connect-button + token-status + component, parameterized by a small per-provider config (name, icon, docs + link). +- `ExperimentInfo.js` / `QueuePanel.js`: replace hardcoded OSF links and OSF + status-code copy with a generic error taxonomy (`RATE_LIMITED`, + `AUTH_EXPIRED`, `NAME_CONFLICT`, `UNAVAILABLE`) that each adapter maps its own + provider's errors into. +- `firestore.rules`: generalize the field whitelist to the `connectedAccounts.*` + shape; the experiment `hasAll` check becomes conditional on `storageProvider`. + +## Build sequence + +1. Define the provider interface + registry + additive Firestore schema (no + behavior change yet). +2. Refactor existing OSF code into an OSF adapter implementing the new + interface — pure refactor, proves the abstraction before adding anything new. +3. Land the Firestore collision-cache (salted hash + TTL + lazy rehydration) and + metadata-ref tracking for the OSF adapter first, while there's still only one + provider to reason about. +4. Google Drive adapter (simplest OAuth2, most rate-limit headroom, proves the + multi-provider auth flow end-to-end). +5. Figshare adapter (after the upload-conflict-behavior spike). +6. Dataverse adapter (static-token path, "stays in draft" publish workflow). +7. Frontend: provider selection UI, generalized connect/status components, + generalized dashboard links and error copy. +8. FAQ/docs, `firestore.rules`, env config for new provider client + IDs/secrets. + +## Open questions / spikes before implementation + +- Empirically verify Figshare's upload behavior on a duplicate filename. +- Confirm Dataverse's exact response shape on a silent rename, so DataPipe can + detect and surface it rather than trust the returned filename blindly. +- Decide the exact collision-cache TTL window (90 days proposed, not yet + validated against real usage patterns). +- Decide the UX for Dataverse's federated `serverUrl` requirement (does + DataPipe maintain a picker of known installations, or require researchers to + paste their institution's Dataverse URL?). From ab334cda4450f29dc47e5afe4f1fbbc9367f2de8 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 21 Jul 2026 14:27:44 -0400 Subject: [PATCH 027/181] docs: harden provider-migration design after review - Make Figshare/Dataverse selection conditional on gating spikes, with Box as the pre-approved substitute - Scope base64/media support to all providers at launch; define WriteResult and size/quota capability hints - Add claim lifecycle (pending/confirmed) and rehydration locking to the collision cache; dual-run against OSF 409 for validation - Document auth-longevity constraints (Google verification lead time, Dataverse token expiry) and add QUOTA_EXCEEDED to the error taxonomy - Add testing strategy, build step 0 (OAuth app registration), and named disqualification criteria per spike Co-Authored-By: Claude Fable 5 --- docs/provider-migration-design.md | 173 +++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 28 deletions(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index c50d6f9..0987307 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -14,6 +14,11 @@ introducing a pluggable storage-provider abstraction, and to ship initial suppor for three concrete providers: **Google Drive, Figshare, and Dataverse**, alongside OSF kept in place as a legacy adapter for existing connected users. +Migration tooling for moving existing OSF experiments or historical data to a +new provider is **deliberately out of scope**. If OSF shuts down, researchers +start new experiments on a new provider; the legacy adapter exists so nothing +breaks before that day, not as the first step of a rescue plan. + ## Current OSF dependency (summary) A full code-level audit found DataPipe's OSF dependency concentrated in a @@ -44,9 +49,16 @@ already provider-agnostic and requiring no change. The OSF-specific surface: 1. OAuth2 delegated authorization with refresh-token rotation — researcher owns the account, DataPipe never holds a password, access must survive unattended - for the life of a study (months). -2. A file-write API (atomic create-if-absent is a nice-to-have, not a hard - requirement — see "Collision detection" below for why). + for the life of a study (months). **Deliberately relaxed for Dataverse**, + which only offers static API tokens: accepted because DataPipe already + maintains an equivalent PAT path for OSF, but Dataverse tokens expire + (commonly yearly, installation-configurable), so the unattended-for-months + constraint requires expiry-warning UX, not just token storage. +2. A file-write API that handles **binary media as well as text** — the + `/api/base64` path (audio/video recordings) is in scope for all providers + from day one, so per-provider file-size caps and quota behavior are launch + constraints, not later work. (Atomic create-if-absent is a nice-to-have, not + a hard requirement — see "Collision detection" below for why.) 3. Support for updating one mutable file per project (the metadata file). 4. Folder/path support, or a documented fallback when absent. 5. A "project + child container" shape, or a flat namespace DataPipe's model can @@ -82,11 +94,23 @@ already provider-agnostic and requiring no change. The OSF-specific surface: universities; S3 has the best region control and longest clean API history but breaks OAuth-onboarding simplicity (self-provisioned IAM). These remain reasonable fallback options but were not selected as the initial three. + **Box is the pre-approved substitute**: if a conditional provider fails its + gating spike (below), Box takes its slot without re-opening the full + evaluation. + +**Selected for initial implementation: Google Drive, Figshare, Dataverse** — +but the three are not equally confident picks. Google Drive is a comfortable +technical fit. Figshare and Dataverse come from the repository category ruled +out above; they are selected *despite* that structural mismatch because +DOI/citation support and research-repository identity are part of DataPipe's +pitch to researchers (requirement 7), and their selection is **conditional**: +each must pass its gating spike (see "Gating spikes" below) before its adapter +is built, with Box as the named substitute if it fails. If both fail, the +initial lineup is Drive + Box. -**Selected for initial implementation: Google Drive, Figshare, Dataverse.** OSF is retained as a legacy adapter — existing connected users and in-flight -studies keep working unmodified; new experiments default to one of the three -new providers. +studies keep working unmodified; new experiments default to one of the new +providers. ## Architecture @@ -104,19 +128,33 @@ StorageProvider { // one-time setup at experiment creation createDataContainer(auth, researcherInput) -> containerRef // opaque, provider-shaped - // ongoing writes - writeSessionFile(auth, containerRef, filename, data) -> WriteResult - updateFile(auth, containerRef, existingFileRef, data) -> WriteResult + // ongoing writes — `data` may be binary (base64/media path), with declared + // size and content type so adapters can enforce provider size caps up front + writeSessionFile(auth, containerRef, filename, data, {size, contentType}) -> WriteResult + updateFile(auth, containerRef, existingFileRef, data, {size, contentType}) -> WriteResult // needed for collision-cache rehydration (see below) and dashboard file counts listFiles(auth, containerRef) -> FileRef[] - capabilities: { nativeSubfolders: bool, supportsRegion: bool } + capabilities: { nativeSubfolders: bool, supportsRegion: bool, + maxFileSizeBytes: number | null, quotaNote: string | null } +} + +WriteResult { + fileRef, // provider-shaped id/path/rev + storedFilename, // the filename the provider REPORTS having stored — not + // the one requested; detecting Dataverse's silent rename + // depends on comparing the two + bytesWritten, } ``` -`capabilities` is descriptive (UI hints, subfolder fallback behavior), not a -correctness gate — provider-side atomicity is no longer load-bearing (see below). +`capabilities` is descriptive (UI hints, subfolder fallback behavior, size-cap +warnings), not a correctness gate — provider-side atomicity is no longer +load-bearing (see below). Note one contract caveat: Figshare has no in-place +file update, so its `updateFile` is implemented as delete + re-upload and is +**non-atomic** — there is a brief window where the metadata file does not exist +in the article. Callers of `updateFile` must tolerate that. ### Collision detection: a cache, not a new system of record @@ -129,6 +167,15 @@ Firestore, decoupled from the provider: - Before any provider write, atomically claim `(experimentId, filenameHash)` in a Firestore transaction. A failed claim means "duplicate filename" — no provider round-trip needed to know that. +- **Claims have a lifecycle, not just existence.** A claim is written as + `pending` with an idempotency token owned by the submitting request, and + flipped to `confirmed` only after the provider write succeeds. The upload + retry queue (`queue-upload.ts` / `scheduled-upload-retry.ts`) re-enters its + own `pending` claim by token rather than being rejected as a duplicate of + itself; a terminally failed write releases its claim (or a new request + bearing no token may overwrite a stale `pending` claim past a timeout). + Without this, a failed provider write orphans the claim and a legitimate + resubmission of that filename is blocked until TTL expiry (~90 days). - **Retention-safe by design**: the claim stores a *salted hash* of the filename (salt is per-experiment, generated once, kept indefinitely — a nonce, not "file information"), never the raw filename. This keeps the "we don't retain @@ -147,10 +194,18 @@ Firestore, decoupled from the provider: submission. This only costs anything for experiments that actually get reactivated — the common case (experiment finishes, goes cold, stays cold) never pays the rehydration cost. -- Edge cases to handle explicitly: rehydration should fail loudly (prompt the +- Edge cases to handle explicitly: rehydration needs a **per-experiment lock** + — concurrent submissions arriving at a cold cache must wait/retry against a + single in-flight rehydration rather than each triggering their own + `listFiles` + bulk claim-write; rehydration should fail loudly (prompt the researcher to reconnect) if the provider container is missing or access was revoked, rather than silently accepting duplicates; large containers need paginated listing. +- **Validation before it matters**: while OSF is still the only provider + (build step 3), dual-run — keep OSF's `409` response as a backstop and log + any disagreement between it and the Firestore cache. That checks the cache + against production ground truth for free, before any provider that *has* no + backstop ships. This also incidentally resolves a latent bug in the current OSF code, where `metadata-block.ts` checks for a success status code (`210`) that @@ -179,7 +234,8 @@ users/{uid}: { connectedAccounts: { gdrive?: { authMethod: 'oauth2', encryptedToken, encryptedRefreshToken, tokenExpiresAt, providerAccountId }, figshare?: { authMethod: 'oauth2', ...same shape... }, - dataverse?: { authMethod: 'static-token', encryptedToken, serverUrl }, + dataverse?: { authMethod: 'static-token', encryptedToken, serverUrl, + tokenExpiresAt }, // Dataverse tokens expire (~yearly) — needed for expiry-warning UX } } ``` @@ -192,11 +248,13 @@ handling) carry over unchanged — already provider-agnostic. | | Google Drive | Figshare | Dataverse | |---|---|---|---| | Auth | OAuth2, `drive.file` scope | OAuth2, `authorization_code` + `refresh_token` | Static API token — same shape as today's OSF PAT fallback (`usingPersonalToken`) | -| Container | Subfolder under a researcher-picked parent folder | Article inside a Project (two levels only) | Dataset inside a Collection | +| Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire** (commonly yearly, installation-configurable) — needs expiry-warning UX, not just storage | +| Container | **App-created "DataPipe" folder at Drive root.** Under `drive.file` the app can only touch files it created or the user explicitly picked — a researcher-picked parent would force a Google Picker frontend integration for little gain. Revisit only if researchers demand placement control | Article inside a Project (two levels only) | Dataset inside a Collection | | Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | -| Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher | +| Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case | Per-file and total-quota caps on the free tier; upload is a multi-step multipart flow (initiate → parts → complete) with correspondingly more failure modes; no in-place update (delete + re-upload) | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed, which transforms presentation and extends dataset locking — suppression support is version-dependent | +| Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher, and DataPipe integrates whatever software version each installation runs (version drift is a permanent fact of this adapter) | | DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | -| Needs a pre-build spike | No | Confirm actual upload conflict behavior empirically (docs are silent/unconfirmed) | Confirm and handle the silent-rename response explicitly, even though Firestore is the real collision gate | +| Gating spike | None (comfortable fit) — but OAuth app verification has **weeks of lead time**; start it at build step 0 | See "Gating spikes" below — duplicate-filename behavior, per-item **file-count cap** (historically ~500 files/item; a semester-long study can exceed it), multipart burst behavior | See "Gating spikes" below — **dataset locking under concurrent adds** (most likely disqualifier in the plan), tabular-ingest suppression, silent-rename response shape | ### OAuth generalization @@ -206,6 +264,15 @@ OSF-hardcoded `oauth2-callback` function with a small provider registry generic callback function parameterized by a `provider` field carried in the existing CSRF state payload. +The generalization also covers the OSF-shaped background jobs: + +- `scheduled-token-refresh.ts` becomes per-provider — each OAuth2 provider gets + its own refresh cadence, and Dataverse (no refresh token to rotate) gets an + **expiry-warning** job instead, emailing/flagging the researcher before the + static token lapses mid-study. +- `on-user-deleted.ts` cleanup iterates the `connectedAccounts.*` map rather + than assuming a single OSF token shape. + ### Frontend changes - `admin/new.js`: provider selector + provider-specific sub-form. @@ -215,36 +282,86 @@ existing CSRF state payload. link). - `ExperimentInfo.js` / `QueuePanel.js`: replace hardcoded OSF links and OSF status-code copy with a generic error taxonomy (`RATE_LIMITED`, - `AUTH_EXPIRED`, `NAME_CONFLICT`, `UNAVAILABLE`) that each adapter maps its own - provider's errors into. + `AUTH_EXPIRED`, `NAME_CONFLICT`, `QUOTA_EXCEEDED`, `UNAVAILABLE`) that each + adapter maps its own provider's errors into. With media in scope from day + one, `QUOTA_EXCEEDED` (storage full / file too large) is the most likely + researcher-visible failure and needs first-class copy, not a generic error. - `firestore.rules`: generalize the field whitelist to the `connectedAccounts.*` shape; the experiment `hasAll` check becomes conditional on `storageProvider`. ## Build sequence +0. **Register provider OAuth apps and start Google's verification process + immediately** — publication/brand verification has weeks of lead time, and + until it completes, Drive refresh tokens last 7 days and the app is capped + at 100 users. This runs in parallel with everything below. 1. Define the provider interface + registry + additive Firestore schema (no behavior change yet). 2. Refactor existing OSF code into an OSF adapter implementing the new interface — pure refactor, proves the abstraction before adding anything new. -3. Land the Firestore collision-cache (salted hash + TTL + lazy rehydration) and - metadata-ref tracking for the OSF adapter first, while there's still only one - provider to reason about. +3. Land the Firestore collision-cache (salted hash + claim lifecycle + TTL + + lazy rehydration) and metadata-ref tracking for the OSF adapter first, while + there's still only one provider to reason about. **Dual-run**: keep OSF's + `409` as a backstop and log cache/backstop disagreements as free production + validation. 4. Google Drive adapter (simplest OAuth2, most rate-limit headroom, proves the multi-provider auth flow end-to-end). -5. Figshare adapter (after the upload-conflict-behavior spike). -6. Dataverse adapter (static-token path, "stays in draft" publish workflow). +5. **Gate: Figshare spike** (see below). Pass → Figshare adapter. Fail → Box + adapter takes the slot. +6. **Gate: Dataverse spike** (see below). Pass → Dataverse adapter + (static-token path, "stays in draft" publish workflow). Fail → Box (or, if + Box already replaced Figshare, ship two providers and revisit). 7. Frontend: provider selection UI, generalized connect/status components, generalized dashboard links and error copy. 8. FAQ/docs, `firestore.rules`, env config for new provider client IDs/secrets. -## Open questions / spikes before implementation +## Testing strategy + +- Extend `mock-server.ts` per provider and mirror the existing emulator-based + test suite (`__tests__/*-emulator.test.js`) for each adapter — same coverage + bar as the OSF path has today, including the base64/media path. +- Live smoke checks: `demo.dataverse.org` for Dataverse; Figshare has no real + sandbox, so a dedicated throwaway account; a dedicated test Google account + for Drive. +- Spikes run against real services with throwaway accounts; their findings get + recorded back into this doc (adapter-notes table) when complete. + +## Gating spikes (go/no-go before the adapter is built) + +These are decision gates, not confirmations. Each has a named disqualification +criterion and Box is the pre-approved substitute — a failed spike swaps the +provider, it does not trigger a redesign. + +- **Dataverse — concurrent-write locking.** Dataverse locks a dataset during + file add/ingest, and concurrent adds to a locked dataset fail. The burst + requirement writes 30–100 files to *one* dataset within a minute; the retry + queue softens this, but if the spike shows writes serialize through a lock at + a rate that can't absorb a class section, that is disqualifying. This is the + single most likely spike to fail in the plan. +- **Dataverse — tabular ingest.** Confirm CSV ingest-into-`.tab` can be + suppressed on the installations researchers actually use (suppression is + version-dependent, and federation means DataPipe doesn't choose the version). +- **Dataverse — silent rename.** Confirm the exact response shape on a + duplicate filename so DataPipe compares `storedFilename` against the request + rather than trusting it blindly. +- **Figshare — duplicate filename behavior.** Docs are silent; verify + empirically. +- **Figshare — per-item file-count cap.** Historically ~500 files/item; a + semester of sessions can exceed it. Determine the real limit and whether + article-rollover (a new article per N files) is acceptable; if the cap is low + and rollover unacceptable, that is disqualifying. +- **Figshare — multipart upload under burst.** The initiate → parts → complete + flow has more failure modes than a single PUT; verify behavior under + concurrent submissions, including media-sized files. + +## Open questions -- Empirically verify Figshare's upload behavior on a duplicate filename. -- Confirm Dataverse's exact response shape on a silent rename, so DataPipe can - detect and surface it rather than trust the returned filename blindly. - Decide the exact collision-cache TTL window (90 days proposed, not yet validated against real usage patterns). - Decide the UX for Dataverse's federated `serverUrl` requirement (does DataPipe maintain a picker of known installations, or require researchers to paste their institution's Dataverse URL?). +- Do researchers need placement control for the Drive folder strongly enough + to justify a Google Picker integration, or is the app-created root folder + acceptable? (Default answer: root folder; revisit on demand.) From e7fc79292640b3085711c8dd274cc01e11252647 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 17:17:31 -0400 Subject: [PATCH 028/181] feat: add storage-provider interface, registry, and additive schema types Build step 1 of the provider migration (docs/provider-migration-design.md): - StorageProvider interface with WriteResult union, generic error taxonomy, and capability hints - provider registry (register/get/list, test-only reset) - additive optional fields on ExperimentData and UserData; legacy OSF fields unchanged - promote typescript to a direct devDependency of functions/ (was only available transitively via @jspsych/metadata) No behavior change; nothing consumes the interface yet. Co-Authored-By: Claude Fable 5 --- functions/package-lock.json | 3 +- functions/package.json | 3 +- .../src/__tests__/providers-registry.test.js | 66 ++++++++ functions/src/interfaces.ts | 21 ++- functions/src/providers/registry.ts | 27 ++++ functions/src/providers/types.ts | 149 ++++++++++++++++++ package-lock.json | 20 +-- 7 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 functions/src/__tests__/providers-registry.test.js create mode 100644 functions/src/providers/registry.ts create mode 100644 functions/src/providers/types.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index 730d804..a89b634 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -20,7 +20,8 @@ "devDependencies": { "@types/archiver": "^7.0.0", "@types/is-base64": "^1.1.3", - "firebase-functions-test": "^3.4.1" + "firebase-functions-test": "^3.4.1", + "typescript": "^5.5.2" }, "engines": { "node": "22" diff --git a/functions/package.json b/functions/package.json index 812883a..55da299 100644 --- a/functions/package.json +++ b/functions/package.json @@ -31,7 +31,8 @@ "devDependencies": { "@types/archiver": "^7.0.0", "@types/is-base64": "^1.1.3", - "firebase-functions-test": "^3.4.1" + "firebase-functions-test": "^3.4.1", + "typescript": "^5.5.2" }, "private": true } diff --git a/functions/src/__tests__/providers-registry.test.js b/functions/src/__tests__/providers-registry.test.js new file mode 100644 index 0000000..a83c874 --- /dev/null +++ b/functions/src/__tests__/providers-registry.test.js @@ -0,0 +1,66 @@ +import { + registerProvider, + getProvider, + listProviders, + clearProvidersForTesting, +} from "../../lib/providers/registry.js"; + +function makeFakeProvider(id) { + return { + id, + authMethod: "static-token", + capabilities: { + nativeSubfolders: false, + supportsRegion: false, + maxFileSizeBytes: null, + quotaNote: null, + }, + createDataContainer: async () => ({ provider: id }), + writeSessionFile: async () => ({ + success: true, + fileRef: { name: "test" }, + storedFilename: "test", + }), + updateFile: async () => ({ + success: true, + fileRef: { name: "test" }, + storedFilename: "test", + }), + listFiles: async () => [], + }; +} + +describe("providers registry", () => { + beforeEach(() => { + clearProvidersForTesting(); + }); + + it("returns a registered provider from getProvider", () => { + const provider = makeFakeProvider("osf"); + + registerProvider(provider); + + expect(getProvider("osf")).toBe(provider); + }); + + it("throws when getProvider is called with an unknown id", () => { + expect(() => getProvider("gdrive")).toThrow("Unknown storage provider: gdrive"); + }); + + it("throws when registering a duplicate id", () => { + registerProvider(makeFakeProvider("figshare")); + + expect(() => registerProvider(makeFakeProvider("figshare"))).toThrow( + "Storage provider already registered: figshare" + ); + }); + + it("reflects registrations in listProviders", () => { + expect(listProviders()).toEqual([]); + + registerProvider(makeFakeProvider("osf")); + registerProvider(makeFakeProvider("dataverse")); + + expect(listProviders()).toEqual(["osf", "dataverse"]); + }); +}); diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index 5dfd2d5..0a85d7c 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -1,3 +1,11 @@ +import { + StorageProviderId, + ContainerRef, + FileRef, + CollisionCacheState, + ConnectedAccounts, + } from './providers/types'; + export interface ExperimentData { active: boolean; activeBase64: boolean; @@ -14,8 +22,13 @@ export interface ExperimentData { requiredFields: string[]; owner: string; osfFilesLink: string; + // Provider-migration fields (additive; absent = legacy OSF experiment). + storageProvider?: StorageProviderId; + providerContainer?: ContainerRef; + metadataFileRef?: FileRef | null; + collisionCache?: CollisionCacheState; } - + export interface UserData { email: string; uid: string; @@ -24,10 +37,12 @@ export interface ExperimentData { experiments: string[]; usingPersonalToken: boolean; refreshToken: string; - refreshTokenExpires: number; + refreshTokenExpires: number; authToken: string; authTokenExpires: number; - } + // Provider-migration field (additive; legacy OSF fields above stay as-is). + connectedAccounts?: ConnectedAccounts; + } export interface RequestBody { experimentID: string; diff --git a/functions/src/providers/registry.ts b/functions/src/providers/registry.ts new file mode 100644 index 0000000..af85e3c --- /dev/null +++ b/functions/src/providers/registry.ts @@ -0,0 +1,27 @@ +import { StorageProvider, StorageProviderId } from "./types.js"; + +const providers = new Map(); + +export function registerProvider(provider: StorageProvider): void { + if (providers.has(provider.id)) { + throw new Error(`Storage provider already registered: ${provider.id}`); + } + providers.set(provider.id, provider); +} + +export function getProvider(id: StorageProviderId): StorageProvider { + const provider = providers.get(id); + if (!provider) { + throw new Error(`Unknown storage provider: ${id}`); + } + return provider; +} + +export function listProviders(): StorageProviderId[] { + return [...providers.keys()]; +} + +// For tests only — production code never unregisters a provider. +export function clearProvidersForTesting(): void { + providers.clear(); +} diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts new file mode 100644 index 0000000..a649ad2 --- /dev/null +++ b/functions/src/providers/types.ts @@ -0,0 +1,149 @@ +// Storage-provider abstraction (docs/provider-migration-design.md). +// Nothing imports these types yet except the registry; adapters arrive in +// later build steps, starting with the OSF refactor. + +export type StorageProviderId = "osf" | "gdrive" | "figshare" | "dataverse"; + +export type AuthMethod = "oauth2" | "static-token"; + +// Generic error taxonomy that every adapter maps its provider's errors into. +// QUOTA_EXCEEDED covers both storage-full and file-too-large. +export type ProviderErrorCode = + | "RATE_LIMITED" + | "AUTH_EXPIRED" + | "NAME_CONFLICT" + | "QUOTA_EXCEEDED" + | "UNAVAILABLE"; + +// A resolved, decrypted credential handed to adapter calls. serverUrl is only +// present for federated providers (Dataverse). +export interface ResolvedAuth { + token: string; + serverUrl?: string; +} + +// Opaque, provider-shaped reference to the container an experiment writes +// into (OSF component, Drive folder, Figshare article, Dataverse dataset). +// Only the owning adapter interprets fields beyond `provider`. +export interface ContainerRef { + provider: StorageProviderId; + [key: string]: unknown; +} + +export interface FileRef { + name: string; + id?: string; + path?: string; + rev?: string; +} + +export interface FileMeta { + size: number; + contentType: string; +} + +export type WriteResult = + | { + success: true; + fileRef: FileRef; + // The filename the provider REPORTS having stored — callers compare it + // against the requested name to detect silent renames (Dataverse). + storedFilename: string; + } + | { + success: false; + error: ProviderErrorCode; + // Raw provider response, preserved for logs and the retry queue. + providerStatus: number | null; + providerMessage: string | null; + retryAfter?: number | null; + }; + +// Descriptive (UI hints, subfolder fallback, size-cap warnings) — never a +// correctness gate. Collision detection lives in Firestore, not here. +export interface ProviderCapabilities { + nativeSubfolders: boolean; + supportsRegion: boolean; + maxFileSizeBytes: number | null; + quotaNote: string | null; +} + +export interface OAuthEndpointConfig { + authorizeUrl: string; + tokenUrl: string; + clientId: string; + clientSecret: string; + scope: string; +} + +export interface StorageProvider { + id: StorageProviderId; + authMethod: AuthMethod; + capabilities: ProviderCapabilities; + + // oauth2 providers only + oauth?: OAuthEndpointConfig; + + // static-token providers only + validateStaticToken?(auth: ResolvedAuth): Promise; + + // One-time setup at experiment creation. researcherInput is provider-shaped + // (e.g. parent project for Figshare, collection + serverUrl for Dataverse). + createDataContainer( + auth: ResolvedAuth, + researcherInput: Record + ): Promise; + + writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + meta: FileMeta + ): Promise; + + // Figshare has no in-place update: its adapter implements this as + // delete + re-upload, so callers must tolerate a non-atomic window. + updateFile( + auth: ResolvedAuth, + container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + meta: FileMeta + ): Promise; + + // Full listing (adapters paginate internally). Used for collision-cache + // rehydration and dashboard file counts. + listFiles(auth: ResolvedAuth, container: ContainerRef): Promise; +} + +// users/{uid}.connectedAccounts.* shapes (additive Firestore schema). +export interface OAuth2AccountConnection { + authMethod: "oauth2"; + encryptedToken: string; + encryptedRefreshToken: string; + tokenExpiresAt: number; + providerAccountId?: string; +} + +export interface StaticTokenAccountConnection { + authMethod: "static-token"; + encryptedToken: string; + serverUrl: string; + // Dataverse tokens expire (~yearly); drives the expiry-warning job. + tokenExpiresAt?: number; +} + +export interface ConnectedAccounts { + gdrive?: OAuth2AccountConnection; + figshare?: OAuth2AccountConnection; + dataverse?: StaticTokenAccountConnection; +} + +// experiments/{id}.collisionCache (additive Firestore schema). The salt is a +// per-experiment nonce retained indefinitely; claims themselves live in a +// subcollection keyed by salted filename hash and expire via TTL. +export interface CollisionCacheState { + salt: string; + warmUntil: FirebaseFirestore.Timestamp; +} diff --git a/package-lock.json b/package-lock.json index c6620b3..d7b7337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5180,7 +5180,6 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5220,7 +5219,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5241,7 +5239,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5262,7 +5259,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5283,7 +5279,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5304,7 +5299,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5325,7 +5319,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5346,7 +5339,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5367,7 +5359,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5388,7 +5379,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5409,7 +5399,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5430,7 +5419,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5451,7 +5439,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5472,7 +5459,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5490,7 +5476,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -13694,7 +13679,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13759,7 +13744,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -17788,7 +17773,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true }, From f6304d8df678bea5854afcdfa80085696f7a0997 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 17:33:49 -0400 Subject: [PATCH 029/181] refactor: route all OSF writes through the storage-provider interface Build step 2 of the provider migration: pure refactor, no behavior change. - OSF adapter (providers/osf.ts) implementing StorageProvider by delegating to the existing put-file-osf/update-file-osf modules; maps OSF statuses into the generic error taxonomy - getProviderForExperiment() resolves the adapter + container ref, with legacy experiments (no storageProvider field) defaulting to OSF - api-data, api-base64, metadata-block, scheduled-upload-retry switched to the interface; response codes, queue fields, and log strings unchanged - preserves the latent 210 status check in metadata-block verbatim (removed in build step 3 per the design doc) - 6 new adapter unit tests; full emulator suite green (18 suites, 114 tests) with no existing test modified Co-Authored-By: Claude Fable 5 --- functions/src/__tests__/providers-osf.test.js | 166 ++++++++++++++++++ functions/src/api-base64.ts | 28 +-- functions/src/api-data.ts | 28 +-- functions/src/metadata-block.ts | 47 ++--- functions/src/providers/index.ts | 29 +++ functions/src/providers/osf.ts | 117 ++++++++++++ functions/src/scheduled-upload-retry.ts | 15 +- 7 files changed, 381 insertions(+), 49 deletions(-) create mode 100644 functions/src/__tests__/providers-osf.test.js create mode 100644 functions/src/providers/index.ts create mode 100644 functions/src/providers/osf.ts diff --git a/functions/src/__tests__/providers-osf.test.js b/functions/src/__tests__/providers-osf.test.js new file mode 100644 index 0000000..1dd4476 --- /dev/null +++ b/functions/src/__tests__/providers-osf.test.js @@ -0,0 +1,166 @@ +// osfProvider delegates its writes to put-file-osf.js / update-file-osf.js, +// which both import their own `fetch` from the "node-fetch" package rather +// than using the global fetch. Mocking global.fetch (the pattern used by +// metadata-process.test.js) would have no effect on this code path, since +// node-fetch's fetch is a distinct implementation from globalThis.fetch. +// We mock the "node-fetch" module itself instead. +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { osfProvider } from "../../lib/providers/osf.js"; + +function mockResponse({ status, statusText, retryAfter = null }) { + return { + status, + statusText, + headers: { + get: (header) => (header === "Retry-After" ? retryAfter : null), + }, + }; +} + +const auth = { token: "test-token" }; +const container = { provider: "osf", filesLink: "https://osf.io/abc123/" }; + +describe("osfProvider.writeSessionFile", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("maps a 201 response to a success WriteResult", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, statusText: "Created" })); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "file.json" }, + storedFilename: "file.json", + }); + }); + + it("maps a 409 response to NAME_CONFLICT", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 409, statusText: "Conflict" })); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "NAME_CONFLICT", + providerStatus: 409, + providerMessage: "Conflict", + retryAfter: null, + }); + }); + + it("maps a 429 response to RATE_LIMITED and passes through retryAfter", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 429, statusText: "Too Many Requests", retryAfter: "30" }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 429, + providerMessage: "Too Many Requests", + retryAfter: 30, + }); + }); + + it("maps a 401 response to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + retryAfter: null, + }); + }); + + it("maps a 500 response to UNAVAILABLE and preserves providerStatus/providerMessage", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 500, statusText: "Internal Server Error" }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 500, + providerMessage: "Internal Server Error", + retryAfter: null, + }); + }); +}); + +describe("osfProvider.listFiles", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("returns name/id pairs and filters out folder entries", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + data: [ + { attributes: { name: "data.json", kind: "file" }, id: "osfstorage/111" }, + { attributes: { name: "subfolder", kind: "folder" }, id: "osfstorage/222" }, + { attributes: { name: "dataset_description.json", kind: "file" }, id: "osfstorage/333" }, + ], + }), + }); + + const result = await osfProvider.listFiles(auth, container); + + expect(result).toEqual([ + { name: "data.json", id: "osfstorage/111" }, + { name: "dataset_description.json", id: "osfstorage/333" }, + ]); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + "https://osf.io/abc123/?meta=", + expect.objectContaining({ method: "GET" }) + ); + }); +}); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 46173d7..901c256 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -1,6 +1,5 @@ import { onRequest } from "firebase-functions/v2/https"; import { DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; -import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; import writeLog from "./write-log.js"; import isBase64 from "is-base64"; @@ -8,7 +7,9 @@ import MESSAGES from "./api-messages.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { ExperimentData, UserData, OSFResult } from './interfaces'; +import { getProviderForExperiment } from "./providers/index.js"; +import { WriteResult } from "./providers/types.js"; +import { ExperimentData, UserData } from './interfaces'; export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { const { experimentID, data, filename } = req.body; @@ -112,13 +113,16 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: const token = tokenResult.token; - let result: OSFResult; + const { provider, container } = getProviderForExperiment(exp_data); + + let result: WriteResult; try { - result = await putFileOSF( - exp_data.osfFilesLink, - token, + result = await provider.writeSessionFile( + { token }, + container, + filename, buffer, - filename + { size: buffer.length, contentType: "application/octet-stream" } ); } catch (e) { // Network errors, timeouts, etc. — queue for retry @@ -142,7 +146,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: } if (!result.success) { - if (result.errorCode === 409 && result.errorText === "Conflict") { + if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { res.status(400).json(MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); return; @@ -152,16 +156,16 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, - errorCode: result.errorCode || 0, sessionIncremented: false, - failureReason: `OSF error ${result.errorCode}: ${result.errorText}`, + errorCode: result.providerStatus || 0, sessionIncremented: false, + failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } catch { res.status(400).json(MESSAGES.OSF_UPLOAD_ERROR); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } } diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 3c14c3a..df7b357 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -2,7 +2,6 @@ import { onRequest } from "firebase-functions/v2/https"; import { FieldValue, DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; import validateJSON from "./validate-json.js"; import validateCSV from "./validate-csv.js"; -import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; @@ -10,7 +9,9 @@ import blockMetadata from "./metadata-block.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { ExperimentData, UserData, MetadataResponse, OSFResult, RequestBody } from './interfaces'; +import { getProviderForExperiment } from "./providers/index.js"; +import { WriteResult } from "./providers/types.js"; +import { ExperimentData, UserData, MetadataResponse, RequestBody } from './interfaces'; export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { const { experimentID, data, filename, metadataOptions }: RequestBody = req.body; @@ -145,13 +146,16 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //METADATA BLOCK END - let result: OSFResult; + const { provider, container } = getProviderForExperiment(exp_data); + + let result: WriteResult; try { - result = await putFileOSF( - exp_data.osfFilesLink, - token, + result = await provider.writeSessionFile( + { token }, + container, + filename, data, - filename + { size: Buffer.byteLength(data), contentType: "application/json" } ); } catch (e) { // Network errors, timeouts, etc. — queue for retry @@ -176,7 +180,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 } if (!result.success) { - if (result.errorCode === 409 && result.errorText === "Conflict") { + if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); return; @@ -186,17 +190,17 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, - errorCode: result.errorCode || 0, sessionIncremented: true, - failureReason: `OSF error ${result.errorCode}: ${result.errorText}`, + errorCode: result.providerStatus || 0, sessionIncremented: true, + failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } catch { res.status(400).json({...MESSAGES.OSF_UPLOAD_ERROR, metadataMessage}); - await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.errorCode, osfStatusText: result.errorText}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; } } diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index d607c3e..a57fff2 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -2,13 +2,12 @@ import MESSAGES from "./api-messages.js"; import processMetadata from "./metadata-process.js"; import updateMetadata from "./metadata-update.js"; import produceMetadata from "./metadata-production.js"; -import updateFileOSF from "./update-file-osf.js"; import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; -import putFileOSF from "./put-file-osf.js"; import { db } from "./app.js"; import { decrypt } from "./crypto-utils.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; +import { osfProvider } from "./providers/osf.js"; import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; @@ -77,28 +76,32 @@ try { t.update(metadata_doc_ref, {metadata: updatedMetadata}); - //If a metadata file exists in OSF, it is updated with the above metadata. + //If a metadata file exists in OSF, it is updated with the above metadata. if (osfMetadataId){ - await updateFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + await osfProvider.updateFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + { id: osfMetadataId, name: "dataset_description.json" }, JSON.stringify(updatedMetadata, null, 2), - osfMetadataId + { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } ) } //If a metadata file does not exist in OSF, it is created with the above metadata. else { - const response = await putFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + const response = await osfProvider.writeSessionFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + `dataset_description.json`, JSON.stringify(updatedMetadata, null, 2), - `dataset_description.json` + { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } ); - if (response.errorCode !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); - + // Latent pre-existing bug preserved verbatim — removed in build step 3 (see design doc, "Collision detection") + const status = response.success ? null : response.providerStatus; + if (status !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); + } } //When OSF has metadata but firestore does not, updating is done with respect to OSF. @@ -119,11 +122,12 @@ try { t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); //Since metadata exists in OSF, it is updated and not set. - await updateFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + await osfProvider.updateFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + { id: osfMetadataId, name: "dataset_description.json" }, JSON.stringify(incomingMetadata, null, 2), - osfMetadataId + { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } ); } @@ -136,11 +140,12 @@ try { t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); - await putFileOSF( - exp_data.osfFilesLink, - decryptedOsfToken, + await osfProvider.writeSessionFile( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink }, + `dataset_description.json`, JSON.stringify(incomingMetadata, null, 2), - `dataset_description.json` + { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } ); } diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts new file mode 100644 index 0000000..901fafe --- /dev/null +++ b/functions/src/providers/index.ts @@ -0,0 +1,29 @@ +import { registerProvider, getProvider } from "./registry.js"; +import { osfProvider } from "./osf.js"; +import { StorageProvider, ContainerRef } from "./types.js"; +import { ExperimentData } from "../interfaces.js"; + +registerProvider(osfProvider); + +export function getProviderForExperiment(exp_data: ExperimentData): { + provider: StorageProvider; + container: ContainerRef; +} { + if (exp_data.storageProvider) { + return { + provider: getProvider(exp_data.storageProvider), + container: exp_data.providerContainer as ContainerRef, + }; + } + + // Legacy default: experiments created before the provider-migration schema + // have no storageProvider field and always write to OSF. + return { + provider: osfProvider, + container: { provider: "osf", filesLink: exp_data.osfFilesLink }, + }; +} + +export { registerProvider, getProvider } from "./registry.js"; +export { osfProvider } from "./osf.js"; +export * from "./types.js"; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts new file mode 100644 index 0000000..fabcfad --- /dev/null +++ b/functions/src/providers/osf.ts @@ -0,0 +1,117 @@ +import fetch from "node-fetch"; +import putFileOSF from "../put-file-osf.js"; +import updateFileOSF from "../update-file-osf.js"; +import { OSFFile } from "../interfaces.js"; +import { + StorageProvider, + ResolvedAuth, + ContainerRef, + FileRef, + FileMeta, + WriteResult, + ProviderErrorCode, +} from "./types.js"; + +// The OSF container ref shape — only the filesLink is meaningful to this adapter. +export interface OSFContainerRef extends ContainerRef { + provider: "osf"; + filesLink: string; +} + +function mapStatus(errorCode: number | null): ProviderErrorCode { + switch (errorCode) { + case 409: + return "NAME_CONFLICT"; + case 401: + case 403: + return "AUTH_EXPIRED"; + case 429: + return "RATE_LIMITED"; + case 507: + return "QUOTA_EXCEEDED"; + default: + return "UNAVAILABLE"; + } +} + +export const osfProvider: StorageProvider = { + id: "osf", + authMethod: "oauth2", + capabilities: { + nativeSubfolders: true, + supportsRegion: true, + maxFileSizeBytes: null, + quotaNote: null, + }, + + async createDataContainer(): Promise { + throw new Error("osfProvider.createDataContainer is not implemented"); + }, + + async writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + _meta: FileMeta + ): Promise { + const osfContainer = container as OSFContainerRef; + + const result = await putFileOSF(osfContainer.filesLink, auth.token, data, filename); + + if (result.success) { + return { + success: true, + fileRef: { name: filename }, + storedFilename: filename, + }; + } + + return { + success: false, + error: mapStatus(result.errorCode), + providerStatus: result.errorCode, + providerMessage: result.errorText, + retryAfter: result.retryAfter, + }; + }, + + async updateFile( + auth: ResolvedAuth, + container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + _meta: FileMeta + ): Promise { + const osfContainer = container as OSFContainerRef; + + // updateFileOSF throws on non-200 responses — let the throw propagate, + // callers rely on this behavior. + await updateFileOSF(osfContainer.filesLink, auth.token, data as string, existingFileRef.id as string); + + return { + success: true, + fileRef: existingFileRef, + storedFilename: existingFileRef.name, + }; + }, + + async listFiles(auth: ResolvedAuth, container: ContainerRef): Promise { + const osfContainer = container as OSFContainerRef; + + const osfResult = await fetch(`${osfContainer.filesLink}?meta=`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + }); + + const folder = (await osfResult.json()) as { data: OSFFile[] }; + const listOfFiles: OSFFile[] = folder["data"]; + + return listOfFiles + .filter((file) => file.attributes.kind === "file") + .map((file) => ({ name: file.attributes.name, id: file.id })); + }, +}; diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index e26b97d..1aca84b 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -1,7 +1,7 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; -import putFileOSF from "./put-file-osf.js"; +import { osfProvider } from "./providers/osf.js"; import resolveToken from "./resolve-token.js"; import { ExperimentData, UserData } from "./interfaces.js"; @@ -135,7 +135,14 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho // Attempt the upload try { - const result = await putFileOSF(data.osfFilesLink, token, fileData, data.filename); + const container = { provider: "osf" as const, filesLink: data.osfFilesLink }; + const result = await osfProvider.writeSessionFile( + { token }, + container, + data.filename, + fileData, + { size: Buffer.byteLength(fileData), contentType: "application/json" } + ); if (result.success) { await markCompleted(docRef, data); @@ -143,14 +150,14 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho return; } - if (result.errorCode === 409) { + if (result.error === "NAME_CONFLICT") { // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); return; } - await handleRetryFailure(docRef, data, `OSF error ${result.errorCode}: ${result.errorText}`, result.retryAfter); + await handleRetryFailure(docRef, data, `OSF error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await handleRetryFailure(docRef, data, `Upload exception: ${detail}`); From 0c336f873415b1f691808de06cebb743e9e4b185 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 18:13:20 -0400 Subject: [PATCH 030/181] feat: Firestore collision cache with claim lifecycle and OSF dual-run Build step 3a of the provider migration. Duplicate-filename detection now lives in Firestore, decoupled from the provider: - collision-cache.ts: salted-hash claims (raw filenames never stored) at experiments/{id}/filenameClaims/{hash} with a pending->confirmed lifecycle, owner tokens for idempotent retry re-entry, stale-pending takeover (15 min), 90-day TTL via expiresAt, and lazy rehydration from provider.listFiles behind a 60s lease that fails loudly if listing fails - api-data/api-base64 claim before every provider write; duplicates are rejected without a provider round-trip; OSF's 409 stays as a dual-run backstop with disagreements logged (collisionCacheDisagreement) - retry queue carries claimToken; retries re-enter their own pending claim; legacy queue docs without a token skip the cache - confirmClaim skips the warmUntil bump when it's already >89 days out, avoiding a second experiment-doc write per submission under burst load - early-persist test's inline mock gains a GET route (rehydration lists the container on first write); its assertions are unchanged TDD: 12 unit + 5 integration tests written and reviewed red before implementation. Full emulator suite green. Deploy note: the Firestore TTL policy on filenameClaims.expiresAt must be enabled via console/gcloud when this ships; the emulator does not enforce TTL and nothing depends on it for correctness. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/collision-cache.test.js | 430 ++++++++++++++++++ .../collision-integration-emulator.test.js | 294 ++++++++++++ .../__tests__/early-persist-emulator.test.js | 6 + functions/src/api-base64.ts | 83 +++- functions/src/api-data.ts | 85 +++- functions/src/collision-cache.ts | 305 +++++++++++++ functions/src/interfaces.ts | 4 + functions/src/providers/types.ts | 3 + functions/src/queue-upload.ts | 2 + functions/src/scheduled-upload-retry.ts | 41 +- 10 files changed, 1248 insertions(+), 5 deletions(-) create mode 100644 functions/src/__tests__/collision-cache.test.js create mode 100644 functions/src/__tests__/collision-integration-emulator.test.js create mode 100644 functions/src/collision-cache.ts diff --git a/functions/src/__tests__/collision-cache.test.js b/functions/src/__tests__/collision-cache.test.js new file mode 100644 index 0000000..e0d0257 --- /dev/null +++ b/functions/src/__tests__/collision-cache.test.js @@ -0,0 +1,430 @@ +/** + * @jest-environment node + */ + +// RED-phase unit tests for step 3a (docs/provider-migration-design.md, +// scratchpad/step3a-collision-cache-spec.md). collision-cache.ts does not +// exist yet — this whole file is expected to fail at module resolution +// until it is implemented. +// +// Style follows upload-queue.test.js: direct Firestore-emulator-backed calls +// against the module's public API, no HTTP layer involved. Every test uses +// its own freshly-generated experiment ID so tests never depend on one +// another's state or ordering. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { randomUUID, createHash } from "crypto"; + +import { + claimFilename, + confirmClaim, + releaseClaim, + CLAIM_TTL_MS, + STALE_PENDING_TAKEOVER_MS, + REHYDRATION_LEASE_MS, + CollisionCacheUnavailableError, +} from "../../lib/collision-cache.js"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +const config = { projectId: "datapipe-test" }; + +jest.setTimeout(30000); + +let db; + +beforeAll(async () => { + let app; + try { + app = getApp("collision-cache-test"); + } catch { + app = initializeApp(config, "collision-cache-test"); + } + db = getFirestore(app); +}); + +// A tolerance window for timestamp assertions — generous enough to absorb +// emulator round-trip latency without hiding a genuinely wrong duration. +const TOLERANCE_MS = 5000; + +function freshExperimentId(label) { + return `collision-${label}-${randomUUID()}`; +} + +// Mirrors the spec's hashing rule exactly: sha256hex(salt + ":" + filename). +// Recomputing it locally (rather than trusting the module) lets tests assert +// the doc ID *is* that specific value, not just "some opaque string". +function claimHash(salt, filename) { + return createHash("sha256").update(`${salt}:${filename}`).digest("hex"); +} + +async function createExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + owner: "collision-test-owner", + osfFilesLink: "https://example.invalid/files", + ...overrides, + }); +} + +// Pre-warms the cache with a known salt and a future warmUntil so tests that +// aren't *about* rehydration don't incidentally exercise it. Returns the salt +// so callers can compute expected claim-doc hashes. +async function warmExperiment(experimentID, { salt, warmUntilMs } = {}) { + const resolvedSalt = salt || randomUUID().replace(/-/g, ""); + const warmUntil = Timestamp.fromMillis(warmUntilMs ?? Date.now() + 24 * 60 * 60 * 1000); + await db + .collection("experiments") + .doc(experimentID) + .set({ collisionCache: { salt: resolvedSalt, warmUntil } }, { merge: true }); + return resolvedSalt; +} + +async function getExperimentData(experimentID) { + const snap = await db.collection("experiments").doc(experimentID).get(); + return snap.data(); +} + +function claimsCollection(experimentID) { + return db.collection("experiments").doc(experimentID).collection("filenameClaims"); +} + +async function getClaimDoc(experimentID, hash) { + return claimsCollection(experimentID).doc(hash).get(); +} + +describe("1. first claim on a cold cache", () => { + it("creates a salt, claims the filename, and never stores the raw filename anywhere in the claim doc", async () => { + const experimentID = freshExperimentId("first-claim"); + await createExperiment(experimentID); + const ownerToken = randomUUID(); + const listFilesFn = jest.fn().mockResolvedValue([]); + const filename = "data.csv"; + + const result = await claimFilename(experimentID, filename, ownerToken, listFilesFn); + + expect(result).toEqual({ claimed: true }); + + const expData = await getExperimentData(experimentID); + expect(expData.collisionCache).toBeDefined(); + expect(typeof expData.collisionCache.salt).toBe("string"); + expect(expData.collisionCache.salt.length).toBeGreaterThan(0); + + const claims = await claimsCollection(experimentID).get(); + expect(claims.docs).toHaveLength(1); + const claimDoc = claims.docs[0]; + + // The doc ID must be the salted hash, not the filename itself. + expect(claimDoc.id).not.toBe(filename); + expect(claimDoc.id).toBe(claimHash(expData.collisionCache.salt, filename)); + + // The raw filename string must not appear anywhere in the stored claim, + // including the doc ID — serialize everything and grep for it. + const serialized = JSON.stringify({ id: claimDoc.id, ...claimDoc.data() }); + expect(serialized).not.toContain(filename); + + const data = claimDoc.data(); + expect(data.status).toBe("pending"); + expect(data.ownerToken).toBe(ownerToken); + expect(data.createdAt).toBeDefined(); + expect(data.expiresAt).toBeDefined(); + }); +}); + +describe("2. duplicate claim from a different owner", () => { + it("rejects a second claim of an already-pending filename made by a different token", async () => { + const experimentID = freshExperimentId("dup-diff-token"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const filename = "dup.csv"; + + const first = await claimFilename(experimentID, filename, randomUUID(), listFilesFn); + expect(first).toEqual({ claimed: true }); + + const second = await claimFilename(experimentID, filename, randomUUID(), listFilesFn); + expect(second).toEqual({ claimed: false, reason: "duplicate" }); + + // Cache was already warm — rehydration must never have run. + expect(listFilesFn).not.toHaveBeenCalled(); + }); +}); + +describe("3. idempotent re-entry with the same owner token", () => { + it("allows the same owner token to re-claim its own pending filename without creating a second doc", async () => { + const experimentID = freshExperimentId("idempotent"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const filename = "retry.csv"; + + const first = await claimFilename(experimentID, filename, ownerToken, listFilesFn); + expect(first).toEqual({ claimed: true }); + + const second = await claimFilename(experimentID, filename, ownerToken, listFilesFn); + expect(second).toEqual({ claimed: true }); + + const claims = await claimsCollection(experimentID).get(); + expect(claims.docs).toHaveLength(1); + }); +}); + +describe("4. confirmClaim", () => { + it("marks the claim confirmed, bumps collisionCache.warmUntil forward, and blocks future claims of that filename", async () => { + const experimentID = freshExperimentId("confirm"); + await createExperiment(experimentID); + // Warm, but only a little way out — far short of CLAIM_TTL_MS — so a bump + // from confirmClaim is unambiguous and can't be confused with rehydration + // (which would also set warmUntil, but only runs on a COLD cache). + const initialWarmUntilMs = Date.now() + 5 * 60 * 1000; + const salt = await warmExperiment(experimentID, { warmUntilMs: initialWarmUntilMs }); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const filename = "confirmed.csv"; + + await claimFilename(experimentID, filename, ownerToken, listFilesFn); + expect(listFilesFn).not.toHaveBeenCalled(); // cache was warm + + const beforeConfirm = Date.now(); + await confirmClaim(experimentID, filename, ownerToken); + const afterConfirm = Date.now(); + + const expData = await getExperimentData(experimentID); + const warmUntilMs = expData.collisionCache.warmUntil.toMillis(); + expect(warmUntilMs).toBeGreaterThan(initialWarmUntilMs); + expect(warmUntilMs).toBeGreaterThanOrEqual(beforeConfirm + CLAIM_TTL_MS - TOLERANCE_MS); + expect(warmUntilMs).toBeLessThanOrEqual(afterConfirm + CLAIM_TTL_MS + TOLERANCE_MS); + + const hash = claimHash(salt, filename); + const claimSnap = await getClaimDoc(experimentID, hash); + expect(claimSnap.data().status).toBe("confirmed"); + + const later = await claimFilename(experimentID, filename, randomUUID(), listFilesFn); + expect(later).toEqual({ claimed: false, reason: "duplicate" }); + }); +}); + +describe("5. releaseClaim", () => { + it("deletes an owned pending claim so another token can claim it, and is a no-op with the wrong token", async () => { + const experimentID = freshExperimentId("release"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const otherToken = randomUUID(); + const filename = "released.csv"; + + await claimFilename(experimentID, filename, ownerToken, listFilesFn); + + // Wrong-token release must not touch the claim. + await releaseClaim(experimentID, filename, otherToken); + const stillBlocked = await claimFilename(experimentID, filename, otherToken, listFilesFn); + expect(stillBlocked).toEqual({ claimed: false, reason: "duplicate" }); + + // Correct-token release removes it, freeing the filename. + await releaseClaim(experimentID, filename, ownerToken); + const reclaimed = await claimFilename(experimentID, filename, otherToken, listFilesFn); + expect(reclaimed).toEqual({ claimed: true }); + }); +}); + +describe("6. stale pending takeover", () => { + it("lets a new token take over a pending claim whose createdAt is older than STALE_PENDING_TAKEOVER_MS", async () => { + const experimentID = freshExperimentId("stale-takeover"); + await createExperiment(experimentID); + const salt = await warmExperiment(experimentID); + const staleOwnerToken = randomUUID(); + const filename = "stale.csv"; + const hash = claimHash(salt, filename); + + const staleCreatedAt = Timestamp.fromMillis(Date.now() - STALE_PENDING_TAKEOVER_MS - 60 * 1000); + await claimsCollection(experimentID) + .doc(hash) + .set({ + status: "pending", + ownerToken: staleOwnerToken, + createdAt: staleCreatedAt, + expiresAt: Timestamp.fromMillis(Date.now() + CLAIM_TTL_MS), + }); + + const newToken = randomUUID(); + const listFilesFn = jest.fn().mockResolvedValue([]); + const result = await claimFilename(experimentID, filename, newToken, listFilesFn); + + expect(result).toEqual({ claimed: true }); + + const claimSnap = await getClaimDoc(experimentID, hash); + expect(claimSnap.data().ownerToken).toBe(newToken); + expect(claimSnap.data().status).toBe("pending"); + expect(claimSnap.data().createdAt.toMillis()).toBeGreaterThan(staleCreatedAt.toMillis()); + }); +}); + +describe("7. filename and experiment isolation", () => { + it("claims different filenames independently within an experiment, and hashes an identical filename differently per experiment", async () => { + const expA = freshExperimentId("iso-a"); + const expB = freshExperimentId("iso-b"); + await createExperiment(expA); + await createExperiment(expB); + const saltA = await warmExperiment(expA); + const saltB = await warmExperiment(expB); + const listFilesFn = jest.fn().mockResolvedValue([]); + const sharedFilename = "shared-name.csv"; + + const resultOne = await claimFilename(expA, "one.csv", randomUUID(), listFilesFn); + const resultTwo = await claimFilename(expA, "two.csv", randomUUID(), listFilesFn); + expect(resultOne).toEqual({ claimed: true }); + expect(resultTwo).toEqual({ claimed: true }); + + const resultInB = await claimFilename(expB, sharedFilename, randomUUID(), listFilesFn); + const resultInA = await claimFilename(expA, sharedFilename, randomUUID(), listFilesFn); + expect(resultInB).toEqual({ claimed: true }); + expect(resultInA).toEqual({ claimed: true }); + + expect(saltA).not.toBe(saltB); + const hashInA = claimHash(saltA, sharedFilename); + const hashInB = claimHash(saltB, sharedFilename); + expect(hashInA).not.toBe(hashInB); + + expect((await getClaimDoc(expA, hashInA)).exists).toBe(true); + expect((await getClaimDoc(expB, hashInB)).exists).toBe(true); + }); +}); + +describe("8. expiresAt timing", () => { + it("sets expiresAt ~CLAIM_TTL_MS in the future on create, and refreshes it forward on confirm", async () => { + const experimentID = freshExperimentId("expires-at"); + await createExperiment(experimentID); + const salt = await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + const ownerToken = randomUUID(); + const filename = "ttl.csv"; + const hash = claimHash(salt, filename); + + const beforeClaim = Date.now(); + await claimFilename(experimentID, filename, ownerToken, listFilesFn); + const afterClaim = Date.now(); + + let claimSnap = await getClaimDoc(experimentID, hash); + const createdExpiresAtMs = claimSnap.data().expiresAt.toMillis(); + expect(createdExpiresAtMs).toBeGreaterThanOrEqual(beforeClaim + CLAIM_TTL_MS - TOLERANCE_MS); + expect(createdExpiresAtMs).toBeLessThanOrEqual(afterClaim + CLAIM_TTL_MS + TOLERANCE_MS); + + // Small delay so a refreshed expiresAt is unambiguously later, not just + // equal due to millisecond truncation. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const beforeConfirm = Date.now(); + await confirmClaim(experimentID, filename, ownerToken); + const afterConfirm = Date.now(); + + claimSnap = await getClaimDoc(experimentID, hash); + const confirmedExpiresAtMs = claimSnap.data().expiresAt.toMillis(); + expect(confirmedExpiresAtMs).toBeGreaterThanOrEqual(beforeConfirm + CLAIM_TTL_MS - TOLERANCE_MS); + expect(confirmedExpiresAtMs).toBeLessThanOrEqual(afterConfirm + CLAIM_TTL_MS + TOLERANCE_MS); + expect(confirmedExpiresAtMs).toBeGreaterThanOrEqual(createdExpiresAtMs); + }); +}); + +describe("9. rehydration on a cold cache", () => { + it("bulk-confirms every filename listFilesFn returns, calling it exactly once even across multiple claims", async () => { + const experimentID = freshExperimentId("rehydrate"); + await createExperiment(experimentID); // no collisionCache field at all — cold by construction + const listFilesFn = jest.fn().mockResolvedValue([{ name: "a.csv" }, { name: "b.csv" }]); + + const beforeRehydrate = Date.now(); + const result = await claimFilename(experimentID, "c.csv", randomUUID(), listFilesFn); + + expect(result).toEqual({ claimed: true }); // c.csv wasn't in the listing + expect(listFilesFn).toHaveBeenCalledTimes(1); + + const expData = await getExperimentData(experimentID); + expect(expData.collisionCache.warmUntil.toMillis()).toBeGreaterThanOrEqual( + beforeRehydrate + CLAIM_TTL_MS - TOLERANCE_MS + ); + + const salt = expData.collisionCache.salt; + const aSnap = await getClaimDoc(experimentID, claimHash(salt, "a.csv")); + const bSnap = await getClaimDoc(experimentID, claimHash(salt, "b.csv")); + expect(aSnap.data().status).toBe("confirmed"); + expect(aSnap.data().ownerToken).toBe("rehydration"); + expect(bSnap.data().status).toBe("confirmed"); + expect(bSnap.data().ownerToken).toBe("rehydration"); + + // a.csv is now provably taken via the bulk-written claim from rehydration. + const dup = await claimFilename(experimentID, "a.csv", randomUUID(), listFilesFn); + expect(dup).toEqual({ claimed: false, reason: "duplicate" }); + + // Neither the c.csv claim nor the a.csv duplicate check should have + // triggered a second rehydration pass. + expect(listFilesFn).toHaveBeenCalledTimes(1); + }); +}); + +describe("10. rehydration lease held by another request", () => { + it("returns reason 'rehydrating' without calling listFilesFn while another request's lease is active", async () => { + const experimentID = freshExperimentId("lease-held"); + await createExperiment(experimentID); + await db + .collection("experiments") + .doc(experimentID) + .set( + { + collisionCache: { + salt: randomUUID().replace(/-/g, ""), + // warmUntil deliberately absent/past — cache is cold, forcing the + // rehydration-lease check to run. + rehydratingUntil: Timestamp.fromMillis(Date.now() + REHYDRATION_LEASE_MS), + }, + }, + { merge: true } + ); + const listFilesFn = jest.fn().mockResolvedValue([]); + + const result = await claimFilename(experimentID, "leased.csv", randomUUID(), listFilesFn); + + expect(result).toEqual({ claimed: false, reason: "rehydrating" }); + expect(listFilesFn).not.toHaveBeenCalled(); + }); +}); + +describe("11. rehydration failure", () => { + it("throws CollisionCacheUnavailableError and clears the lease so a later claim retries rehydration", async () => { + const experimentID = freshExperimentId("rehydrate-fail"); + await createExperiment(experimentID); // cold cache, no salt yet + + const failingListFiles = jest.fn().mockRejectedValue(new Error("container access revoked")); + + await expect(claimFilename(experimentID, "x.csv", randomUUID(), failingListFiles)).rejects.toThrow( + CollisionCacheUnavailableError + ); + + const expDataAfterFailure = await getExperimentData(experimentID); + expect(expDataAfterFailure.collisionCache.rehydratingUntil).toBeFalsy(); + + const workingListFiles = jest.fn().mockResolvedValue([]); + const result = await claimFilename(experimentID, "x.csv", randomUUID(), workingListFiles); + + expect(result).toEqual({ claimed: true }); + expect(workingListFiles).toHaveBeenCalledTimes(1); // rehydration retried, not skipped + }); +}); + +describe("12. warm cache", () => { + it("never calls listFilesFn when warmUntil is already in the future", async () => { + const experimentID = freshExperimentId("warm"); + await createExperiment(experimentID); + await warmExperiment(experimentID); + const listFilesFn = jest.fn().mockResolvedValue([]); + + const result = await claimFilename(experimentID, "warm.csv", randomUUID(), listFilesFn); + + expect(result).toEqual({ claimed: true }); + expect(listFilesFn).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/src/__tests__/collision-integration-emulator.test.js b/functions/src/__tests__/collision-integration-emulator.test.js new file mode 100644 index 0000000..9de7a26 --- /dev/null +++ b/functions/src/__tests__/collision-integration-emulator.test.js @@ -0,0 +1,294 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 3a (docs/provider-migration-design.md, +// scratchpad/step3a-collision-cache-spec.md), cases 13-17 of the test plan. +// +// These exercise the deployed-in-emulator apidata/apibase64 functions end to +// end against a mock OSF server, following the pattern in +// early-persist-emulator.test.js: a self-contained express server started on +// an OS-assigned port (`listen(0)`) rather than the shared, fixed-port +// mock-server.ts used by metadata-emulator.test.js. Two reasons for that +// choice instead of extending mock-server.ts: +// 1. mock-server.ts binds a hardcoded port (3000); a second test file +// binding the same port would race it when Jest runs files in parallel +// workers, causing spurious EADDRINUSE failures unrelated to collision +// logic. `listen(0)` sidesteps that entirely. +// 2. early-persist-emulator.test.js already establishes this inline-mock +// convention for tests that need per-test control over the OSF +// response, which is exactly what cases 14/15 need (forcing 409/500). +// mock-server.ts itself is untouched. +// +// Until api-data.ts / api-base64.ts / collision-cache.ts are implemented, +// these requests never claim a filename in Firestore at all, so most +// assertions here fail on missing behavior (no collisionCache field ever +// appears, no claim docs, no claimToken on the queue doc) rather than on any +// transport-level problem. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID, createHash } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +const OWNER_ID = "collision-int-owner"; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; +const sampleBase64 = Buffer.from("collision cache integration payload").toString("base64"); + +async function saveData(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apidata", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const message = await response.json(); + return { status: response.status, body: message }; +} + +async function saveBase64Data(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apibase64", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const message = await response.json(); + return { status: response.status, body: message }; +} + +// Mirrors collision-cache.ts's hashing rule (sha256hex(salt + ":" + filename)) +// so tests can look up the exact claim doc a request should have produced. +function claimHash(salt, filename) { + return createHash("sha256").update(`${salt}:${filename}`).digest("hex"); +} + +// A minimal, self-contained mock OSF "files" container: +// GET /files -> osfProvider.listFiles (?meta=) +// PUT /files?name=<...> -> osfProvider.writeSessionFile / putFileOSF +// with in-process (not HTTP) controls, since the test and the mock server +// share the same Node process. +function createMockOSFServer() { + const app = express(); + const uploadCountsByFilename = new Map(); + const forcedStatusByFilename = new Map(); + + app.get("/files", (req, res) => { + // Always an empty container: every experiment in this suite starts + // fresh, so rehydration (if implemented) should complete trivially. + res.json({ data: [] }); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + uploadCountsByFilename.set(filename, (uploadCountsByFilename.get(filename) || 0) + 1); + + const forcedStatus = forcedStatusByFilename.get(filename); + if (forcedStatus && forcedStatus !== 201) { + // Deliberately setting only the status (not a custom statusMessage) so + // Node's default HTTP reason phrase applies — e.g. 409 -> "Conflict", + // 500 -> "Internal Server Error" — matching what production code + // (api-data.ts's `result.providerMessage === "Conflict"` check) expects + // from a real OSF response. + res.status(forcedStatus).json({ errors: [{ detail: `mock-forced-status-${forcedStatus}` }] }); + return; + } + + res.status(201).json({ + data: { attributes: { name: filename, kind: "file" }, id: "osfstorage/mock-upload" }, + }); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + getUploadCount: (filename) => uploadCountsByFilename.get(filename) || 0, + forceStatus: (filename, status) => forcedStatusByFilename.set(filename, status), + reset: () => { + uploadCountsByFilename.clear(); + forcedStatusByFilename.clear(); + }, + }); + }); + }); +} + +let db; +let mockOSF; +let filesLink; + +beforeAll(async () => { + mockOSF = await createMockOSFServer(); + filesLink = `http://localhost:${mockOSF.port}/files`; + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); +}); + +afterEach(() => { + mockOSF.reset(); +}); + +afterAll(async () => { + mockOSF.server.close(); +}); + +async function createDataExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: false, + owner: OWNER_ID, + osfFilesLink: filesLink, + ...overrides, + }); +} + +async function createBase64Experiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + activeBase64: true, + owner: OWNER_ID, + osfFilesLink: filesLink, + ...overrides, + }); +} + +describe("13. apidata: duplicate filename is rejected without a second provider upload", () => { + it("first POST succeeds, second POST for the same filename gets OSF_FILE_EXISTS, and OSF received exactly one upload", async () => { + const experimentID = `collision-int13-${randomUUID()}`; + const filename = `dup-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + expect(mockOSF.getUploadCount(filename)).toBe(1); + }); +}); + +describe("14. dual-run disagreement between an empty cache and a 409 from OSF", () => { + it("responds OSF_FILE_EXISTS, confirms the claim, and logs a collisionCacheDisagreement entry", async () => { + const experimentID = `collision-int14-${randomUUID()}`; + const filename = `disagree-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + // The cache has no claim for this filename (fresh experiment, empty + // mock container) — but OSF itself reports the name is already taken. + mockOSF.forceStatus(filename, 409); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + + const hash = claimHash(expDataAfter.collisionCache.salt, filename); + const claimSnap = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(hash) + .get(); + expect(claimSnap.exists).toBe(true); + expect(claimSnap.data().status).toBe("confirmed"); + + const logDoc = await db.collection("logs").doc(experimentID).get(); + const errors = logDoc.data()?.errors || []; + expect(errors.some((entry) => entry.collisionCacheDisagreement === true)).toBe(true); + }); +}); + +describe("15. provider failure queues the upload and preserves the claim", () => { + it("500 from OSF results in a 202 queued response with a claimToken, and the claim stays pending", async () => { + const experimentID = `collision-int15-${randomUUID()}`; + const filename = `queue-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + mockOSF.forceStatus(filename, 500); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(202); + expect(response.body).toEqual(expect.objectContaining({ ...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage: "" })); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueDoc = await db.collection("uploadQueue").doc(docId).get(); + expect(queueDoc.exists).toBe(true); + expect(typeof queueDoc.data().claimToken).toBe("string"); + expect(queueDoc.data().claimToken.length).toBeGreaterThan(0); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + + const hash = claimHash(expDataAfter.collisionCache.salt, filename); + const claimSnap = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(hash) + .get(); + expect(claimSnap.exists).toBe(true); + expect(claimSnap.data().status).toBe("pending"); + expect(claimSnap.data().ownerToken).toBe(queueDoc.data().claimToken); + }); +}); + +describe("16. apibase64: duplicate filename is rejected without a second provider upload", () => { + it("first POST succeeds, second POST for the same filename gets OSF_FILE_EXISTS, and OSF received exactly one upload", async () => { + const experimentID = `collision-int16-${randomUUID()}`; + const filename = `dup-b64-${randomUUID()}.dat`; + await createBase64Experiment(experimentID); + + const first = await saveBase64Data({ experimentID, data: sampleBase64, filename }); + expect(first.status).toBe(201); + + const second = await saveBase64Data({ experimentID, data: sampleBase64, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual(MESSAGES.OSF_FILE_EXISTS); + + expect(mockOSF.getUploadCount(filename)).toBe(1); + }); +}); + +describe("17. successful upload stamps the experiment with a warm collision cache", () => { + it("after a 201, the experiment doc has collisionCache.salt and a warmUntil in the future", async () => { + const experimentID = `collision-int17-${randomUUID()}`; + const filename = `stamp-${randomUUID()}.json`; + await createDataExperiment(experimentID); + + const before = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(201); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + expect(typeof expDataAfter.collisionCache.salt).toBe("string"); + expect(expDataAfter.collisionCache.salt.length).toBeGreaterThan(0); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(before); + }); +}); diff --git a/functions/src/__tests__/early-persist-emulator.test.js b/functions/src/__tests__/early-persist-emulator.test.js index 9c2ad09..c7aaeda 100644 --- a/functions/src/__tests__/early-persist-emulator.test.js +++ b/functions/src/__tests__/early-persist-emulator.test.js @@ -57,6 +57,12 @@ function createMockOSFServer() { app.put("/endpoint", (req, res) => { res.status(201).json({ data: { attributes: { name: req.query.name || "uploaded.json" } } }); }); + // Collision-cache rehydration lists the container on an experiment's first + // write (see collision-cache.ts); an empty listing keeps this suite's + // experiments cold-start-clean without changing what it tests. + app.get("/endpoint", (req, res) => { + res.json({ data: [] }); + }); return new Promise((resolve) => { const server = app.listen(0, () => { resolve(server); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 901c256..c0e1123 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -1,4 +1,5 @@ import { onRequest } from "firebase-functions/v2/https"; +import { randomUUID } from "crypto"; import { DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; import { db } from "./app.js"; import writeLog from "./write-log.js"; @@ -9,6 +10,7 @@ import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment } from "./providers/index.js"; import { WriteResult } from "./providers/types.js"; +import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from './interfaces'; export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { @@ -115,6 +117,67 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: const { provider, container } = getProviderForExperiment(exp_data); + // Collision detection: claim the filename in the Firestore cache + // immediately before the provider write. The provider's own conflict + // response (NAME_CONFLICT) stays wired up below as a dual-run backstop. + const claimToken = randomUUID(); + let claimResult: Awaited>; + try { + claimResult = await claimFilename(experimentID, filename, claimToken, () => + provider.listFiles({ token }, container) + ); + } catch (e) { + if (e instanceof CollisionCacheUnavailableError) { + const detail = e.message; + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: false, + failureReason: `Collision cache rehydration failed: ${detail}`, + claimToken, + }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: `Collision cache rehydration failed: ${detail}`}); + return; + } catch { + res.status(500).json(MESSAGES.OSF_UPLOAD_EXCEPTION); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail}); + return; + } + } + throw e; + } + + if (!claimResult.claimed) { + if (claimResult.reason === "duplicate") { + res.status(400).json(MESSAGES.OSF_FILE_EXISTS); + await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + return; + } + + // reason === "rehydrating" — another request holds the rehydration + // lease; queue this upload and let the retry land after it expires. + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: false, + failureReason: "Collision cache rehydrating", + claimToken, + }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } catch { + res.status(500).json(MESSAGES.OSF_UPLOAD_EXCEPTION); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } + } + let result: WriteResult; try { result = await provider.writeSessionFile( @@ -125,7 +188,8 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: { size: buffer.length, contentType: "application/octet-stream" } ); } catch (e) { - // Network errors, timeouts, etc. — queue for retry + // Network errors, timeouts, etc. — queue for retry. The claim stays + // pending so the retry can re-enter it with the same token. const detail = e instanceof Error ? e.message : "Unknown error"; try { await queueUpload({ @@ -133,6 +197,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: dataType: "base64", osfFilesLink: exp_data.osfFilesLink, errorCode: 0, sessionIncremented: false, failureReason: `Upload exception: ${detail}`, + claimToken, }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); @@ -147,17 +212,27 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: if (!result.success) { if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { + // Dual-run disagreement: the cache thought the name was free but OSF + // says it's taken. OSF is still the backstop — record the + // disagreement and confirm the claim (the name is now provably taken). + await confirmClaim(experimentID, filename, claimToken); res.status(400).json(MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + await writeLog(experimentID, "logError", { + collisionCacheDisagreement: true, + direction: "cache-free-provider-conflict", + }); return; } - // Queue all other failures for retry + // Queue all other failures for retry. The claim stays pending so the + // retry can re-enter it with the same token. try { await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, errorCode: result.providerStatus || 0, sessionIncremented: false, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, + claimToken, }); await cleanupPending(pendingPath); // queue-upload has its own copy res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); @@ -170,6 +245,10 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: } } + // Successful write — confirm the claim (best-effort; a confirm failure + // must not fail a request that already succeeded against the provider). + await confirmClaim(experimentID, filename, claimToken); + // Data successfully uploaded to OSF — clean up the pending copy. await cleanupPending(pendingPath); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index df7b357..1440c3d 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -1,4 +1,5 @@ import { onRequest } from "firebase-functions/v2/https"; +import { randomUUID } from "crypto"; import { FieldValue, DocumentReference, DocumentData, DocumentSnapshot } from "firebase-admin/firestore"; import validateJSON from "./validate-json.js"; import validateCSV from "./validate-csv.js"; @@ -11,6 +12,7 @@ import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment } from "./providers/index.js"; import { WriteResult } from "./providers/types.js"; +import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData, MetadataResponse, RequestBody } from './interfaces'; export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { @@ -148,6 +150,69 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 const { provider, container } = getProviderForExperiment(exp_data); + // Collision detection: claim the filename in the Firestore cache + // immediately before the provider write. The provider's own conflict + // response (NAME_CONFLICT) stays wired up below as a dual-run backstop. + const claimToken = randomUUID(); + let claimResult: Awaited>; + try { + claimResult = await claimFilename(experimentID, filename, claimToken, () => + provider.listFiles({ token }, container) + ); + } catch (e) { + if (e instanceof CollisionCacheUnavailableError) { + const detail = e.message; + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "data", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: true, + failureReason: `Collision cache rehydration failed: ${detail}`, + claimToken, + }); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: `Collision cache rehydration failed: ${detail}`}); + return; + } catch { + res.status(500).json({...MESSAGES.OSF_UPLOAD_EXCEPTION, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail}); + return; + } + } + throw e; + } + + if (!claimResult.claimed) { + if (claimResult.reason === "duplicate") { + res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); + await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + return; + } + + // reason === "rehydrating" — another request holds the rehydration + // lease; queue this upload and let the retry land after it expires. + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "data", osfFilesLink: exp_data.osfFilesLink, + errorCode: 0, sessionIncremented: true, + failureReason: "Collision cache rehydrating", + claimToken, + }); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } catch { + res.status(500).json({...MESSAGES.OSF_UPLOAD_EXCEPTION, metadataMessage}); + await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); + return; + } + } + let result: WriteResult; try { result = await provider.writeSessionFile( @@ -158,7 +223,8 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 { size: Buffer.byteLength(data), contentType: "application/json" } ); } catch (e) { - // Network errors, timeouts, etc. — queue for retry + // Network errors, timeouts, etc. — queue for retry. The claim stays + // pending so the retry can re-enter it with the same token. const detail = e instanceof Error ? e.message : "Unknown error"; try { await queueUpload({ @@ -166,6 +232,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 dataType: "data", osfFilesLink: exp_data.osfFilesLink, errorCode: 0, sessionIncremented: true, failureReason: `Upload exception: ${detail}`, + claimToken, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy @@ -181,17 +248,27 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 if (!result.success) { if (result.error === "NAME_CONFLICT" && result.providerMessage === "Conflict") { + // Dual-run disagreement: the cache thought the name was free but OSF + // says it's taken. OSF is still the backstop — record the + // disagreement and confirm the claim (the name is now provably taken). + await confirmClaim(experimentID, filename, claimToken); res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); + await writeLog(experimentID, "logError", { + collisionCacheDisagreement: true, + direction: "cache-free-provider-conflict", + }); return; } - // Queue all other failures for retry + // Queue all other failures for retry. The claim stays pending so the + // retry can re-enter it with the same token. try { await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, errorCode: result.providerStatus || 0, sessionIncremented: true, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, + claimToken, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy @@ -205,6 +282,10 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 } } + // Successful write — confirm the claim (best-effort; a confirm failure + // must not fail a request that already succeeded against the provider). + await confirmClaim(experimentID, filename, claimToken); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); // Data successfully uploaded to OSF — clean up the pending copy. diff --git a/functions/src/collision-cache.ts b/functions/src/collision-cache.ts new file mode 100644 index 0000000..b9d999f --- /dev/null +++ b/functions/src/collision-cache.ts @@ -0,0 +1,305 @@ +// Firestore-backed filename collision cache (docs/provider-migration-design.md, +// scratchpad/step3a-collision-cache-spec.md). +// +// Duplicate-filename detection moves out of "ask the provider and interpret +// its 409" and into a per-experiment Firestore cache of salted filename +// hashes. The provider's own conflict response stays wired up as a dual-run +// backstop (see api-data.ts / api-base64.ts): callers keep reacting to +// NAME_CONFLICT, but only to reconcile with what the cache already believes. +// +// Firestore layout: +// experiments/{id}.collisionCache: { +// salt: string, +// warmUntil: Timestamp, +// rehydratingUntil?: Timestamp, +// } +// experiments/{id}/filenameClaims/{sha256hex(salt + ":" + filename)}: { +// status: "pending" | "confirmed", +// ownerToken: string, +// createdAt: Timestamp, +// expiresAt: Timestamp, +// } +// +// The raw filename is never stored anywhere — only its salted hash, used as +// the claim document's ID. + +import { randomBytes, createHash } from "crypto"; +import { Timestamp, FieldValue } from "firebase-admin/firestore"; +import { db } from "./app.js"; +import { FileRef } from "./providers/types.js"; + +export const CLAIM_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days +export const STALE_PENDING_TAKEOVER_MS = 15 * 60 * 1000; // 15 minutes +export const REHYDRATION_LEASE_MS = 60 * 1000; // 60 seconds + +// Thrown when a cold cache needs to rehydrate but the caller-supplied +// listFilesFn fails (e.g. container missing, access revoked). Callers must +// treat this as "we don't know" and fail loudly rather than silently +// accepting a possible duplicate. +export class CollisionCacheUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "CollisionCacheUnavailableError"; + } +} + +export type ClaimResult = + | { claimed: true } + | { claimed: false; reason: "duplicate" } + | { claimed: false; reason: "rehydrating" }; + +function hashFilename(salt: string, filename: string): string { + return createHash("sha256").update(`${salt}:${filename}`).digest("hex"); +} + +function experimentRef(experimentID: string) { + return db.collection("experiments").doc(experimentID); +} + +function claimsCollection(experimentID: string) { + return experimentRef(experimentID).collection("filenameClaims"); +} + +// Reads (or lazily creates) the per-experiment salt used to hash filenames. +// The salt is retained indefinitely and never rotated. +async function ensureSalt(experimentID: string): Promise { + const expRef = experimentRef(experimentID); + return db.runTransaction(async (tx) => { + const snap = await tx.get(expRef); + const existingSalt = snap.data()?.collisionCache?.salt as string | undefined; + if (existingSalt) { + return existingSalt; + } + const salt = randomBytes(32).toString("hex"); + tx.update(expRef, { "collisionCache.salt": salt }); + return salt; + }); +} + +async function isCacheWarm(experimentID: string): Promise { + const snap = await experimentRef(experimentID).get(); + const warmUntil = snap.data()?.collisionCache?.warmUntil as FirebaseFirestore.Timestamp | undefined; + return !!warmUntil && warmUntil.toMillis() > Date.now(); +} + +// Rehydrates a cold cache: acquires a short lease, lists every file the +// provider currently has, and bulk-writes them as confirmed claims. Returns +// true once the cache is warm, or false if another in-flight request already +// holds the rehydration lease (caller should surface "rehydrating" rather +// than block). Throws CollisionCacheUnavailableError if listFilesFn fails — +// the lease is cleared first so a later request can retry. +async function rehydrate( + experimentID: string, + salt: string, + listFilesFn: () => Promise +): Promise { + const expRef = experimentRef(experimentID); + + let acquiredLease = false; + await db.runTransaction(async (tx) => { + const snap = await tx.get(expRef); + const rehydratingUntil = snap.data()?.collisionCache?.rehydratingUntil as + | FirebaseFirestore.Timestamp + | undefined; + if (rehydratingUntil && rehydratingUntil.toMillis() > Date.now()) { + acquiredLease = false; + return; + } + const leaseUntil = Timestamp.fromMillis(Date.now() + REHYDRATION_LEASE_MS); + tx.update(expRef, { "collisionCache.rehydratingUntil": leaseUntil }); + acquiredLease = true; + }); + + if (!acquiredLease) { + return false; + } + + let files: FileRef[]; + try { + files = await listFilesFn(); + } catch (e) { + // Clear the lease so a subsequent claim attempts rehydration again + // rather than being locked out until the lease naturally expires. + await expRef.update({ "collisionCache.rehydratingUntil": FieldValue.delete() }); + const detail = e instanceof Error ? e.message : String(e); + throw new CollisionCacheUnavailableError( + `Rehydration failed for experiment ${experimentID}: ${detail}` + ); + } + + const now = Timestamp.now(); + const expiresAt = Timestamp.fromMillis(now.toMillis() + CLAIM_TTL_MS); + const claims = claimsCollection(experimentID); + + for (let i = 0; i < files.length; i += 500) { + const chunk = files.slice(i, i + 500); + const batch = db.batch(); + for (const file of chunk) { + const hash = hashFilename(salt, file.name); + batch.set(claims.doc(hash), { + status: "confirmed", + ownerToken: "rehydration", + createdAt: now, + expiresAt, + }); + } + await batch.commit(); + } + + const warmUntil = Timestamp.fromMillis(Date.now() + CLAIM_TTL_MS); + await expRef.update({ + "collisionCache.warmUntil": warmUntil, + "collisionCache.rehydratingUntil": FieldValue.delete(), + }); + + return true; +} + +async function attemptClaim( + experimentID: string, + filename: string, + ownerToken: string, + salt: string +): Promise { + const hash = hashFilename(salt, filename); + const claimRef = claimsCollection(experimentID).doc(hash); + + return db.runTransaction(async (tx): Promise => { + const snap = await tx.get(claimRef); + const now = Timestamp.now(); + const expiresAt = Timestamp.fromMillis(now.toMillis() + CLAIM_TTL_MS); + + if (!snap.exists) { + tx.set(claimRef, { status: "pending", ownerToken, createdAt: now, expiresAt }); + return { claimed: true }; + } + + const data = snap.data()!; + + if (data.status === "pending") { + if (data.ownerToken === ownerToken) { + // Idempotent re-entry — the retry queue re-claiming its own filename. + return { claimed: true }; + } + + const createdAtMs = (data.createdAt as FirebaseFirestore.Timestamp).toMillis(); + if (Date.now() - createdAtMs > STALE_PENDING_TAKEOVER_MS) { + tx.set(claimRef, { status: "pending", ownerToken, createdAt: now, expiresAt }); + return { claimed: true }; + } + } + + // confirmed, or a fresh pending claim owned by someone else. + return { claimed: false, reason: "duplicate" }; + }); +} + +export async function claimFilename( + experimentID: string, + filename: string, + ownerToken: string, + listFilesFn: () => Promise +): Promise { + const salt = await ensureSalt(experimentID); + + const warm = await isCacheWarm(experimentID); + if (!warm) { + const nowWarm = await rehydrate(experimentID, salt, listFilesFn); + if (!nowWarm) { + return { claimed: false, reason: "rehydrating" }; + } + } + + return attemptClaim(experimentID, filename, ownerToken, salt); +} + +// Best-effort: marks a claim confirmed and bumps the cache's warmUntil. +// Never throws — a confirm failure must not fail the request that already +// succeeded (or was provably a name conflict) against the provider. Missing +// claims / salt / owner mismatches are logged, not thrown. +export async function confirmClaim( + experimentID: string, + filename: string, + ownerToken: string +): Promise { + try { + const expRef = experimentRef(experimentID); + const expSnap = await expRef.get(); + const salt = expSnap.data()?.collisionCache?.salt as string | undefined; + if (!salt) { + console.error( + `confirmClaim: no collisionCache.salt for experiment ${experimentID}; cannot confirm claim` + ); + return; + } + + const hash = hashFilename(salt, filename); + const claimRef = claimsCollection(experimentID).doc(hash); + + // The experiment doc already takes one write per submission (the sessions + // increment); bumping warmUntil on every confirm would double that under + // burst load. Skip the bump while warmUntil is still comfortably in the + // future — a day of drift is irrelevant against a 90-day window. + const currentWarmUntil = expSnap.data()?.collisionCache?.warmUntil as + | FirebaseFirestore.Timestamp + | undefined; + + await db.runTransaction(async (tx) => { + const claimSnap = await tx.get(claimRef); + if (!claimSnap.exists) { + console.error(`confirmClaim: no claim doc found for experiment ${experimentID}`); + return; + } + + const data = claimSnap.data()!; + if (data.ownerToken !== ownerToken) { + console.error(`confirmClaim: owner token mismatch for experiment ${experimentID}`); + return; + } + + const now = Timestamp.now(); + const expiresAt = Timestamp.fromMillis(now.toMillis() + CLAIM_TTL_MS); + tx.update(claimRef, { status: "confirmed", expiresAt }); + const warmEnough = + currentWarmUntil && + currentWarmUntil.toMillis() > now.toMillis() + CLAIM_TTL_MS - 24 * 60 * 60 * 1000; + if (!warmEnough) { + tx.update(expRef, { "collisionCache.warmUntil": expiresAt }); + } + }); + } catch (e) { + console.error( + `confirmClaim failed for experiment ${experimentID}, filename ${filename}:`, + e instanceof Error ? e.message : e + ); + } +} + +// Deletes a claim only if it is still pending and owned by the given token — +// a no-op with the wrong token or for a confirmed claim. +export async function releaseClaim( + experimentID: string, + filename: string, + ownerToken: string +): Promise { + const expRef = experimentRef(experimentID); + const expSnap = await expRef.get(); + const salt = expSnap.data()?.collisionCache?.salt as string | undefined; + if (!salt) { + return; + } + + const hash = hashFilename(salt, filename); + const claimRef = claimsCollection(experimentID).doc(hash); + + await db.runTransaction(async (tx) => { + const snap = await tx.get(claimRef); + if (!snap.exists) { + return; + } + const data = snap.data()!; + if (data.status === "pending" && data.ownerToken === ownerToken) { + tx.delete(claimRef); + } + }); +} diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index 0a85d7c..6e5d9df 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -108,6 +108,10 @@ export interface ExperimentData { failureReason: string | null; deduplicationKey: string; sessionIncremented: boolean; + // Collision-cache claim owned by this queue entry (additive; absent for + // entries queued before the collision cache existed — those skip the + // cache entirely on retry). + claimToken?: string; } export interface OSFFile{ diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index a649ad2..ba2b275 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -146,4 +146,7 @@ export interface ConnectedAccounts { export interface CollisionCacheState { salt: string; warmUntil: FirebaseFirestore.Timestamp; + // Set while a rehydration pass is in flight (leases the rehydration work + // to one request at a time); cleared on completion or failure. + rehydratingUntil?: FirebaseFirestore.Timestamp; } diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index 286de8c..818e582 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -11,6 +11,7 @@ interface QueueUploadParams { errorCode: number; sessionIncremented: boolean; failureReason?: string; + claimToken?: string; } const MAX_RETRIES = 5; @@ -60,6 +61,7 @@ export default async function queueUpload(params: QueueUploadParams): Promise>; + try { + claimResult = await claimFilename(data.experimentID, data.filename, data.claimToken, () => + osfProvider.listFiles({ token }, container) + ); + } catch (e) { + if (e instanceof CollisionCacheUnavailableError) { + await handleRetryFailure(docRef, data, `Collision cache rehydration failed: ${e.message}`); + return; + } + throw e; + } + + if (!claimResult.claimed) { + if (claimResult.reason === "duplicate") { + // Someone else confirmed this name while we were queued — mirrors + // today's 409-on-retry-means-done semantics. + await markCompleted(docRef, data); + console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); + return; + } + // reason === "rehydrating" + await handleRetryFailure(docRef, data, "Collision cache rehydrating"); + return; + } + } + // Attempt the upload try { - const container = { provider: "osf" as const, filesLink: data.osfFilesLink }; const result = await osfProvider.writeSessionFile( { token }, container, @@ -145,12 +178,18 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho ); if (result.success) { + if (data.claimToken) { + await confirmClaim(data.experimentID, data.filename, data.claimToken); + } await markCompleted(docRef, data); console.log(`Successfully retried upload ${queueDoc.id} (${data.filename})`); return; } if (result.error === "NAME_CONFLICT") { + if (data.claimToken) { + await confirmClaim(data.experimentID, data.filename, data.claimToken); + } // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); From 4e889f34157b4bf8df4bef7ac1f8bb2361a60b4a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 18:42:09 -0400 Subject: [PATCH 031/181] feat: track metadata file by provider ref instead of listing on every write Build step 3b of the provider migration; closes out build step 3. - putFileOSF parses the 201 response body; WriteResult.fileRef now carries the provider file id - metadata-block reads/stores metadataFileRef on metadata/{id} instead of listing the provider folder per submission. Three-state semantics: undefined = pre-migration doc, triggers one-time legacy discovery via listFiles; null = known absent, create without listing; ref = update directly. Stale refs self-heal (recreate + store new ref). Refs without a usable id are not stored, degrading to re-discovery instead of persisting an un-updatable ref - the latent 210 status check is removed: the firestore-has-metadata / provider-doesn't branch now succeeds instead of always throwing (the old emulator test masked this by asserting only metadataMessage, which was populated before the throw) - metadata-process.ts deleted (no remaining callers) along with its unit test; metadata-emulator's runTransaction block rewritten from the token-flip mock trick to direct structural seeding of the four firestore x provider metadata scenarios TDD: 9 unit/integration contract tests written and reviewed red first. Full emulator suite green (21 suites, 142 tests). Co-Authored-By: Claude Fable 5 --- .../src/__tests__/metadata-emulator.test.js | 264 ++++++++++--- .../src/__tests__/metadata-process.test.js | 41 -- .../__tests__/metadata-ref-emulator.test.js | 366 ++++++++++++++++++ .../src/__tests__/put-file-osf-ref.test.js | 194 ++++++++++ functions/src/metadata-block.ts | 143 ++++--- functions/src/metadata-process.ts | 48 --- functions/src/providers/osf.ts | 5 +- functions/src/put-file-osf.ts | 16 +- 8 files changed, 881 insertions(+), 196 deletions(-) delete mode 100644 functions/src/__tests__/metadata-process.test.js create mode 100644 functions/src/__tests__/metadata-ref-emulator.test.js create mode 100644 functions/src/__tests__/put-file-osf-ref.test.js delete mode 100644 functions/src/metadata-process.ts diff --git a/functions/src/__tests__/metadata-emulator.test.js b/functions/src/__tests__/metadata-emulator.test.js index 7cb6a73..9b47f35 100644 --- a/functions/src/__tests__/metadata-emulator.test.js +++ b/functions/src/__tests__/metadata-emulator.test.js @@ -1,10 +1,40 @@ /** * @jest-environment node */ -import { startServer } from '../../lib/mock-server.js' + +// Rewritten for step 3b (docs/provider-migration-design.md, +// scratchpad/step3b-metadata-ref-spec.md). +// +// The original `runTransaction` block simulated OSF metadata-file +// presence/absence by flipping a single shared test user's osfToken +// validity, which flipped mock-server.ts's fixed GET /endpoint response +// between "has dataset_description.json" and "doesn't" (see +// mock-server.ts's `bearerInvalid` branch). metadata-block.ts no longer +// lists the provider folder on every request -- presence/absence is now +// read from the metadataFileRef stored on the metadata doc -- so that +// token-validity trick no longer produces the scenario it used to. +// +// This rewrite seeds metadataFileRef directly (a ref object, or explicit +// null) instead of toggling token validity, and replaces mock-server.ts +// (which defines no PUT route at all -- see below) with a real inline +// mock OSF server, following the pattern already established in +// metadata-ref-emulator.test.js. +// +// Notably, mock-server.ts's complete absence of a PUT route means the two +// "not in OSF" scenarios in the original file (METADATA_NOT_IN_FIRESTORE_OR_OSF +// and METADATA_IN_FIRESTORE_NOT_IN_OSF) could only reach putFileOSF via a 404 +// from the mock. In the METADATA_IN_FIRESTORE_NOT_IN_OSF case this fed +// straight into the (now-removed) `status !== 210` bug in metadata-block.ts: +// the create branch always threw, and the request as a whole FAILED -- +// but the old test only asserted `response.metadataMessage`, never +// `response.success` or the HTTP status, so the failure was invisible. That +// gap is exactly how the 210 bug survived undetected; each rewritten test +// below asserts the HTTP status alongside metadataMessage to close it. import { initializeApp } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; -import MESSAGES from '../../lib/api-messages.js'; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../../lib/api-messages.js"; process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; @@ -14,6 +44,8 @@ const config = { projectId: "datapipe-test", }; +const OWNER_ID = "metadata-matrix-owner"; + async function saveData(body) { const response = await fetch( "http://localhost:5001/datapipe-test/us-central1/apidata", @@ -27,102 +59,224 @@ async function saveData(body) { } ); const message = await response.json(); - return message; + return { status: response.status, body: message }; } const sampleData = `[{ "trial_type": "html-keyboard-response", "trial_index": 1, "time_elapsed": 776 -}]` +}]`; + +// A valid Psych-DS-shaped metadata object. metadata-update.ts's +// updateMetadata (real, unmocked) requires `variableMeasured`, and +// metadata-download.ts's downloadMetadata additionally requires the full +// Psych-DS field set -- both throw "Invalid ..." otherwise, which would fail +// these tests for the wrong reason (an unrelated throw, not the scenario +// under test). +const existingMetadata = { variableMeasured: [{ name: "existing_var" }] }; +const downloadedMetadata = { + name: "test-dataset", + schemaVersion: "Psych-DS 0.4.0", + "@context": "https://schema.org", + "@type": "Dataset", + description: "test dataset for download", + author: [{ name: "tester" }], + variableMeasured: [{ name: "downloaded_var" }], +}; -let mockServerInstance; +// A minimal, self-contained mock OSF "files" container, following the +// pattern established in metadata-ref-emulator.test.js: +// GET /files -> osfProvider.listFiles (collision-cache rehydration +// only in this file -- metadataFileRef is always +// seeded explicitly below, so metadata-block.ts's +// legacy-discovery listing never fires) +// GET /files/:id -> metadata-download.ts's downloadMetadata +// PUT /files -> create (putFileOSF / writeSessionFile) +// PUT /files/:id -> update (updateFileOSF / updateFile) +// mock-server.ts defines none of the PUT routes this needs, and is left +// untouched -- nothing else in the repo still imports it after this file +// stops doing so. +function createMockOSFServer() { + const app = express(); + let nextId = 1; + const createCallsByFilename = new Map(); + const updateCallsById = new Map(); + + app.get("/files", (req, res) => { + res.json({ data: [] }); + }); -beforeAll(async () => { + app.get("/files/:id", (req, res) => { + res.json(downloadedMetadata); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + createCallsByFilename.set(filename, (createCallsByFilename.get(filename) || 0) + 1); + const id = `mock-file-${nextId++}`; + res.status(201).json({ data: { id, attributes: { name: filename, kind: "file" } } }); + }); - mockServerInstance = await startServer(); + app.put("/files/:id", (req, res) => { + const id = req.params.id; + updateCallsById.set(id, (updateCallsById.get(id) || 0) + 1); + res.status(200).json({}); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + getCreateCount: (filename) => createCallsByFilename.get(filename) || 0, + getUpdateCount: (id) => updateCallsById.get(id) || 0, + // Every scenario below creates/updates a file literally named + // "dataset_description.json", so counts must be reset between + // tests -- otherwise a later test's assertion would include + // creates/updates left over from an earlier one. + resetCounts: () => { + createCallsByFilename.clear(); + updateCallsById.clear(); + }, + }); + }); + }); +} + +let db; +let mockOSF; +let filesLink; + +beforeAll(async () => { + mockOSF = await createMockOSFServer(); + filesLink = `http://localhost:${mockOSF.port}/files/`; initializeApp(config); - const db = getFirestore(); + db = getFirestore(); - await db.collection("experiments").doc('metadata-testexp').set({active: true, metadataActive: true, owner: 'test-user', osfFilesLink: "http://localhost:3000/endpoint"}); - await db.collection('users').doc('test-user').set({osfTokenValid: true, osfToken: 'valid', usingPersonalToken: true}); - await db.collection("metadata").doc('metadata-testexp').set({}); + await db.collection("users").doc(OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); +}); + +afterEach(() => { + mockOSF.resetCounts(); }); afterAll(async () => { - mockServerInstance.close(); - console.log('Server closed'); + mockOSF.server.close(); }); -describe('runTransaction', () => { +async function createExperiment(experimentID) { + await db.collection("experiments").doc(experimentID).set({ + active: true, + metadataActive: true, + owner: OWNER_ID, + osfFilesLink: filesLink, + }); +} - it('should handle the case when metadata is present in OSF but not in firestore', async () => { +describe("runTransaction", () => { + // Was: "should handle the case when metadata is present in OSF but not in + // firestore", driven by an invalid osfToken flipping mock-server.ts's fixed + // GET response to include a dataset_description.json entry. Now: a ref is + // seeded directly and no firestore `metadata` field exists, which is the + // same (metadataFileRef present, firestoreMetadata absent) pairing the old + // test intended. Newly asserted: status/success -- the old test only + // checked metadataMessage. + it("should handle the case when metadata is present in OSF but not in firestore", async () => { + const experimentID = `metadata-matrix1-${randomUUID()}`; + const refId = `ref1-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ + metadataFileRef: { id: refId, name: "dataset_description.json" }, + }); const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix1-${randomUUID()}.json`, }); - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE.metadataMessage); + expect(mockOSF.getUpdateCount(refId)).toBe(1); }); - it('should handle the case when metadata is neither in firestore nor OSF', async () => { - - const db = getFirestore(); - await db.collection("users").doc("test-user").set({osfToken: 'invalid'}, {merge: true}); - - await db.collection("experiments").doc("metadata-testexp").get() - await db.collection("users").doc('test-user').get() - await db.collection("metadata").doc("metadata-testexp").get() + // Was: "should handle the case when metadata is neither in firestore nor + // OSF", driven by an invalid osfToken. Now: metadataFileRef is explicitly + // null (known absent -- this experiment's metadata doc was never + // populated) and there's no firestore `metadata` field either. Newly + // asserted: status/success, and that a create actually occurred. + it("should handle the case when metadata is neither in firestore nor OSF", async () => { + const experimentID = `metadata-matrix2-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ metadataFileRef: null }); const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix2-${randomUUID()}.json`, }); - console.log(response); - - // console.log(response); - - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF.metadataMessage); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); }); - it('should handle the case when metadata is in OSF and in firestore', async () => { - - const db = getFirestore(); - await db.collection("users").doc("test-user").set({osfToken: 'valid'}, {merge: true}); - await db.collection("metadata").doc("metadata-testexp").set({metadata: "test-metadata"}, {merge: true}); + // Was: "should handle the case when metadata is in OSF and in firestore", + // driven by a valid osfToken. Now: a ref plus firestore `metadata` are + // both seeded directly. Newly asserted: status/success, and that the + // ref'd file (not a fresh one) received the update. + it("should handle the case when metadata is in OSF and in firestore", async () => { + const experimentID = `metadata-matrix3-${randomUUID()}`; + const refId = `ref3-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: { id: refId, name: "dataset_description.json" }, + }); - // Call your function const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix3-${randomUUID()}.json`, }); - // console.log(response); - - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_AND_FIRESTORE.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_IN_OSF_AND_FIRESTORE.metadataMessage); + expect(mockOSF.getUpdateCount(refId)).toBe(1); }); - it('should handle the case when metadata is not in OSF but is in firestore', async () => { - const db = getFirestore(); - await db.collection("users").doc("test-user").set({osfToken: 'invalid'}, {merge: true}); - await db.collection("metadata").doc("metadata-testexp").set({metadata: "test-metadata"}, {merge: true}) + // Was: "should handle the case when metadata is not in OSF but is in + // firestore", driven by an invalid osfToken. This is the scenario the + // now-removed `status !== 210` bug always threw on: the old mock-server.ts + // has no PUT route at all, so the create attempt 404'd, the buggy check + // threw regardless, and the request FAILED end-to-end -- invisible to the + // old test because it asserted only `metadataMessage`, which the catch + // block still populated from the already-set-before-the-throw local + // variable. With the bug removed and a real create route in place, this + // now genuinely succeeds. Newly asserted: status 201 (was silently 400) + // and that a create occurred. + it("should handle the case when metadata is not in OSF but is in firestore", async () => { + const experimentID = `metadata-matrix4-${randomUUID()}`; + await createExperiment(experimentID); + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: null, + }); const response = await saveData({ - experimentID: "metadata-testexp", + experimentID, data: sampleData, - filename: "test", + filename: `matrix4-${randomUUID()}.json`, }); - // console.log(response); - - expect(response.metadataMessage).toEqual(MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF.metadataMessage); + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toEqual(MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF.metadataMessage); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); }); - }); - diff --git a/functions/src/__tests__/metadata-process.test.js b/functions/src/__tests__/metadata-process.test.js deleted file mode 100644 index 398ff8e..0000000 --- a/functions/src/__tests__/metadata-process.test.js +++ /dev/null @@ -1,41 +0,0 @@ -// Import the function to test -import processMetadata from "../../lib/metadata-process.js"; - -// Mock the fetch function -global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ data: [{ attributes: { name: 'dataset_description.json' }, id: 'osfstorage/123' }] }), - }) -); - -describe('processMetadata', () => { - beforeEach(() => { - fetch.mockClear(); - }); - - it('returns metadataId when default metadata file is found', async () => { - const osfComponent = 'testComponent'; - const osfToken = 'testToken'; - - - - const result = await processMetadata(osfComponent, osfToken); - - expect(result).toEqual({ success: true, errorCode: null, errorText: null, metadataId: '123' }); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it('returns error when metadata file is not found', async () => { - fetch.mockImplementationOnce(() => Promise.resolve({ - json: () => Promise.resolve({ data: [{ attributes: { name: 'other-file.json' }, id: 'osfstorage/123' }] }), - })); - - const osfComponent = 'testComponent'; - const osfToken = 'testToken'; - - const result = await processMetadata(osfComponent, osfToken); - - expect(result).toEqual({ success: false, errorCode: 404, errorText: 'Metadata file not found', metadataString: null }); - expect(fetch).toHaveBeenCalledTimes(1); - }); -}); \ No newline at end of file diff --git a/functions/src/__tests__/metadata-ref-emulator.test.js b/functions/src/__tests__/metadata-ref-emulator.test.js new file mode 100644 index 0000000..05ae5d2 --- /dev/null +++ b/functions/src/__tests__/metadata-ref-emulator.test.js @@ -0,0 +1,366 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 3b (docs/provider-migration-design.md, +// scratchpad/step3b-metadata-ref-spec.md), cases 3-8 of the test plan. +// +// Follows the inline-mock-OSF-server pattern established by +// collision-integration-emulator.test.js: a self-contained express server +// started on an OS-assigned port (`listen(0)`), with in-process (not HTTP) +// controls for call counts and forced statuses, rather than extending the +// shared fixed-port mock-server.ts used by metadata-emulator.test.js. That +// server can only express a single fixed GET/PUT pair; this test plan needs +// a controllable listing body (case 5), an update route keyed by file id +// (cases 4/5/7), and forced statuses on specific ids (case 7's 404), none of +// which mock-server.ts supports. mock-server.ts itself is untouched. +// +// Until metadata-block.ts is rewritten to read/store metadataFileRef, most +// assertions below fail on missing/incorrect behavior -- metadataFileRef +// never appears on the metadata doc, the 210-bug branch always throws +// (case 6), stale refs are never retried via self-heal (case 7) -- rather +// than on any transport-level or setup problem. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const OWNER_ID = "metadata-ref-owner"; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +// A valid Psych-DS-shaped metadata object. metadata-update.ts's updateMetadata +// (the real, unmocked production function) reads `variableMeasured` and +// throws "Invalid metadata format" on anything else, so seeded firestore +// metadata must have this shape or these tests would fail for the wrong +// reason (an unrelated throw, not the behavior under test). +const existingMetadata = { variableMeasured: [{ name: "existing_var" }] }; + +async function saveData(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apidata", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const message = await response.json(); + return { status: response.status, body: message }; +} + +// A minimal, self-contained mock OSF "files" container: +// GET /files -> osfProvider.listFiles (?meta=) +// PUT /files -> osfProvider.writeSessionFile / putFileOSF (create) +// PUT /files/:id -> osfProvider.updateFile / updateFileOSF (update) +// filesLink is handed out with a trailing slash so that update-file-osf.ts's +// `${osfComponent}${fileId}` concatenation produces `/files/` rather than +// `/files`. +function createMockOSFServer() { + const app = express(); + + let listingContents = []; + let nextId = 1; + const createCallsByFilename = new Map(); + const updateCallsById = new Map(); + let listingCallCount = 0; + const forcedCreateStatus = new Map(); + const forcedUpdateStatus = new Map(); + + app.get("/files", (req, res) => { + listingCallCount += 1; + res.json({ data: listingContents }); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + createCallsByFilename.set(filename, (createCallsByFilename.get(filename) || 0) + 1); + + const forced = forcedCreateStatus.get(filename); + if (forced && forced !== 201) { + res.status(forced).json({ errors: [{ detail: `mock-forced-create-${forced}` }] }); + return; + } + + const id = `mock-file-${nextId++}`; + res.status(201).json({ data: { id, attributes: { name: filename, kind: "file" } } }); + }); + + app.put("/files/:id", (req, res) => { + const id = req.params.id; + updateCallsById.set(id, (updateCallsById.get(id) || 0) + 1); + + const forced = forcedUpdateStatus.get(id); + if (forced && forced !== 200) { + res.status(forced).json({ errors: [{ detail: `mock-forced-update-${forced}` }] }); + return; + } + + res.status(200).json({}); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + setListing: (files) => { + listingContents = files; + }, + getCreateCount: (filename) => createCallsByFilename.get(filename) || 0, + getUpdateCount: (id) => updateCallsById.get(id) || 0, + getTotalUpdateCount: () => + Array.from(updateCallsById.values()).reduce((a, b) => a + b, 0), + getListingCallCount: () => listingCallCount, + forceCreateStatus: (filename, status) => forcedCreateStatus.set(filename, status), + forceUpdateStatus: (id, status) => forcedUpdateStatus.set(id, status), + // Clears call counters only, keeping listing contents and forced + // statuses -- used mid-test to isolate a "second call" assertion + // from setup calls that came before it (see case 4). + resetCounts: () => { + createCallsByFilename.clear(); + updateCallsById.clear(); + listingCallCount = 0; + }, + // Full reset between tests: counters, listing contents, and forced + // statuses. + reset: () => { + listingContents = []; + createCallsByFilename.clear(); + updateCallsById.clear(); + listingCallCount = 0; + forcedCreateStatus.clear(); + forcedUpdateStatus.clear(); + }, + }); + }); + }); +} + +let db; +let mockOSF; +let filesLink; + +beforeAll(async () => { + mockOSF = await createMockOSFServer(); + filesLink = `http://localhost:${mockOSF.port}/files/`; + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); +}); + +afterEach(() => { + mockOSF.reset(); +}); + +afterAll(async () => { + mockOSF.server.close(); +}); + +async function createExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: true, + owner: OWNER_ID, + osfFilesLink: filesLink, + ...overrides, + }); +} + +describe("3. first metadata-active submission (no metadata anywhere)", () => { + it("succeeds and stores metadata AND metadataFileRef with id + name", async () => { + const experimentID = `metadata-ref3-${randomUUID()}`; + const filename = `case3-${randomUUID()}.json`; + await createExperiment(experimentID); + // No metadata doc created at all -- firestoreMetadata and + // metadataFileRef are both genuinely absent. + mockOSF.setListing([]); // provider has no dataset_description.json either + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadata).toBeDefined(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef).not.toBeNull(); + expect(typeof metadataDoc.metadataFileRef.id).toBe("string"); + expect(metadataDoc.metadataFileRef.id.length).toBeGreaterThan(0); + expect(metadataDoc.metadataFileRef.name).toBe("dataset_description.json"); + + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); + }); +}); + +describe("4. second submission updates the existing metadata file via its stored ref", () => { + it("PUTs to the ref'd file id and makes no discovery listing call", async () => { + const experimentID = `metadata-ref4-${randomUUID()}`; + const existingMetaId = `preexisting-meta-${randomUUID()}`; + await createExperiment(experimentID); + + // Pre-seed a steady-state metadata doc: metadata AND metadataFileRef + // already established, as if a prior submission had already created + // them. This isolates "second submission" behavior without depending on + // case 3's flow. + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: { id: existingMetaId, name: "dataset_description.json" }, + }); + + // Warm the collision cache first: this experiment's very first + // submission triggers collision-cache rehydration (collision-cache.ts), + // which calls provider.listFiles once -- a GET entirely unrelated to + // metadata discovery, but indistinguishable from one on the wire (same + // endpoint). Run it here, then reset counters, so the assertion below + // about "no discovery listing" isn't polluted by this unrelated GET. + const warmResponse = await saveData({ + experimentID, + data: sampleData, + filename: `case4-warm-${randomUUID()}.json`, + }); + expect(warmResponse.status).toBe(201); + mockOSF.resetCounts(); + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case4-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(mockOSF.getUpdateCount(existingMetaId)).toBe(1); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(0); + expect(mockOSF.getListingCallCount()).toBe(0); + }); +}); + +describe("5. legacy discovery: metadata doc has metadata but no metadataFileRef field", () => { + it("finds the existing dataset_description.json via listing, updates it (not create), and stores the discovered ref", async () => { + const experimentID = `metadata-ref5-${randomUUID()}`; + const legacyId = `legacy-meta-${randomUUID()}`; + await createExperiment(experimentID); + + // Pre-seed metadata WITHOUT a metadataFileRef field at all -- distinct + // from explicit null (case 6). This is the shape a pre-migration, + // still-active legacy experiment would have. + await db.collection("metadata").doc(experimentID).set({ metadata: existingMetadata }); + + mockOSF.setListing([ + { attributes: { name: "dataset_description.json", kind: "file" }, id: legacyId }, + { attributes: { name: "some-session-data.json", kind: "file" }, id: `${legacyId}-other` }, + ]); + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case5-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(mockOSF.getUpdateCount(legacyId)).toBe(1); + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(0); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toEqual({ id: legacyId, name: "dataset_description.json" }); + }); +}); + +describe("6. 210-bug resurrection: metadataFileRef explicitly null, no metadata file on the provider", () => { + it("creates the metadata file and succeeds (previously always failed with METADATA_ERROR)", async () => { + const experimentID = `metadata-ref6-${randomUUID()}`; + await createExperiment(experimentID); + + // metadataFileRef is explicitly null here -- distinct from case 5's + // "field absent" -- meaning "we already looked, it's known absent; + // don't list again." + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: null, + }); + + mockOSF.setListing([]); // provider genuinely has no metadata file + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case6-${randomUUID()}.json`, + }); + + // Today, this always fails: metadata-block.ts's create branch checks + // `status !== 210` on a response that never returns 210, so it throws + // MESSAGES.OSF_UPLOAD_ERROR unconditionally, even though the upload + // itself succeeded. + expect(response.status).toBe(201); + + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef).not.toBeNull(); + expect(metadataDoc.metadataFileRef.name).toBe("dataset_description.json"); + }); +}); + +describe("7. self-heal: stored ref points at a file the mock 404s on update", () => { + it("creates a fresh metadata file and stores the new ref without failing the request", async () => { + const experimentID = `metadata-ref7-${randomUUID()}`; + const staleId = `stale-meta-${randomUUID()}`; + await createExperiment(experimentID); + + await db.collection("metadata").doc(experimentID).set({ + metadata: existingMetadata, + metadataFileRef: { id: staleId, name: "dataset_description.json" }, + }); + + mockOSF.forceUpdateStatus(staleId, 404); // provider-side file was deleted + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case7-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(mockOSF.getUpdateCount(staleId)).toBe(1); // the failed update attempt + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(1); // the self-heal create + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef.id).not.toBe(staleId); + expect(metadataDoc.metadataFileRef.name).toBe("dataset_description.json"); + }); +}); + +describe("8. skip-metadata behavior (metadataActive false) is unchanged", () => { + it("does not create a metadata document or a metadataFileRef when metadataActive is false", async () => { + const experimentID = `metadata-ref8-${randomUUID()}`; + await createExperiment(experimentID, { metadataActive: false }); + + const response = await saveData({ + experimentID, + data: sampleData, + filename: `case8-${randomUUID()}.json`, + }); + + expect(response.status).toBe(201); + expect(response.body.metadataMessage).toBeFalsy(); + + const metadataDoc = await db.collection("metadata").doc(experimentID).get(); + expect(metadataDoc.exists).toBe(false); + + expect(mockOSF.getCreateCount("dataset_description.json")).toBe(0); + expect(mockOSF.getTotalUpdateCount()).toBe(0); + }); +}); diff --git a/functions/src/__tests__/put-file-osf-ref.test.js b/functions/src/__tests__/put-file-osf-ref.test.js new file mode 100644 index 0000000..c59c295 --- /dev/null +++ b/functions/src/__tests__/put-file-osf-ref.test.js @@ -0,0 +1,194 @@ +// RED-phase unit tests for step 3b (docs/provider-migration-design.md, +// scratchpad/step3b-metadata-ref-spec.md), cases 1-2 of the test plan. +// +// putFileOSF currently discards the 201 response body entirely (see +// put-file-osf.ts: it never calls `.json()` on a successful upload). This +// step extends it to parse `{ data: { id, attributes: { name } } }` and +// surface `fileId`/`fileName`, and extends osfProvider.writeSessionFile to +// pass those through as `fileRef`/`storedFilename`. Written as a new file +// (providers-osf.test.js is left untouched per the RED-phase instructions) +// but following its mocking style exactly: put-file-osf.ts/update-file-osf.ts +// import their own `fetch` from "node-fetch" rather than using globalThis +// fetch, so "node-fetch" is mocked at the module level, and the compiled +// lib/ output is imported (same convention providers-osf.test.js and +// metadata-process.test.js use) rather than the .ts source. +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import putFileOSF from "../../lib/put-file-osf.js"; +import { osfProvider } from "../../lib/providers/osf.js"; + +function mockResponse({ status, statusText, retryAfter = null, jsonBody, jsonError }) { + return { + status, + statusText, + headers: { + get: (header) => (header === "Retry-After" ? retryAfter : null), + }, + json: () => (jsonError ? Promise.reject(jsonError) : Promise.resolve(jsonBody)), + }; +} + +describe("putFileOSF response parsing (case 1)", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("parses fileId and fileName from a well-formed 201 body", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { id: "osfstorage/abc123", attributes: { name: "file.json" } } }, + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result).toEqual({ + success: true, + errorCode: null, + errorText: null, + fileId: "osfstorage/abc123", + fileName: "file.json", + }); + }); + + it("tolerates a 201 body containing only attributes.name (no id)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { attributes: { name: "file.json" } } }, + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result.success).toBe(true); + expect(result.fileName).toBe("file.json"); + expect(result.fileId).toBeUndefined(); + }); + + it("tolerates a missing/unparseable body on a successful upload without throwing", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonError: new SyntaxError("Unexpected end of JSON input"), + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result.success).toBe(true); + expect(result.fileId).toBeUndefined(); + expect(result.fileName).toBeUndefined(); + }); + + it("does not attempt to parse a body on a non-201 (failure) response", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 409, + statusText: "Conflict", + // If implementation code ever called .json() on a failure response, + // this rejection would surface as an unhandled/uncaught test failure. + jsonError: new Error("json() should not be called on a failed upload"), + }) + ); + + const result = await putFileOSF("https://osf.io/abc123/", "test-token", "data", "file.json"); + + expect(result).toEqual({ + success: false, + errorCode: 409, + errorText: "Conflict", + retryAfter: null, + }); + }); +}); + +describe("osfProvider.writeSessionFile fileRef surfacing (case 2)", () => { + const auth = { token: "test-token" }; + const container = { provider: "osf", filesLink: "https://osf.io/abc123/" }; + + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("surfaces fileRef.id/name parsed from the 201 body, and storedFilename from the parsed name", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { id: "osfstorage/xyz789", attributes: { name: "renamed.json" } } }, + }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "requested.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "renamed.json", id: "osfstorage/xyz789" }, + storedFilename: "renamed.json", + }); + }); + + it("falls back to the requested filename when the body has no name, but keeps the parsed id", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { data: { id: "osfstorage/xyz789" } }, + }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "requested.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "requested.json", id: "osfstorage/xyz789" }, + storedFilename: "requested.json", + }); + }); + + it("falls back to the requested filename and an undefined id when the body is unparseable", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonError: new SyntaxError("Unexpected end of JSON input"), + }) + ); + + const result = await osfProvider.writeSessionFile( + auth, + container, + "requested.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { name: "requested.json", id: undefined }, + storedFilename: "requested.json", + }); + }); +}); diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index a57fff2..b87f4dc 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -1,5 +1,4 @@ import MESSAGES from "./api-messages.js"; -import processMetadata from "./metadata-process.js"; import updateMetadata from "./metadata-update.js"; import produceMetadata from "./metadata-production.js"; import downloadMetadata from "./metadata-download.js"; @@ -8,6 +7,7 @@ import { db } from "./app.js"; import { decrypt } from "./crypto-utils.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; import { osfProvider } from "./providers/osf.js"; +import { FileRef } from "./providers/types.js"; import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; @@ -56,63 +56,101 @@ try { //Retrieves the metadata from the Firestore metadata document. const firestoreMetadataObj: DocumentData | undefined = (await t.get(metadata_doc_ref)).data(); - + const firestoreMetadata: Metadata | undefined = firestoreMetadataObj ? firestoreMetadataObj.metadata : undefined; - - //Retrieves the metadata ID from the OSF metadata file. If an ID exists, then a metadata file with name: - //dataset_description.json exists in the OSF project. - const osfMetadataId: string | undefined = (await processMetadata(exp_data.osfFilesLink, decryptedOsfToken)).metadataId; - - //When firestore and OSF both have metadata, updating is done with respect to firestore. - //When firestore has metadata but OSF does not, updating is done with respect to firestore. - if ( (osfMetadataId && firestoreMetadata) || (!osfMetadataId && firestoreMetadata) ) { - - // Sets the metadata message. - if (osfMetadataId) metadataMessage = MESSAGES.METADATA_IN_OSF_AND_FIRESTORE; - else metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; - // Incoming metadata is used to update firestore metadata. - const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); + // The metadata file's provider ref, tracked on the metadata doc. + // - undefined: the field has never been written (pre-migration doc, or + // no doc at all) — distinct from explicit null. + // - null: known absent — a prior request already looked and found + // nothing, so we must not list the provider folder again. + // - FileRef: a metadata file is known to exist at this id/name. + let metadataFileRef: FileRef | null | undefined = firestoreMetadataObj + ? (firestoreMetadataObj.metadataFileRef as FileRef | null | undefined) + : undefined; + + // Legacy discovery fallback: only runs once, for docs that predate ref + // tracking. Afterward the ref (possibly null) is stored so this never + // runs again for this experiment. + if (metadataFileRef === undefined) { + const providerFiles = await osfProvider.listFiles( + { token: decryptedOsfToken }, + { provider: "osf", filesLink: exp_data.osfFilesLink } + ); - t.update(metadata_doc_ref, {metadata: updatedMetadata}); + const found = providerFiles.find((file) => file.name === "dataset_description.json"); - //If a metadata file exists in OSF, it is updated with the above metadata. - if (osfMetadataId){ - await osfProvider.updateFile( + metadataFileRef = found ?? null; + + t.set(metadata_doc_ref, { metadataFileRef }, { merge: true }); + } + + // Creates a fresh dataset_description.json and stores the returned ref + // on the metadata doc. Used both for the "no ref" branches below and + // for self-healing a stale ref whose provider-side file is gone. + async function createMetadataFile(payload: object) { + const serialized = JSON.stringify(payload, null, 2); + + const response = await osfProvider.writeSessionFile( { token: decryptedOsfToken }, { provider: "osf", filesLink: exp_data.osfFilesLink }, - { id: osfMetadataId, name: "dataset_description.json" }, - JSON.stringify(updatedMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } - ) + `dataset_description.json`, + serialized, + { size: Buffer.byteLength(serialized), contentType: "application/json" } + ); + if (!response.success) { + throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); } - //If a metadata file does not exist in OSF, it is created with the above metadata. - else { - const response = await osfProvider.writeSessionFile( + // Only track a ref we can actually use later. If the provider's 201 + // body was unparseable, fileRef.id is undefined — storing that would + // either fail the Firestore write or persist an un-updatable ref + // (whose self-heal re-create would then 409 forever). Leaving the + // field unset instead lets the next submission's legacy-discovery + // listing find the file and store a complete ref. + if (response.fileRef.id) { + t.set(metadata_doc_ref, { metadataFileRef: response.fileRef }, { merge: true }); + } + } + + //When a ref and firestore metadata both exist, updating is done with respect to firestore. + if (metadataFileRef && firestoreMetadata) { + + metadataMessage = MESSAGES.METADATA_IN_OSF_AND_FIRESTORE; + + // Incoming metadata is used to update firestore metadata. + const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); + + t.update(metadata_doc_ref, {metadata: updatedMetadata}); + + const serialized = JSON.stringify(updatedMetadata, null, 2); + + try { + //The ref'd metadata file is updated with the above metadata. + await osfProvider.updateFile( { token: decryptedOsfToken }, { provider: "osf", filesLink: exp_data.osfFilesLink }, - `dataset_description.json`, - JSON.stringify(updatedMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(updatedMetadata, null, 2)), contentType: "application/json" } + metadataFileRef, + serialized, + { size: Buffer.byteLength(serialized), contentType: "application/json" } ); - - // Latent pre-existing bug preserved verbatim — removed in build step 3 (see design doc, "Collision detection") - const status = response.success ? null : response.providerStatus; - if (status !== 210) throw new Error(MESSAGES.OSF_UPLOAD_ERROR.message); - + } catch (e) { + // Self-heal: the ref is stale (the file was deleted provider-side). + // Create a fresh metadata file and store its new ref rather than + // failing the whole request. + await createMetadataFile(updatedMetadata); } } - //When OSF has metadata but firestore does not, updating is done with respect to OSF. - if (osfMetadataId && !firestoreMetadata) { + //When a ref exists but firestore does not have metadata, updating is done with respect to OSF. + else if (metadataFileRef && !firestoreMetadata) { metadataMessage = MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE; //Metadata is downloaded from OSF, and is compared to incoming metadata to produce an updated version. // ********[IMPORTANT]*********** - // Since Metadata is in OSF as evidenced by the metadata ID, it is downloaded, and the type is asserted. - const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, osfMetadataId); + // Since Metadata is in OSF as evidenced by the ref, it is downloaded, and the type is asserted. + const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, metadataFileRef.id as string); const osfMetadata: Metadata = downloadResponse.metadata; @@ -125,14 +163,27 @@ try { await osfProvider.updateFile( { token: decryptedOsfToken }, { provider: "osf", filesLink: exp_data.osfFilesLink }, - { id: osfMetadataId, name: "dataset_description.json" }, + metadataFileRef, JSON.stringify(incomingMetadata, null, 2), { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } ); } - // When neither OSF nor firestore have metadata, the metadata is created in OSF and firestore. - if (!osfMetadataId && !firestoreMetadata) { + // When no ref exists but firestore has metadata, the metadata file is (re)created in OSF. + else if (!metadataFileRef && firestoreMetadata) { + + metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; + + // Incoming metadata is used to update firestore metadata. + const updatedMetadata = await updateMetadata(firestoreMetadata, incomingMetadata); + + t.update(metadata_doc_ref, {metadata: updatedMetadata}); + + //If a metadata file does not exist in OSF, it is created with the above metadata. + await createMetadataFile(updatedMetadata); + } + // When neither a ref nor firestore metadata exist, the metadata is created in OSF and firestore. + else { metadataMessage = MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; @@ -140,13 +191,7 @@ try { t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); - await osfProvider.writeSessionFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, - `dataset_description.json`, - JSON.stringify(incomingMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } - ); + await createMetadataFile(incomingMetadata); } }); diff --git a/functions/src/metadata-process.ts b/functions/src/metadata-process.ts deleted file mode 100644 index 9839d21..0000000 --- a/functions/src/metadata-process.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { OSFFile } from './interfaces'; - -export default async function processMetadata( - osfComponent: string, - osfToken: string, -) { - //Gets the metadata of the data storage element in the OSF project. - try { - const osfResult = await fetch(`${osfComponent}?meta=`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - } - }); - - const folder = await osfResult.json(); //Gets the json portion of the response - - // The JSON portion has a property called 'data' that contains an array of objects, each of which - // corresponds to a data file in the OSF project. We access this array. - const listOfFiles: OSFFile[]= folder['data']; - - - // Every file object has an 'attributes' property which contains an object of information about the file, - // including a name property. We use this to find the file object of the metadata file. - const metadataFile: OSFFile[] = listOfFiles.filter((file) => file.attributes.name === `dataset_description.json`); - - // Return error if no file with the name 'dataset-description.json' is found. - if (metadataFile.length === 0) { - return { success: false, errorCode: 404, errorText: 'Metadata file not found', metadataString: null}; - } - - // Since filter returns a list, we access the first object and access the id property, which contains the - // unique id needed to access the file. The string comes with an osfstorage/ prefix that we remove. - const metadataId: string = metadataFile[0].id.replace('osfstorage/', ''); - - return { success: true, errorCode: null, errorText: null, metadataId: metadataId}; -} -catch (error) { - let errorMessage: string; - - if (error instanceof Error) errorMessage = error.message; - - else errorMessage = 'An unknown error occurred'; - - throw Error(`Error processing metadata with code: 400, and message: ${errorMessage}`)} -} - diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index fabcfad..ad587fd 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -60,10 +60,11 @@ export const osfProvider: StorageProvider = { const result = await putFileOSF(osfContainer.filesLink, auth.token, data, filename); if (result.success) { + const storedFilename = result.fileName ?? filename; return { success: true, - fileRef: { name: filename }, - storedFilename: filename, + fileRef: { name: storedFilename, id: result.fileId }, + storedFilename, }; } diff --git a/functions/src/put-file-osf.ts b/functions/src/put-file-osf.ts index df87782..536fa26 100644 --- a/functions/src/put-file-osf.ts +++ b/functions/src/put-file-osf.ts @@ -51,5 +51,19 @@ export default async function putFileOSF( return { success: false, errorCode: osfResult.status, errorText: osfResult.statusText, retryAfter }; } - return { success: true, errorCode: null, errorText: null }; + // Parse the created-file reference out of the response body. Tolerate a + // missing/unparseable body — the upload itself already succeeded (status + // 201), so a body we can't read must never turn this into a failure. + let fileId: string | undefined; + let fileName: string | undefined; + try { + const body = (await osfResult.json()) as { data?: { id?: string; attributes?: { name?: string } } }; + fileId = body?.data?.id; + fileName = body?.data?.attributes?.name; + } catch { + fileId = undefined; + fileName = undefined; + } + + return { success: true, errorCode: null, errorText: null, fileId, fileName }; } From bc3c8a37310cf837ebb07cf046f5d52be84020e1 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 22 Jul 2026 19:31:41 -0400 Subject: [PATCH 032/181] feat: Google Drive adapter and full provider generalization of the write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 4a of the provider migration — the first non-OSF provider, proving the abstraction end-to-end for writes, collision cache, metadata, and the retry queue. - providers/gdrive.ts: app-created DataPipe root folder + per-experiment subfolders, hand-built multipart uploads, media-PATCH updates, paginated listFiles (fails loudly on listing errors — Drive has no 409 backstop, so rehydration completeness is load-bearing), alt=media downloads, and reason-based Drive error mapping (never NAME_CONFLICT: the collision cache is the only duplicate gate) - StorageProvider gains downloadFile; OSF adapter implements it and metadata-download.ts is deleted - resolveToken dispatches on storageProvider: gdrive path reads connectedAccounts.gdrive, refreshes via GDRIVE_TOKEN_URL with rotation, persists tokens encrypted; PROVIDER_NOT_CONNECTED added to api-messages - metadata-block fully provider-generalized (shared resolveToken replaces its duplicated inline OSF token logic); self-heal handles both provider failure styles but refuses to self-heal on AUTH_EXPIRED / RATE_LIMITED / QUOTA_EXCEEDED - queue docs carry storageProvider/providerContainer (legacy OSF fallback preserved); scheduled-upload-retry parameterized accordingly - GDRIVE_API_BASE/GDRIVE_TOKEN_URL env overrides (call-time reads); functions/.env.datapipe-test wires the emulator to the tests' mock Drive server TDD: 17 unit + 5 integration contract tests reviewed red first. Full emulator suite green (24 suites, 174 tests). Co-Authored-By: Claude Fable 5 --- functions/.env.datapipe-test | 2 + .../src/__tests__/gdrive-emulator.test.js | 533 +++++++++++++++ .../src/__tests__/providers-gdrive.test.js | 640 ++++++++++++++++++ .../__tests__/resolve-token-gdrive.test.js | 286 ++++++++ functions/src/api-base64.ts | 4 + functions/src/api-data.ts | 4 + functions/src/api-messages.ts | 4 + functions/src/interfaces.ts | 8 +- functions/src/metadata-block.ts | 147 ++-- functions/src/metadata-download.ts | 59 -- functions/src/providers/gdrive.ts | 370 ++++++++++ functions/src/providers/index.ts | 3 + functions/src/providers/osf.ts | 28 + functions/src/providers/types.ts | 21 + functions/src/queue-upload.ts | 32 +- functions/src/resolve-token.ts | 89 ++- functions/src/scheduled-upload-retry.ts | 22 +- 17 files changed, 2118 insertions(+), 134 deletions(-) create mode 100644 functions/.env.datapipe-test create mode 100644 functions/src/__tests__/gdrive-emulator.test.js create mode 100644 functions/src/__tests__/providers-gdrive.test.js create mode 100644 functions/src/__tests__/resolve-token-gdrive.test.js delete mode 100644 functions/src/metadata-download.ts create mode 100644 functions/src/providers/gdrive.ts diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test new file mode 100644 index 0000000..aad45d6 --- /dev/null +++ b/functions/.env.datapipe-test @@ -0,0 +1,2 @@ +GDRIVE_API_BASE=http://127.0.0.1:3579 +GDRIVE_TOKEN_URL=http://127.0.0.1:3579/token diff --git a/functions/src/__tests__/gdrive-emulator.test.js b/functions/src/__tests__/gdrive-emulator.test.js new file mode 100644 index 0000000..1487e25 --- /dev/null +++ b/functions/src/__tests__/gdrive-emulator.test.js @@ -0,0 +1,533 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 4a (docs/provider-migration-design.md, +// scratchpad/step4a-gdrive-adapter-spec.md), cases 12-16 of the test plan. +// +// These exercise the deployed-in-emulator apidata function end to end +// against a mock Google Drive server, following the fixed-port convention +// documented in the spec: a self-contained express server bound to +// 127.0.0.1:3579 (not an OS-assigned port), because functions/.env.datapipe- +// test (created alongside this file -- see the build-step instructions) sets +// GDRIVE_API_BASE=http://127.0.0.1:3579 for the *functions emulator process* +// to read. Verified empirically: starting `firebase emulators:exec --project +// datapipe-test` logs "functions: Loaded environment variables from .env, +// .env.datapipe-test." -- confirming firebase-tools' documented +// .env. loading (functions/node_modules/firebase-tools/lib/ +// functions/env.js's findEnvfiles: [".env", `.env.${projectId}`, ...]) picks +// this file up with no further plumbing needed. This is a genuinely fixed +// port (unlike collision-integration-emulator.test.js's listen(0) pattern) +// because the emulator-hosted gdrive adapter has no other way to discover +// where the mock server lives. +// +// None of cases 12-16 exercise real token encryption: the seeded +// connectedAccounts.gdrive.encryptedToken values below are bare plaintext +// strings, relying on crypto-utils.ts's decrypt() plaintext fallback (no +// "v1:" prefix -> returned unchanged) -- exactly like the existing OSF +// emulator tests seed osfToken: "valid". This sidesteps needing the jest +// process and the functions-emulator child process to agree on +// TOKEN_ENCRYPTION_KEY (they're different processes; only the latter loads +// functions/.env). The real encrypt/decrypt round-trip through a refresh is +// covered directly, in-process, by resolve-token-gdrive.test.js's case 9. +// +// Until gdrive.ts exists, is registered in providers/index.ts, and +// resolve-token.ts dispatches on storageProvider === "gdrive", every gdrive +// experiment request in cases 12-15 fails long before it reaches the mock +// server: getProviderForExperiment throws "Unknown storage provider: +// gdrive" (registry.ts already throws that for any unregistered id), so +// apidata's response is never the expected 201/202 -- a missing-behavior +// failure, not a mock-server/transport bug. Case 15 additionally depends on +// queue-upload.ts/api-data.ts passing storageProvider/providerContainer +// through to the queue doc (the "Queue generalization" section of the spec), +// which also doesn't exist yet. +// +// Case 16 is a regression guard: a legacy (storageProvider-less) OSF +// experiment, using the same OS-assigned-port inline-mock-OSF-server pattern +// as collision-integration-emulator.test.js. It is expected to PASS today -- +// the gdrive generalization must not be able to break it. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const GDRIVE_OWNER_ID = "gdrive-emulator-owner"; +const OSF_OWNER_ID = "gdrive-emulator-osf-owner"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; +const DRIVE_PORT = 3579; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +async function saveData(body) { + const response = await fetch("http://localhost:5001/datapipe-test/us-central1/apidata", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + // An uncaught exception in the function (e.g. today's "Unknown storage + // provider: gdrive") produces a plain-text "Internal Server Error" body, + // not JSON. Parsing defensively keeps assertions on `status` failing + // cleanly (e.g. "Expected 201, Received 500") instead of the test itself + // throwing a SyntaxError out of response.json() -- that would obscure a + // real missing-behavior red as an apparent test-harness bug. + const text = await response.text(); + let message; + try { + message = JSON.parse(text); + } catch { + message = { rawBody: text }; + } + return { status: response.status, body: message }; +} + +// Parses a Drive `q` filter string just far enough to support the shapes +// this adapter is spec'd to send: a parent clause ('' in parents), an +// exact-name clause (name='...'), and an optional folder-mimeType clause. +function parseQuery(q) { + const nameMatch = /name\s*=\s*'([^']*)'/.exec(q || ""); + const parentMatch = /'([^']*)'\s+in\s+parents/.exec(q || ""); + const folderOnly = /mimeType\s*=\s*'application\/vnd\.google-apps\.folder'/.test(q || ""); + return { + name: nameMatch ? nameMatch[1] : null, + parent: parentMatch ? parentMatch[1] : null, + folderOnly, + }; +} + +// "regex or simple string split on the boundary is fine" per the spec -- +// this splits on the boundary and picks out whichever part declares itself +// application/json as the metadata part. +function parseMultipartMetadata(bodyBuffer, contentTypeHeader) { + const boundaryMatch = /boundary=("?)([^;"]+)\1/.exec(contentTypeHeader || ""); + if (!boundaryMatch) return null; + const boundary = boundaryMatch[2]; + const parts = bodyBuffer.toString("utf8").split(`--${boundary}`); + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, ""); + if (!part || part.startsWith("--")) continue; + if (!/Content-Type:\s*application\/json/i.test(part)) continue; + const sep = part.indexOf("\r\n\r\n"); + if (sep === -1) continue; + try { + return JSON.parse(part.slice(sep + 4).trim()); + } catch { + return null; + } + } + return null; +} + +// Companion to parseMultipartMetadata: extracts the *other* part (the +// payload being uploaded), so the mock can actually store it and serve it +// back via alt=media. +function parseMultipartDataPart(bodyBuffer, contentTypeHeader) { + const boundaryMatch = /boundary=("?)([^;"]+)\1/.exec(contentTypeHeader || ""); + if (!boundaryMatch) return { content: "", contentType: "application/octet-stream" }; + const boundary = boundaryMatch[2]; + const parts = bodyBuffer.toString("utf8").split(`--${boundary}`); + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, ""); + if (!part || part.startsWith("--")) continue; + if (/Content-Type:\s*application\/json/i.test(part)) continue; // that's the metadata part + const sep = part.indexOf("\r\n\r\n"); + if (sep === -1) continue; + const headerBlock = part.slice(0, sep); + const content = part.slice(sep + 4).replace(/\r\n$/, ""); + const ctMatch = /Content-Type:\s*([^\r\n]+)/i.exec(headerBlock); + return { content, contentType: ctMatch ? ctMatch[1].trim() : "application/octet-stream" }; + } + return { content: "", contentType: "application/octet-stream" }; +} + +// A self-contained mock Google Drive: a fixed-id filesById store, plus the +// counters/controls the build-step spec calls for (per-name upload +// counters, forceStatus(name-or-id, status), reset()). +function createMockDriveServer() { + const app = express(); + // A single raw-body parser regardless of Content-Type -- multipart/related + // and the media-PATCH's raw bytes both need the untouched body; JSON + // routes parse it themselves. + app.use(express.raw({ type: () => true, limit: "20mb" })); + + const filesById = new Map(); + const uploadCountsByName = new Map(); + const updateCountsById = new Map(); + const forcedStatus = new Map(); + let nextSeq = 1; + + function forcedFor(key) { + const status = forcedStatus.get(key); + return status && status !== 200 && status !== 201 ? status : null; + } + + app.get("/drive/v3/files", (req, res) => { + const { name, parent, folderOnly } = parseQuery(req.query.q); + let matches = Array.from(filesById.values()).filter((f) => { + if (parent && !f.parents.includes(parent)) return false; + if (name && f.name !== name) return false; + if (folderOnly && f.mimeType !== FOLDER_MIME) return false; + return true; + }); + matches.sort((a, b) => a.__seq - b.__seq); + + const pageSize = parseInt(req.query.pageSize, 10) || matches.length || 1; + const offset = req.query.pageToken ? parseInt(req.query.pageToken, 10) : 0; + const page = matches.slice(offset, offset + pageSize); + const nextPageToken = offset + pageSize < matches.length ? String(offset + pageSize) : undefined; + + const body = { files: page.map((f) => ({ id: f.id, name: f.name, mimeType: f.mimeType })) }; + if (nextPageToken) body.nextPageToken = nextPageToken; + res.status(200).json(body); + }); + + app.post("/drive/v3/files", (req, res) => { + let payload; + try { + payload = JSON.parse(req.body.toString("utf8")); + } catch { + res.status(400).json({ errors: [{ reason: "badRequest", message: "invalid JSON body" }] }); + return; + } + const forced = forcedFor(payload.name); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + const id = `mock-folder-${nextSeq}`; + filesById.set(id, { + id, + name: payload.name, + mimeType: payload.mimeType, + parents: payload.parents || [], + content: "", + contentType: payload.mimeType, + __seq: nextSeq++, + }); + res.status(200).json({ id, name: payload.name }); + }); + + app.post("/upload/drive/v3/files", (req, res) => { + if (req.query.uploadType !== "multipart") { + res.status(400).json({ errors: [{ reason: "badRequest", message: "unsupported uploadType" }] }); + return; + } + const contentTypeHeader = req.headers["content-type"]; + const metadata = parseMultipartMetadata(req.body, contentTypeHeader); + if (!metadata || !metadata.name) { + res.status(400).json({ errors: [{ reason: "badRequest", message: "could not parse multipart metadata" }] }); + return; + } + uploadCountsByName.set(metadata.name, (uploadCountsByName.get(metadata.name) || 0) + 1); + + const forced = forcedFor(metadata.name); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + + const { content, contentType } = parseMultipartDataPart(req.body, contentTypeHeader); + const id = `mock-file-${nextSeq}`; + filesById.set(id, { + id, + name: metadata.name, + mimeType: contentType, + parents: metadata.parents || [], + content, + contentType, + __seq: nextSeq++, + }); + res.status(200).json({ id, name: metadata.name }); + }); + + app.patch("/upload/drive/v3/files/:id", (req, res) => { + if (req.query.uploadType !== "media") { + res.status(400).json({ errors: [{ reason: "badRequest", message: "unsupported uploadType" }] }); + return; + } + const id = req.params.id; + updateCountsById.set(id, (updateCountsById.get(id) || 0) + 1); + + const forced = forcedFor(id); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + + const contentType = req.headers["content-type"] || "application/octet-stream"; + const content = req.body.toString("utf8"); + const existing = filesById.get(id); + if (existing) { + existing.content = content; + existing.contentType = contentType; + } else { + filesById.set(id, { id, name: id, mimeType: contentType, parents: [], content, contentType, __seq: nextSeq++ }); + } + res.status(200).json({ id }); + }); + + app.get("/drive/v3/files/:id", (req, res) => { + if (req.query.alt !== "media") { + res.status(400).json({ errors: [{ reason: "badRequest", message: "unsupported alt" }] }); + return; + } + const id = req.params.id; + const forced = forcedFor(id); + if (forced) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + const file = filesById.get(id); + if (!file) { + res.status(404).json({ errors: [{ reason: "notFound", message: "no such file" }] }); + return; + } + res.status(200).type(file.contentType || "text/plain").send(file.content); + }); + + // "For completeness" per the spec -- not exercised by cases 12-16 (every + // seeded gdrive token is unexpired), but GDRIVE_TOKEN_URL points here. + app.post("/token", (req, res) => { + res.status(200).json({ access_token: "mock-refreshed-token", expires_in: 3600, token_type: "Bearer" }); + }); + + return new Promise((resolve) => { + const server = app.listen(DRIVE_PORT, () => { + resolve({ + server, + port: DRIVE_PORT, + getUploadCount: (name) => uploadCountsByName.get(name) || 0, + getUpdateCount: (id) => updateCountsById.get(id) || 0, + forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), + reset: () => { + filesById.clear(); + uploadCountsByName.clear(); + updateCountsById.clear(); + forcedStatus.clear(); + nextSeq = 1; + }, + }); + }); + }); +} + +// Minimal inline mock OSF server for case 16's regression guard, matching +// the OS-assigned-port pattern established by +// collision-integration-emulator.test.js (listen(0), in-process counters). +function createMockOSFServer() { + const app = express(); + const uploadCountsByFilename = new Map(); + + app.get("/files", (req, res) => { + res.json({ data: [] }); + }); + + app.put("/files", (req, res) => { + const filename = String(req.query.name || ""); + uploadCountsByFilename.set(filename, (uploadCountsByFilename.get(filename) || 0) + 1); + res.status(201).json({ + data: { attributes: { name: filename, kind: "file" }, id: "osfstorage/mock-upload" }, + }); + }); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + port: server.address().port, + getUploadCount: (filename) => uploadCountsByFilename.get(filename) || 0, + reset: () => uploadCountsByFilename.clear(), + }); + }); + }); +} + +let db; +let mockDrive; + +beforeAll(async () => { + mockDrive = await createMockDriveServer(); + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(GDRIVE_OWNER_ID).set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + encryptedToken: "gdrive-integration-token", // plaintext fallback, see header comment + encryptedRefreshToken: "gdrive-integration-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + providerAccountId: "gdrive-integration-acct", + }, + }, + }); +}); + +afterEach(() => { + mockDrive.reset(); +}); + +afterAll(() => { + mockDrive.server.close(); +}); + +async function createGdriveExperiment(experimentID, folderId, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: false, + owner: GDRIVE_OWNER_ID, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId }, + ...overrides, + }); +} + +describe("12. gdrive experiment: apidata POST succeeds and warms the collision cache", () => { + it("returns 201, records the upload in the mock, and leaves the experiment's collisionCache warm", async () => { + const experimentID = `gdrive-int12-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `case12-${randomUUID()}.json`; + await createGdriveExperiment(experimentID, folderId); + + const before = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + expect(mockDrive.getUploadCount(filename)).toBe(1); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache).toBeDefined(); + expect(typeof expDataAfter.collisionCache.salt).toBe("string"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(before); + }); +}); + +describe("13. gdrive experiment: duplicate filename is rejected without a second provider upload", () => { + it("first POST succeeds, second POST for the same filename gets OSF_FILE_EXISTS, and the mock received exactly one upload", async () => { + const experimentID = `gdrive-int13-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `dup-${randomUUID()}.json`; + await createGdriveExperiment(experimentID, folderId); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + // Message key is a historical "OSF_FILE_EXISTS" name (unchanged: the + // collision-cache duplicate check is entirely provider-agnostic in + // api-data.ts), not a claim that gdrive itself returned an OSF error. + expect(second.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + expect(mockDrive.getUploadCount(filename)).toBe(1); + }); +}); + +describe("14. metadata on gdrive", () => { + it("first submission creates dataset_description.json via multipart and stores the ref; second submission media-PATCHes that ref'd id with no second create", async () => { + const experimentID = `gdrive-int14-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + await createGdriveExperiment(experimentID, folderId, { metadataActive: true }); + + const first = await saveData({ + experimentID, + data: sampleData, + filename: `case14-a-${randomUUID()}.json`, + }); + expect(first.status).toBe(201); + expect(mockDrive.getUploadCount("dataset_description.json")).toBe(1); + + const metadataDocAfterFirst = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDocAfterFirst.metadataFileRef).toBeDefined(); + expect(metadataDocAfterFirst.metadataFileRef).not.toBeNull(); + const refId = metadataDocAfterFirst.metadataFileRef.id; + expect(typeof refId).toBe("string"); + expect(refId.length).toBeGreaterThan(0); + + const second = await saveData({ + experimentID, + data: sampleData, + filename: `case14-b-${randomUUID()}.json`, + }); + expect(second.status).toBe(201); + + expect(mockDrive.getUploadCount("dataset_description.json")).toBe(1); // still just the one create + expect(mockDrive.getUpdateCount(refId)).toBe(1); // the media PATCH from the second submission + }); +}); + +describe("15. provider failure queues the upload and tags it with the gdrive container", () => { + it("a mock-forced 500 on upload results in a 202 queued response, with the queue doc carrying claimToken + storageProvider + providerContainer", async () => { + const experimentID = `gdrive-int15-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `case15-${randomUUID()}.json`; + await createGdriveExperiment(experimentID, folderId); + + mockDrive.forceStatus(filename, 500); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(202); + expect(response.body).toEqual(expect.objectContaining({ ...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage: "" })); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueDoc = await db.collection("uploadQueue").doc(docId).get(); + expect(queueDoc.exists).toBe(true); + + const queueData = queueDoc.data(); + expect(typeof queueData.claimToken).toBe("string"); + expect(queueData.claimToken.length).toBeGreaterThan(0); + expect(queueData.storageProvider).toBe("gdrive"); + expect(queueData.providerContainer).toEqual({ provider: "gdrive", folderId }); + }); +}); + +describe("16. OSF experiment in the same run still works (legacy dispatch untouched, regression guard)", () => { + let mockOSF; + + beforeAll(async () => { + mockOSF = await createMockOSFServer(); + await db.collection("users").doc(OSF_OWNER_ID).set({ + osfTokenValid: true, + osfToken: "valid", + usingPersonalToken: true, + }); + }); + + afterEach(() => { + mockOSF.reset(); + }); + + afterAll(() => { + mockOSF.server.close(); + }); + + it("apidata POST for a legacy (storageProvider-less) OSF experiment succeeds normally -- expected to PASS today", async () => { + const experimentID = `gdrive-int16-${randomUUID()}`; + const filename = `case16-${randomUUID()}.json`; + const filesLink = `http://localhost:${mockOSF.port}/files/`; + + await db.collection("experiments").doc(experimentID).set({ + active: true, + metadataActive: false, + owner: OSF_OWNER_ID, + osfFilesLink: filesLink, + // storageProvider deliberately absent -- legacy dispatch. + }); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + expect(mockOSF.getUploadCount(filename)).toBe(1); + }); +}); diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js new file mode 100644 index 0000000..499a127 --- /dev/null +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -0,0 +1,640 @@ +// RED-phase unit tests for step 4a (docs/provider-migration-design.md, +// scratchpad/step4a-gdrive-adapter-spec.md), cases 1-7 of the test plan. +// +// functions/src/providers/gdrive.ts does not exist yet, so the import below +// fails at module resolution -- EVERY test in this file (including the osf +// sub-test inside case 7) fails as a collateral "Cannot find module +// .../lib/providers/gdrive.js" error until the adapter is implemented. Once +// gdrive.ts exists, the osf.downloadFile sub-test in case 7 is expected to +// keep failing on its own, different (and correct) ground: osf.ts has no +// downloadFile method yet either (that's also new in this step -- see the +// "downloadFile" interface addition in the spec) -- so it fails with +// "osfProvider.downloadFile is not a function", a missing-behavior failure +// distinct from the module-not-found failures affecting cases 1-6. +// +// Style follows providers-osf.test.js: node-fetch is imported by name inside +// the (future) gdrive.ts adapter module, same as osf.ts's put-file-osf.ts / +// update-file-osf.ts, so "node-fetch" is mocked at the module level and the +// compiled lib/ output is imported rather than the .ts source. +// +// GDRIVE_API_BASE is read at CALL time (not module load, per the spec), so +// this file pins it to a distinctive, non-default sentinel value in +// beforeAll/afterAll -- this both keeps assertions independent of whatever +// the real default happens to be, and forces the implementation to actually +// read process.env.GDRIVE_API_BASE rather than hardcoding +// https://www.googleapis.com. +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { gdriveProvider } from "../../lib/providers/gdrive.js"; +import { osfProvider } from "../../lib/providers/osf.js"; + +const API_BASE = "https://gdrive.mock.test"; + +const ORIGINAL_GDRIVE_API_BASE = process.env.GDRIVE_API_BASE; + +beforeAll(() => { + process.env.GDRIVE_API_BASE = API_BASE; +}); + +afterAll(() => { + process.env.GDRIVE_API_BASE = ORIGINAL_GDRIVE_API_BASE; +}); + +beforeEach(() => { + mockFetch.mockClear(); +}); + +const auth = { token: "test-token" }; + +function mockResponse({ status, statusText, retryAfter = null, jsonBody, textBody }) { + return { + status, + statusText, + headers: { + get: (header) => (header === "Retry-After" ? retryAfter : null), + }, + json: () => Promise.resolve(jsonBody), + text: () => Promise.resolve(textBody), + }; +} + +// Case-insensitive header lookup -- the exact casing of the headers object +// gdrive.ts builds isn't spec'd beyond "Authorization"/"Content-Type" style +// (mirrored from osf.ts), so tests look up by name rather than assume a key. +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +function extractBoundary(contentType) { + const match = /boundary=("?)([^;"]+)\1/.exec(contentType || ""); + return match ? match[2] : null; +} + +function callArgs(index = 0) { + const [url, options] = mockFetch.mock.calls[index]; + return { url, options }; +} + +describe("1. writeSessionFile success", () => { + it("POSTs a multipart/related upload containing both the JSON metadata and the payload, and parses the returned fileRef", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { id: "gdrive-file-1", name: "file.json" }, + }) + ); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + const result = await gdriveProvider.writeSessionFile( + auth, + container, + "file.json", + "a,b,c\n1,2,3", + { size: 11, contentType: "text/csv" } + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + + expect(url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + expect(options.method).toBe("POST"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + const contentType = header(options.headers, "Content-Type"); + expect(contentType).toMatch(/^multipart\/related; boundary=/); + const boundary = extractBoundary(contentType); + expect(boundary).toBeTruthy(); + + const body = options.body.toString(); + expect(body).toContain(`--${boundary}`); + expect(body).toContain('"name":"file.json"'); + expect(body).toContain('"parents":["folder-abc"]'); + expect(body).toContain("application/json; charset=UTF-8"); + expect(body).toContain("text/csv"); + expect(body).toContain("a,b,c\n1,2,3"); + // Metadata part must precede the data part. + expect(body.indexOf('"name":"file.json"')).toBeLessThan(body.indexOf("a,b,c\n1,2,3")); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-1", name: "file.json" }, + storedFilename: "file.json", + }); + }); + + it("also treats a 201 response as success (per the '200/201' contract)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { id: "gdrive-file-2", name: "file2.json" }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file2.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-2", name: "file2.json" }, + storedFilename: "file2.json", + }); + }); + + it("falls back to the requested filename as storedFilename when the response omits name", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { id: "gdrive-file-3" }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file3.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-3", name: undefined }, + storedFilename: "file3.json", + }); + }); +}); + +describe("2. writeSessionFile subfolder", () => { + it("finds-or-creates the subfolder by name under the container, then uploads parented to it", async () => { + // 1) folder query -- absent + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + // 2) folder create + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "sub-folder-id", name: "sub" } }) + ); + // 3) upload into the new subfolder + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "gdrive-file-4", name: "file.csv" } }) + ); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + const result = await gdriveProvider.writeSessionFile(auth, container, "sub/file.csv", "csv-data", { + size: 8, + contentType: "text/csv", + }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + + const findCall = callArgs(0); + const findUrl = new URL(findCall.url); + expect(findCall.options.method).toBe("GET"); + expect(findUrl.searchParams.get("q")).toContain("name='sub'"); + expect(findUrl.searchParams.get("q")).toContain("'folder-abc' in parents"); + expect(findUrl.searchParams.get("q")).toContain("mimeType='application/vnd.google-apps.folder'"); + expect(findUrl.searchParams.get("q")).toContain("trashed=false"); + + const createCall = callArgs(1); + expect(createCall.options.method).toBe("POST"); + expect(JSON.parse(createCall.options.body)).toEqual({ + name: "sub", + mimeType: "application/vnd.google-apps.folder", + parents: ["folder-abc"], + }); + + const uploadCall = callArgs(2); + expect(uploadCall.url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + const uploadBody = uploadCall.options.body.toString(); + expect(uploadBody).toContain('"name":"file.csv"'); + expect(uploadBody).toContain('"parents":["sub-folder-id"]'); + expect(uploadBody).toContain("csv-data"); + + expect(result).toEqual({ + success: true, + fileRef: { id: "gdrive-file-4", name: "file.csv" }, + storedFilename: "file.csv", + }); + }); + + it("uploads directly into an existing subfolder without creating a duplicate", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "existing-sub-id", name: "sub", mimeType: "application/vnd.google-apps.folder" }] }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "gdrive-file-5", name: "file2.csv" } }) + ); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + await gdriveProvider.writeSessionFile(auth, container, "sub/file2.csv", "csv-data-2", { + size: 10, + contentType: "text/csv", + }); + + // Exactly 2 calls: the find query, then the upload. No create call. + expect(mockFetch).toHaveBeenCalledTimes(2); + const uploadBody = callArgs(1).options.body.toString(); + expect(uploadBody).toContain('"parents":["existing-sub-id"]'); + }); +}); + +describe("3. error mapping", () => { + it("maps 401 to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + retryAfter: null, + }); + }); + + it("maps 403 storageQuotaExceeded to QUOTA_EXCEEDED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "storageQuotaExceeded", message: "quota" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "QUOTA_EXCEEDED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps 403 userRateLimitExceeded to RATE_LIMITED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "userRateLimitExceeded", message: "slow down" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps any other 403 reason to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "insufficientFilePermissions", message: "nope" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps 429 to RATE_LIMITED and passes through Retry-After", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 429, statusText: "Too Many Requests", retryAfter: "15" }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 429, + providerMessage: "Too Many Requests", + retryAfter: 15, + }); + }); + + it("maps 500 to UNAVAILABLE", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 500, statusText: "Internal Server Error" })); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 500, + providerMessage: "Internal Server Error", + retryAfter: null, + }); + }); +}); + +describe("4. listFiles pagination", () => { + it("follows nextPageToken until exhausted, concatenates results, filters out folders, and both requests carry the same q filter", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + nextPageToken: "page2tok", + files: [ + { id: "f1", name: "a.csv", mimeType: "text/csv" }, + { id: "folder1", name: "subdir", mimeType: "application/vnd.google-apps.folder" }, + ], + }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "f2", name: "b.csv", mimeType: "text/csv" }] }, + }) + ); + + const container = { provider: "gdrive", folderId: "folder-xyz" }; + const result = await gdriveProvider.listFiles(auth, container); + + expect(result).toEqual([ + { name: "a.csv", id: "f1" }, + { name: "b.csv", id: "f2" }, + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const url1 = new URL(callArgs(0).url); + const url2 = new URL(callArgs(1).url); + + expect(url1.searchParams.get("q")).toBe("'folder-xyz' in parents and trashed=false"); + expect(url2.searchParams.get("q")).toBe(url1.searchParams.get("q")); + expect(url1.searchParams.get("fields")).toBe("nextPageToken,files(id,name,mimeType)"); + expect(url1.searchParams.get("pageSize")).toBe("1000"); + + // The defining pagination assertion: only the second request carries the + // page token from the first response. + expect(url1.searchParams.get("pageToken")).toBeNull(); + expect(url2.searchParams.get("pageToken")).toBe("page2tok"); + }); + + it("stops after a single page when no nextPageToken is returned", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "f1", name: "only.csv", mimeType: "text/csv" }] }, + }) + ); + + const result = await gdriveProvider.listFiles(auth, { provider: "gdrive", folderId: "folder-solo" }); + + expect(result).toEqual([{ name: "only.csv", id: "f1" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws (never returns a partial/empty list) when the listing request fails", async () => { + // Load-bearing for collision-cache rehydration: Drive has no 409 + // backstop, so a swallowed listing failure would warm the cache empty + // and silently accept duplicate filenames. The throw is what surfaces + // as CollisionCacheUnavailableError upstream. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 401, + statusText: "Unauthorized", + jsonBody: { error: { message: "Invalid Credentials" } }, + }) + ); + + await expect( + gdriveProvider.listFiles(auth, { provider: "gdrive", folderId: "folder-err" }) + ).rejects.toThrow(/listing failed/i); + }); +}); + +describe("5. updateFile", () => { + it("PATCHes the media upload endpoint keyed by the ref id and returns a WriteResult on success", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK" })); + + const existingFileRef = { id: "gdrive-existing-1", name: "data.json" }; + const result = await gdriveProvider.updateFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + existingFileRef, + "updated-data", + { size: 12, contentType: "application/json" } + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${API_BASE}/upload/drive/v3/files/gdrive-existing-1?uploadType=media`); + expect(options.method).toBe("PATCH"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + expect(options.body).toBe("updated-data"); + + expect(result).toEqual({ + success: true, + fileRef: existingFileRef, + storedFilename: "data.json", + }); + }); + + it("returns a failure WriteResult (does not throw) when the PATCH fails", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 404, statusText: "Not Found" })); + + const existingFileRef = { id: "gdrive-stale-1", name: "data.json" }; + + await expect( + gdriveProvider.updateFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + existingFileRef, + "updated-data", + { size: 12, contentType: "application/json" } + ) + ).resolves.toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 404, + providerMessage: "Not Found", + retryAfter: null, + }); + }); +}); + +describe("6. createDataContainer", () => { + it("only creates the child folder when the DataPipe root already exists", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + files: [{ id: "root-existing-id", name: "DataPipe", mimeType: "application/vnd.google-apps.folder" }], + }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-A", name: "My Experiment" } }) + ); + + const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment" }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const findUrl = new URL(callArgs(0).url); + expect(findUrl.searchParams.get("q")).toContain("name='DataPipe'"); + expect(findUrl.searchParams.get("q")).toContain("'root' in parents"); + expect(findUrl.searchParams.get("q")).toContain("mimeType='application/vnd.google-apps.folder'"); + expect(findUrl.searchParams.get("q")).toContain("trashed=false"); + + expect(JSON.parse(callArgs(1).options.body)).toEqual({ + name: "My Experiment", + mimeType: "application/vnd.google-apps.folder", + parents: ["root-existing-id"], + }); + + expect(result).toEqual({ provider: "gdrive", folderId: "child-id-A" }); + }); + + it("creates the DataPipe root first, then the child, when the root is absent", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "root-new-id", name: "DataPipe" } }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-B", name: "My Experiment 2" } }) + ); + + const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment 2" }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + + expect(JSON.parse(callArgs(1).options.body)).toEqual({ + name: "DataPipe", + mimeType: "application/vnd.google-apps.folder", + parents: ["root"], + }); + expect(JSON.parse(callArgs(2).options.body)).toEqual({ + name: "My Experiment 2", + mimeType: "application/vnd.google-apps.folder", + parents: ["root-new-id"], + }); + + expect(result).toEqual({ provider: "gdrive", folderId: "child-id-B" }); + }); +}); + +describe("7. downloadFile", () => { + it("gdrive: GETs the alt=media endpoint and returns the body text as content", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "file body text" })); + + const container = { provider: "gdrive", folderId: "folder-abc" }; + const result = await gdriveProvider.downloadFile(auth, container, { id: "gdrive-file-9", name: "data.json" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${API_BASE}/drive/v3/files/gdrive-file-9?alt=media`); + expect(options.method).toBe("GET"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + expect(result).toEqual({ success: true, content: "file body text" }); + }); + + it("gdrive: maps a 401 on download the same way as writes (shared mapDriveError)", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + + const result = await gdriveProvider.downloadFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + { id: "gdrive-file-10", name: "data.json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + }); + }); + + it("osf: GETs filesLink+id and returns the body text as content", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "osf file content" })); + + const container = { provider: "osf", filesLink: "https://osf.io/abc123/" }; + const result = await osfProvider.downloadFile(auth, container, { id: "osfstorage/111", name: "data.json" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe("https://osf.io/abc123/osfstorage/111"); + expect(options.method).toBe("GET"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + expect(result).toEqual({ success: true, content: "osf file content" }); + }); +}); diff --git a/functions/src/__tests__/resolve-token-gdrive.test.js b/functions/src/__tests__/resolve-token-gdrive.test.js new file mode 100644 index 0000000..4803c1c --- /dev/null +++ b/functions/src/__tests__/resolve-token-gdrive.test.js @@ -0,0 +1,286 @@ +/** + * @jest-environment node + */ + +// RED-phase unit tests for step 4a (docs/provider-migration-design.md, +// scratchpad/step4a-gdrive-adapter-spec.md), cases 8-11 of the test plan. +// +// resolve-token.ts currently only understands the OSF/PAT dispatch (see +// resolveToken's source: it branches on user_data.usingPersonalToken and +// user_data.authTokenExpires with no awareness of exp_data.storageProvider +// at all). Cases 8-10 below exercise a gdrive branch that does not exist yet: +// - case 8 fails because resolveToken falls through to the OAuth/OSF path, +// which reads user_data.authToken (undefined for a gdrive-only user) and +// returns { success: true, token: undefined } instead of dispatching on +// exp_data.storageProvider === "gdrive". +// - case 9/10 fail for the same reason: no refresh-POST is ever made (the +// OSF path calls refreshAndUpdateUser against the OSF token endpoint, not +// GDRIVE_TOKEN_URL), so global.fetch is never called and nothing is +// persisted to connectedAccounts.gdrive. +// - case 11 is a regression guard: it exercises the existing +// usingPersonalToken branch, completely untouched by the gdrive +// generalization, and is expected to PASS today. If a future edit to +// resolve-token.ts ever breaks it, that's a real regression, not an +// artifact of this RED phase. +// +// Persistence style follows collision-cache.test.js / upload-queue.test.js: +// a Firestore-emulator-backed app imported by its compiled lib/ output, one +// freshly-generated uid per test. +// +// Token encoding: per crypto-utils.ts, decrypt() has a plaintext fallback -- +// any value not prefixed with "v1:" is returned unchanged. Seeded tokens that +// are never expected to be *re-encrypted and re-read back* by this file use +// that fallback directly (mirrors how existing OSF emulator tests seed +// osfToken: "valid"). Case 9 is the exception: it asserts that resolveToken +// itself calls encrypt() when persisting the refreshed token, so this file +// sets its own TOKEN_ENCRYPTION_KEY and decrypts the persisted value back -- +// entirely self-consistent within this one process, and unrelated to +// whatever key the Functions emulator loads from functions/.env for the +// separate gdrive-emulator.test.js integration run (which never exercises +// real encryption -- see that file's header comment). +// +// resolve-token.ts's existing refresh call (refreshAndUpdateUser, in +// refresh-token.ts) uses the runtime's global `fetch`, not the "node-fetch" +// package -- only the OSF file-upload modules (put-file-osf.ts, +// update-file-osf.ts) import node-fetch explicitly, per providers-osf.test.js's +// header comment. The gdrive refresh branch is expected to follow its +// sibling module's convention, so this file mocks global.fetch rather than +// the "node-fetch" module. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import resolveToken from "../../lib/resolve-token.js"; +import { decrypt } from "../../lib/crypto-utils.js"; + +// TOKEN_ENCRYPTION_KEY is read lazily inside crypto-utils.ts's encrypt()/ +// decrypt() (via getKey(), called per-invocation, not cached at module +// load), so it's safe to import resolveToken/crypto-utils statically here +// and only set the key in beforeAll below, before any test actually calls +// them. +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +let db; + +const ORIGINAL_ENV = { + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + GDRIVE_TOKEN_URL: process.env.GDRIVE_TOKEN_URL, + GDRIVE_CLIENT_ID: process.env.GDRIVE_CLIENT_ID, + GDRIVE_CLIENT_SECRET: process.env.GDRIVE_CLIENT_SECRET, +}; +const ORIGINAL_FETCH = global.fetch; + +beforeAll(async () => { + let app; + try { + app = getApp("resolve-token-gdrive-test"); + } catch { + app = initializeApp(config, "resolve-token-gdrive-test"); + } + db = getFirestore(app); + + // A valid-shaped (64 hex char) key, scoped to this process only -- see + // header comment on why this doesn't need to match functions/.env. + process.env.TOKEN_ENCRYPTION_KEY = "11".repeat(32); + process.env.GDRIVE_TOKEN_URL = "https://gdrive-token.mock.test/token"; + process.env.GDRIVE_CLIENT_ID = "test-gdrive-client-id"; + process.env.GDRIVE_CLIENT_SECRET = "test-gdrive-client-secret"; +}); + +afterAll(() => { + process.env.TOKEN_ENCRYPTION_KEY = ORIGINAL_ENV.TOKEN_ENCRYPTION_KEY; + process.env.GDRIVE_TOKEN_URL = ORIGINAL_ENV.GDRIVE_TOKEN_URL; + process.env.GDRIVE_CLIENT_ID = ORIGINAL_ENV.GDRIVE_CLIENT_ID; + process.env.GDRIVE_CLIENT_SECRET = ORIGINAL_ENV.GDRIVE_CLIENT_SECRET; + global.fetch = ORIGINAL_FETCH; +}); + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +// A tolerance window for timestamp assertions -- generous enough to absorb +// emulator/test round-trip latency without hiding a genuinely wrong duration +// (mirrors collision-cache.test.js's TOLERANCE_MS). +const TOLERANCE_MS = 5000; + +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +async function createGdriveUser(uid, overrides = {}) { + const gdrive = { + authMethod: "oauth2", + encryptedToken: "plain-access-token", + encryptedRefreshToken: "plain-refresh-token", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + providerAccountId: "acct-1", + ...overrides, + }; + await db.collection("users").doc(uid).set({ connectedAccounts: { gdrive } }); + return gdrive; +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +describe("8. unexpired gdrive token", () => { + it("returns the decrypted token directly, without calling the token endpoint", async () => { + const uid = `gdrive-resolve-8-${randomUUID()}`; + await createGdriveUser(uid, { encryptedToken: "unexpired-access-token-8" }); + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result).toEqual({ success: true, token: "unexpired-access-token-8" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("9. expired gdrive token triggers a refresh", () => { + it("POSTs the refresh grant with client credentials, and persists the new token/expiry encrypted, including refresh-token rotation", async () => { + const uid = `gdrive-resolve-9-${randomUUID()}`; + await createGdriveUser(uid, { + encryptedToken: "stale-access-token-9", + encryptedRefreshToken: "refresh-token-9", + tokenExpiresAt: Date.now() - 1000, // already expired + }); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token-9", + expires_in: 3600, + refresh_token: "new-refresh-token-9", + }), + }); + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const before = Date.now(); + const result = await resolveToken(userData, expData); + const after = Date.now(); + + expect(result).toEqual({ success: true, token: "new-access-token-9" }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe(process.env.GDRIVE_TOKEN_URL); + expect(options.method).toBe("POST"); + expect(header(options.headers, "Content-Type")).toContain("application/x-www-form-urlencoded"); + + const bodyParams = new URLSearchParams(options.body); + expect(bodyParams.get("grant_type")).toBe("refresh_token"); + expect(bodyParams.get("refresh_token")).toBe("refresh-token-9"); + expect(bodyParams.get("client_id")).toBe("test-gdrive-client-id"); + expect(bodyParams.get("client_secret")).toBe("test-gdrive-client-secret"); + + const persisted = await getUserData(uid); + const gdrive = persisted.connectedAccounts.gdrive; + expect(decrypt(gdrive.encryptedToken)).toBe("new-access-token-9"); + expect(decrypt(gdrive.encryptedRefreshToken)).toBe("new-refresh-token-9"); // rotated + expect(gdrive.tokenExpiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000 - TOLERANCE_MS); + expect(gdrive.tokenExpiresAt).toBeLessThanOrEqual(after + 3600 * 1000 + TOLERANCE_MS); + }); + + it("keeps the existing refresh token when the response does not include a rotated one", async () => { + const uid = `gdrive-resolve-9b-${randomUUID()}`; + await createGdriveUser(uid, { + encryptedToken: "stale-access-token-9b", + encryptedRefreshToken: "refresh-token-9b-unrotated", + tokenExpiresAt: Date.now() - 1000, + }); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token-9b", + expires_in: 1800, + // no refresh_token field -- no rotation offered + }), + }); + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result).toEqual({ success: true, token: "new-access-token-9b" }); + + const persisted = await getUserData(uid); + const gdrive = persisted.connectedAccounts.gdrive; + expect(decrypt(gdrive.encryptedToken)).toBe("new-access-token-9b"); + expect(decrypt(gdrive.encryptedRefreshToken)).toBe("refresh-token-9b-unrotated"); + }); +}); + +describe("10. resolve failures", () => { + it("returns INVALID_REFRESH_TOKEN when the refresh request fails", async () => { + const uid = `gdrive-resolve-10a-${randomUUID()}`; + await createGdriveUser(uid, { + encryptedToken: "stale-access-token-10a", + encryptedRefreshToken: "bad-refresh-token-10a", + tokenExpiresAt: Date.now() - 1000, + }); + + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve("invalid_grant"), + }); + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result.success).toBe(false); + expect(result.error).toBe("INVALID_REFRESH_TOKEN"); + expect(typeof result.detail).toBe("string"); + expect(result.detail.length).toBeGreaterThan(0); + }); + + it("returns PROVIDER_NOT_CONNECTED when the user has no connectedAccounts.gdrive at all", async () => { + const uid = `gdrive-resolve-10b-${randomUUID()}`; + await db.collection("users").doc(uid).set({}); // no connectedAccounts field whatsoever + + const userData = await getUserData(uid); + const expData = { storageProvider: "gdrive", owner: uid }; + + const result = await resolveToken(userData, expData); + + expect(result.success).toBe(false); + expect(result.error).toBe("PROVIDER_NOT_CONNECTED"); + expect(typeof result.detail).toBe("string"); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("11. osf-experiment dispatch unchanged (regression guard)", () => { + it("still returns the decrypted PAT for a personal-token OSF user -- expected to PASS today, unaffected by the gdrive generalization", async () => { + const userData = { + usingPersonalToken: true, + osfTokenValid: true, + osfToken: "valid-osf-pat-11", + }; + // storageProvider deliberately absent -- legacy OSF experiment. + const expData = { owner: "osf-owner-11" }; + + const result = await resolveToken(userData, expData); + + expect(result).toEqual({ success: true, token: "valid-osf-pat-11" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index c0e1123..9861daf 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -133,6 +133,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: false, failureReason: `Collision cache rehydration failed: ${detail}`, claimToken, @@ -163,6 +164,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: false, failureReason: "Collision cache rehydrating", claimToken, @@ -195,6 +197,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: false, failureReason: `Upload exception: ${detail}`, claimToken, @@ -230,6 +233,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: result.providerStatus || 0, sessionIncremented: false, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, claimToken, diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 1440c3d..958d1d9 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -166,6 +166,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, failureReason: `Collision cache rehydration failed: ${detail}`, claimToken, @@ -197,6 +198,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, failureReason: "Collision cache rehydrating", claimToken, @@ -230,6 +232,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, failureReason: `Upload exception: ${detail}`, claimToken, @@ -266,6 +269,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 await queueUpload({ experimentID, owner: exp_data.owner, filename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: result.providerStatus || 0, sessionIncremented: true, failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, claimToken, diff --git a/functions/src/api-messages.ts b/functions/src/api-messages.ts index cb9bec1..a822bc0 100644 --- a/functions/src/api-messages.ts +++ b/functions/src/api-messages.ts @@ -39,6 +39,10 @@ const MESSAGES = { error: "INVALID_REFRESH_TOKEN", message: "The experiment owner's refresh token is not valid", }, + PROVIDER_NOT_CONNECTED: { + error: "PROVIDER_NOT_CONNECTED", + message: "The experiment owner has not connected an account for this experiment's storage provider", + }, INVALID_BASE64_DATA: { error: "INVALID_BASE64_DATA", message: "The data are not valid base64 data", diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index 6e5d9df..b218ef5 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -96,7 +96,9 @@ export interface ExperimentData { filename: string; storagePath: string; dataType: "data" | "base64"; - osfFilesLink: string; + // Optional — undefined for provider-migrated (e.g. gdrive) queue + // entries, which carry storageProvider/providerContainer instead. + osfFilesLink?: string; status: "pending" | "processing" | "completed" | "failed"; errorCode: number; retryCount: number; @@ -112,6 +114,10 @@ export interface ExperimentData { // entries queued before the collision cache existed — those skip the // cache entirely on retry). claimToken?: string; + // Provider-migration fields (additive; absent = legacy OSF queue entry, + // which falls back to the osfFilesLink-based container above). + storageProvider?: StorageProviderId; + providerContainer?: ContainerRef; } export interface OSFFile{ diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index b87f4dc..9e09a24 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -1,16 +1,31 @@ import MESSAGES from "./api-messages.js"; import updateMetadata from "./metadata-update.js"; import produceMetadata from "./metadata-production.js"; -import downloadMetadata from "./metadata-download.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; import { db } from "./app.js"; -import { decrypt } from "./crypto-utils.js"; -import { refreshAndUpdateUser } from "./refresh-token.js"; -import { osfProvider } from "./providers/osf.js"; -import { FileRef } from "./providers/types.js"; +import resolveToken from "./resolve-token.js"; +import { getProviderForExperiment } from "./providers/index.js"; +import { FileRef, ProviderErrorCode } from "./providers/types.js"; import { ExperimentData, UserData, Metadata, MetadataResponse } from './interfaces'; +// Thrown by performUpdate below when a provider's updateFile call fails +// (either by throwing, in OSF's case, or by returning a failure WriteResult, +// in gdrive's case). Carries the provider's error code, when known, so +// callers can distinguish a self-healable failure (stale ref) from one that +// recreating the file can't fix (auth/quota/rate-limit). +class ProviderUpdateError extends Error { + code?: ProviderErrorCode; + constructor(message: string, code?: ProviderErrorCode) { + super(message); + this.code = code; + } +} +// Failure codes for which recreating the metadata file is pointless: an +// auth or quota problem isn't fixed by writing a new file, and self-healing +// on RATE_LIMITED would double the write load exactly when the provider is +// telling us to back off. +const NON_HEALABLE_CODES: ProviderErrorCode[] = ["AUTH_EXPIRED", "RATE_LIMITED", "QUOTA_EXCEEDED"]; export default async function blockMetadata( exp_data: ExperimentData, @@ -22,26 +37,13 @@ export default async function blockMetadata( let metadataMessage: {metadataMessage: string} = {metadataMessage: ''}; -let decryptedOsfToken: string; -if (user_data.usingPersonalToken) { - decryptedOsfToken = decrypt(user_data.osfToken); -} else { - if (Date.now() > user_data.authTokenExpires) { - const refreshResult = await refreshAndUpdateUser(exp_data.owner, decrypt(user_data.refreshToken)); - if (!refreshResult.success) { - // Fall back to PAT if available - if (user_data.osfTokenValid && user_data.osfToken) { - decryptedOsfToken = decrypt(user_data.osfToken); - } else { - return { success: false, metadataMessage: "OAuth token refresh failed" }; - } - } else { - decryptedOsfToken = refreshResult.accessToken!; - } - } else { - decryptedOsfToken = decrypt(user_data.authToken); - } +const tokenResult = await resolveToken(user_data, exp_data); +if (!tokenResult.success) { + return { success: false, metadataMessage: tokenResult.detail }; } +const token = tokenResult.token; + +const { provider, container } = getProviderForExperiment(exp_data); try { @@ -73,9 +75,9 @@ try { // tracking. Afterward the ref (possibly null) is stored so this never // runs again for this experiment. if (metadataFileRef === undefined) { - const providerFiles = await osfProvider.listFiles( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink } + const providerFiles = await provider.listFiles( + { token }, + container ); const found = providerFiles.find((file) => file.name === "dataset_description.json"); @@ -91,9 +93,9 @@ try { async function createMetadataFile(payload: object) { const serialized = JSON.stringify(payload, null, 2); - const response = await osfProvider.writeSessionFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, + const response = await provider.writeSessionFile( + { token }, + container, `dataset_description.json`, serialized, { size: Buffer.byteLength(serialized), contentType: "application/json" } @@ -114,6 +116,28 @@ try { } } + // Updates the ref'd metadata file. Throws a ProviderUpdateError on any + // failure — OSF's updateFile already throws on non-200 responses; + // gdrive's never throws, so a returned {success:false} is converted + // into the same ProviderUpdateError shape here so callers can handle + // both provider styles identically. + async function performUpdate(fileRef: FileRef, serialized: string) { + const result = await provider.updateFile( + { token }, + container, + fileRef, + serialized, + { size: Buffer.byteLength(serialized), contentType: "application/json" } + ); + + if (!result.success) { + throw new ProviderUpdateError( + `Error updating metadata file: ${result.providerMessage}`, + result.error + ); + } + } + //When a ref and firestore metadata both exist, updating is done with respect to firestore. if (metadataFileRef && firestoreMetadata) { @@ -128,17 +152,17 @@ try { try { //The ref'd metadata file is updated with the above metadata. - await osfProvider.updateFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, - metadataFileRef, - serialized, - { size: Buffer.byteLength(serialized), contentType: "application/json" } - ); + await performUpdate(metadataFileRef, serialized); } catch (e) { - // Self-heal: the ref is stale (the file was deleted provider-side). - // Create a fresh metadata file and store its new ref rather than - // failing the whole request. + // A returned failure with a non-healable code (auth/quota/rate + // limit) must propagate rather than self-heal — recreating the + // file can't fix any of those, and re-creating under rate-limiting + // would only make things worse. + if (e instanceof ProviderUpdateError && e.code && NON_HEALABLE_CODES.includes(e.code)) { + throw e; + } + // Self-heal: the ref is stale (the file was deleted provider-side), + // or the failure is otherwise recoverable by recreating the file. await createMetadataFile(updatedMetadata); } } @@ -147,29 +171,32 @@ try { metadataMessage = MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE; - //Metadata is downloaded from OSF, and is compared to incoming metadata to produce an updated version. - // ********[IMPORTANT]*********** - // Since Metadata is in OSF as evidenced by the ref, it is downloaded, and the type is asserted. - const downloadResponse = await downloadMetadata(exp_data.osfFilesLink, decryptedOsfToken, metadataFileRef.id as string); + //Metadata is downloaded from the provider, and is compared to incoming metadata to produce an updated version. + const downloadResult = await provider.downloadFile({ token }, container, metadataFileRef); - const osfMetadata: Metadata = downloadResponse.metadata; + if (!downloadResult.success) { + throw new Error(`Error downloading metadata file: ${downloadResult.providerMessage}`); + } - const updatedMetadata = await updateMetadata(osfMetadata, incomingMetadata); + let providerMetadata: Metadata; + try { + providerMetadata = JSON.parse(downloadResult.content) as Metadata; + } catch (e) { + throw new Error(`Error parsing downloaded metadata: ${e instanceof Error ? e.message : "Unknown error"}`); + } + + const updatedMetadata = await updateMetadata(providerMetadata, incomingMetadata); //Up to date metadata is uploaded to firestore. t.set(metadata_doc_ref, {metadata: updatedMetadata}, {merge: true}); - //Since metadata exists in OSF, it is updated and not set. - await osfProvider.updateFile( - { token: decryptedOsfToken }, - { provider: "osf", filesLink: exp_data.osfFilesLink }, - metadataFileRef, - JSON.stringify(incomingMetadata, null, 2), - { size: Buffer.byteLength(JSON.stringify(incomingMetadata, null, 2)), contentType: "application/json" } - ); + //Since metadata exists in the provider, it is updated and not set. + // No self-heal here (matches pre-existing behavior) — any failure + // propagates to the outer catch as METADATA_ERROR. + await performUpdate(metadataFileRef, JSON.stringify(incomingMetadata, null, 2)); } - // When no ref exists but firestore has metadata, the metadata file is (re)created in OSF. + // When no ref exists but firestore has metadata, the metadata file is (re)created in the provider. else if (!metadataFileRef && firestoreMetadata) { metadataMessage = MESSAGES.METADATA_IN_FIRESTORE_NOT_IN_OSF; @@ -179,15 +206,15 @@ try { t.update(metadata_doc_ref, {metadata: updatedMetadata}); - //If a metadata file does not exist in OSF, it is created with the above metadata. + //If a metadata file does not exist in the provider, it is created with the above metadata. await createMetadataFile(updatedMetadata); } - // When neither a ref nor firestore metadata exist, the metadata is created in OSF and firestore. + // When neither a ref nor firestore metadata exist, the metadata is created in the provider and firestore. else { metadataMessage = MESSAGES.METADATA_NOT_IN_FIRESTORE_OR_OSF; - //Incoming metadata is uploaded to firestore and OSF. + //Incoming metadata is uploaded to firestore and the provider. t.set(metadata_doc_ref, {metadata: incomingMetadata}, {merge: true}); @@ -195,7 +222,7 @@ try { } }); - + const metadataResponse: MetadataResponse = {success: true, ...metadataMessage}; return metadataResponse; } @@ -219,4 +246,4 @@ catch (error) { return metadataResponse; //METADATA BLOCK END }; -} \ No newline at end of file +} diff --git a/functions/src/metadata-download.ts b/functions/src/metadata-download.ts deleted file mode 100644 index a71365d..0000000 --- a/functions/src/metadata-download.ts +++ /dev/null @@ -1,59 +0,0 @@ -import fetch from "node-fetch"; -import validateJSON from "./validate-json.js"; -import { Metadata } from "./interfaces"; - -export default async function downloadMetadata( - osfComponent: string, - osfToken: string, - metadataId: string, -) { - //Gets the metadata of the data storage element in the OSF project. - const downloadMetadata = await fetch(`${osfComponent}${metadataId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - } - }); - /** - * Checks the status of the metadata download. - * If the status is 404, logs the list of files to the console and returns an error object. - */ - if (downloadMetadata.status === 404) { - throw Error(`Error downloading metadata with code: ${downloadMetadata.status}, and message: ${downloadMetadata.statusText}`); - } - - /** - * Extracts the URL of the metadata file from the download object. - * @type {string} - */ - const fileUrl: string = downloadMetadata.url; - - /** - * Uses the download link provided by OSF to get the metadata file as a string. - * @param {string} fileUrl - The URL of the file to fetch. - * @returns {Promise} A promise that resolves to the data from the file. - */ - async function fetchMetadata(fileUrl: string) { - try { - const response = await fetch(fileUrl); - if (!response.ok) { - throw Error(`Error fetching metadata with code: ${response.status}, and message: ${response.statusText}`); - } - return await response.json(); - } catch (error) { - throw Error(`Error fetching metadata with code: 400, and message: ${error instanceof Error ? error.message : 'An unknown error occurred'}`) - } -} - // Download the metadata file. - const metadata: Metadata = await fetchMetadata(fileUrl) as Metadata; - - if (!metadata.variableMeasured || !metadata.variableMeasured[0].name) { - throw Error(`Error downloading metadata with code: 400, and message: Invalid metadata downloaded`) - } - - // Checks if the existing metadata is in valid JSON format, and that it contains the Psych-DS proper fields. - const success: boolean = validateJSON(JSON.stringify(metadata), ["name", "schemaVersion", "@context", "@type", "description", "author", "variableMeasured"]); - - return { success: success, errorCode: null, errorText: null, metadata: metadata}; -} \ No newline at end of file diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts new file mode 100644 index 0000000..840840f --- /dev/null +++ b/functions/src/providers/gdrive.ts @@ -0,0 +1,370 @@ +import fetch from "node-fetch"; +import { + StorageProvider, + ResolvedAuth, + ContainerRef, + FileRef, + FileMeta, + WriteResult, + DownloadResult, + ProviderErrorCode, +} from "./types.js"; + +// The gdrive container ref shape — only the folderId is meaningful to this +// adapter (the Drive folder an experiment's session files land in). +export interface GdriveContainerRef extends ContainerRef { + provider: "gdrive"; + folderId: string; +} + +const FOLDER_MIME = "application/vnd.google-apps.folder"; + +// A fixed boundary is fine here — the request body is built and sent in one +// shot, never streamed/concatenated across requests, so there's no need for +// per-call uniqueness. +const MULTIPART_BOUNDARY = "datapipe-gdrive-multipart-boundary"; + +// GDRIVE_API_BASE is read at CALL time (not module load) so tests — and, in +// production, config changes — can vary it without a process restart. +function getApiBase(): string { + return process.env.GDRIVE_API_BASE || "https://www.googleapis.com"; +} + +function authHeaders(auth: ResolvedAuth): Record { + return { Authorization: `Bearer ${auth.token}` }; +} + +// Google Drive `q` filters are single-quoted strings; escape backslashes and +// embedded single quotes per Drive's query syntax. +function escapeQueryValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +function isSuccessStatus(status: number): boolean { + return status === 200 || status === 201; +} + +interface MappedDriveError { + error: ProviderErrorCode; + providerStatus: number; + providerMessage: string; + retryAfter: number | null; +} + +// Shared error-mapping helper — every write/update/list/download call routes +// its non-2xx response through this. Drive never yields a duplicate-name +// conflict (NAME_CONFLICT): Drive allows multiple files with the same name +// in the same folder, so the collision cache (not the provider) is the only +// duplicate gate for gdrive experiments. +function mapDriveError( + status: number, + statusText: string, + body: { errors?: { reason?: string; message?: string }[] } | undefined, + retryAfterHeader: string | null +): MappedDriveError { + const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null; + + let error: ProviderErrorCode; + if (status === 401) { + error = "AUTH_EXPIRED"; + } else if (status === 403) { + const reason = body?.errors?.[0]?.reason; + if (reason === "storageQuotaExceeded") { + error = "QUOTA_EXCEEDED"; + } else if ( + reason === "userRateLimitExceeded" || + reason === "rateLimitExceeded" || + reason === "dailyLimitExceeded" + ) { + error = "RATE_LIMITED"; + } else { + error = "AUTH_EXPIRED"; + } + } else if (status === 429) { + error = "RATE_LIMITED"; + } else { + error = "UNAVAILABLE"; + } + + return { error, providerStatus: status, providerMessage: statusText, retryAfter }; +} + +// Reads the body (only when the status requires inspecting it — the 403 +// reason drill-down) and maps the response into the shared error shape. +async function mapErrorResponse(response: { + status: number; + statusText: string; + headers: { get: (name: string) => string | null }; + json: () => Promise; +}): Promise { + let body: { errors?: { reason?: string; message?: string }[] } | undefined; + if (response.status === 403) { + try { + body = (await response.json()) as { errors?: { reason?: string; message?: string }[] }; + } catch { + body = undefined; + } + } + const retryAfterHeader = response.headers.get("Retry-After"); + return mapDriveError(response.status, response.statusText, body, retryAfterHeader); +} + +// Finds a folder by exact name under a given parent. Returns null if none +// exists yet. +async function findFolder( + auth: ResolvedAuth, + name: string, + parentId: string +): Promise { + const url = new URL(`${getApiBase()}/drive/v3/files`); + const q = `name='${escapeQueryValue(name)}' and '${parentId}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`; + url.searchParams.set("q", q); + + const response = await fetch(url.toString(), { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Google Drive folder lookup failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const body = (await response.json()) as { files?: { id: string }[] }; + const files = body.files || []; + return files.length > 0 ? files[0].id : null; +} + +async function createFolder(auth: ResolvedAuth, name: string, parentId: string): Promise { + const response = await fetch(`${getApiBase()}/drive/v3/files`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, mimeType: FOLDER_MIME, parents: [parentId] }), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Google Drive folder creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const body = (await response.json()) as { id: string }; + return body.id; +} + +async function findOrCreateFolder(auth: ResolvedAuth, name: string, parentId: string): Promise { + const existingId = await findFolder(auth, name, parentId); + if (existingId) { + return existingId; + } + return createFolder(auth, name, parentId); +} + +// Hand-built multipart/related body: part 1 is the JSON metadata (name + +// parents), part 2 is the raw file payload — no extra dependencies needed +// for this. +function buildMultipartBody(metadata: object, data: string | Buffer, contentType: string): Buffer { + const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + const preamble = + `--${MULTIPART_BOUNDARY}\r\n` + + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + + `${JSON.stringify(metadata)}\r\n` + + `--${MULTIPART_BOUNDARY}\r\n` + + `Content-Type: ${contentType}\r\n\r\n`; + const epilogue = `\r\n--${MULTIPART_BOUNDARY}--`; + + return Buffer.concat([Buffer.from(preamble), dataBuffer, Buffer.from(epilogue)]); +} + +export const gdriveProvider: StorageProvider = { + id: "gdrive", + authMethod: "oauth2", + capabilities: { + nativeSubfolders: true, + supportsRegion: false, + maxFileSizeBytes: null, + quotaNote: "Free Google accounts share 15 GB across Drive, Gmail, and Photos", + }, + + async createDataContainer(auth: ResolvedAuth, researcherInput: Record): Promise { + const name = researcherInput.name as string; + + let rootId = await findFolder(auth, "DataPipe", "root"); + if (!rootId) { + rootId = await createFolder(auth, "DataPipe", "root"); + } + + // Experiment folders are always created fresh — Drive allows duplicate + // names, so there's nothing to find-or-create here. + const folderId = await createFolder(auth, name, rootId); + + return { provider: "gdrive", folderId }; + }, + + async writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + meta: FileMeta + ): Promise { + const gdriveContainer = container as GdriveContainerRef; + + let parentId = gdriveContainer.folderId; + let uploadFilename = filename; + + const slashIndex = filename.indexOf("/"); + if (slashIndex !== -1) { + const subfolderName = filename.slice(0, slashIndex); + uploadFilename = filename.slice(slashIndex + 1); + try { + parentId = await findOrCreateFolder(auth, subfolderName, gdriveContainer.folderId); + } catch (e) { + return { + success: false, + error: "UNAVAILABLE", + providerStatus: null, + providerMessage: e instanceof Error ? e.message : "Unknown error", + retryAfter: null, + }; + } + } + + const body = buildMultipartBody( + { name: uploadFilename, parents: [parentId] }, + data, + meta.contentType + ); + + const response = await fetch(`${getApiBase()}/upload/drive/v3/files?uploadType=multipart`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": `multipart/related; boundary=${MULTIPART_BOUNDARY}`, + }, + body, + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + const responseBody = (await response.json()) as { id?: string; name?: string }; + const storedFilename = responseBody.name ?? uploadFilename; + + return { + success: true, + fileRef: { id: responseBody.id, name: responseBody.name } as unknown as FileRef, + storedFilename, + }; + }, + + async updateFile( + auth: ResolvedAuth, + _container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + meta: FileMeta + ): Promise { + const response = await fetch( + `${getApiBase()}/upload/drive/v3/files/${existingFileRef.id}?uploadType=media`, + { + method: "PATCH", + headers: { + ...authHeaders(auth), + "Content-Type": meta.contentType, + }, + body: data, + } + ); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + return { + success: true, + fileRef: existingFileRef, + storedFilename: existingFileRef.name, + }; + }, + + async listFiles(auth: ResolvedAuth, container: ContainerRef): Promise { + const gdriveContainer = container as GdriveContainerRef; + const q = `'${gdriveContainer.folderId}' in parents and trashed=false`; + + const results: FileRef[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(`${getApiBase()}/drive/v3/files`); + url.searchParams.set("q", q); + url.searchParams.set("fields", "nextPageToken,files(id,name,mimeType)"); + url.searchParams.set("pageSize", "1000"); + if (pageToken) { + url.searchParams.set("pageToken", pageToken); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: authHeaders(auth), + }); + + // A failed listing MUST throw, never return a partial/empty result: + // collision-cache rehydration treats the returned list as the complete + // set of existing filenames, and Drive has no 409 backstop — silently + // returning [] here would warm the cache empty and let duplicates + // through. The throw surfaces as CollisionCacheUnavailableError. + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error( + `Google Drive listing failed: ${mapped.providerStatus} ${mapped.providerMessage}` + ); + } + + const body = (await response.json()) as { + nextPageToken?: string; + files?: { id: string; name: string; mimeType: string }[]; + }; + + for (const file of body.files || []) { + if (file.mimeType === FOLDER_MIME) { + continue; + } + results.push({ id: file.id, name: file.name }); + } + + pageToken = body.nextPageToken; + } while (pageToken); + + return results; + }, + + async downloadFile( + auth: ResolvedAuth, + _container: ContainerRef, + fileRef: FileRef + ): Promise { + const response = await fetch(`${getApiBase()}/drive/v3/files/${fileRef.id}?alt=media`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { + success: false, + error: mapped.error, + providerStatus: mapped.providerStatus, + providerMessage: mapped.providerMessage, + }; + } + + const content = await response.text(); + return { success: true, content }; + }, +}; diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index 901fafe..ff02741 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -1,9 +1,11 @@ import { registerProvider, getProvider } from "./registry.js"; import { osfProvider } from "./osf.js"; +import { gdriveProvider } from "./gdrive.js"; import { StorageProvider, ContainerRef } from "./types.js"; import { ExperimentData } from "../interfaces.js"; registerProvider(osfProvider); +registerProvider(gdriveProvider); export function getProviderForExperiment(exp_data: ExperimentData): { provider: StorageProvider; @@ -26,4 +28,5 @@ export function getProviderForExperiment(exp_data: ExperimentData): { export { registerProvider, getProvider } from "./registry.js"; export { osfProvider } from "./osf.js"; +export { gdriveProvider } from "./gdrive.js"; export * from "./types.js"; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index ad587fd..1ae6b86 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -9,6 +9,7 @@ import { FileRef, FileMeta, WriteResult, + DownloadResult, ProviderErrorCode, } from "./types.js"; @@ -115,4 +116,31 @@ export const osfProvider: StorageProvider = { .filter((file) => file.attributes.kind === "file") .map((file) => ({ name: file.attributes.name, id: file.id })); }, + + async downloadFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise { + const osfContainer = container as OSFContainerRef; + + const response = await fetch(`${osfContainer.filesLink}${fileRef.id}`, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.token}`, + }, + }); + + if (response.status !== 200) { + return { + success: false, + error: mapStatus(response.status), + providerStatus: response.status, + providerMessage: response.statusText, + }; + } + + const content = await response.text(); + return { success: true, content }; + }, }; diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index ba2b275..c93caaf 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -59,6 +59,18 @@ export type WriteResult = retryAfter?: number | null; }; +export type DownloadResult = + | { + success: true; + content: string; + } + | { + success: false; + error: ProviderErrorCode; + providerStatus: number | null; + providerMessage: string | null; + }; + // Descriptive (UI hints, subfolder fallback, size-cap warnings) — never a // correctness gate. Collision detection lives in Firestore, not here. export interface ProviderCapabilities { @@ -115,6 +127,15 @@ export interface StorageProvider { // Full listing (adapters paginate internally). Used for collision-cache // rehydration and dashboard file counts. listFiles(auth: ResolvedAuth, container: ContainerRef): Promise; + + // Fetches a file's contents as text. Used by metadata-block.ts to read + // back an existing dataset_description.json. Never throws — failures come + // back as a DownloadResult, same shape convention as WriteResult. + downloadFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise; } // users/{uid}.connectedAccounts.* shapes (additive Firestore schema). diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index 818e582..a890ca5 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -1,5 +1,6 @@ import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; +import { StorageProviderId, ContainerRef } from "./providers/types.js"; interface QueueUploadParams { experimentID: string; @@ -7,11 +8,18 @@ interface QueueUploadParams { filename: string; data: string; dataType: "data" | "base64"; - osfFilesLink: string; + // Optional — undefined for provider-migrated (e.g. gdrive) experiments, + // which carry storageProvider/providerContainer instead. + osfFilesLink?: string; errorCode: number; sessionIncremented: boolean; failureReason?: string; claimToken?: string; + // Provider-migration fields (additive; absent for legacy OSF experiments — + // omitted from the Firestore write below rather than stored as undefined, + // since Firestore rejects undefined field values). + storageProvider?: StorageProviderId; + providerContainer?: ContainerRef; } const MAX_RETRIES = 5; @@ -42,14 +50,16 @@ export default async function queueUpload(params: QueueUploadParams): Promise = { experimentID: params.experimentID, owner: params.owner, filename: params.filename, storagePath, dataType: params.dataType, - osfFilesLink: params.osfFilesLink, status: "pending", errorCode: params.errorCode, retryCount: 0, @@ -62,7 +72,19 @@ export default async function queueUpload(params: QueueUploadParams): Promise { @@ -43,3 +44,87 @@ export default async function resolveToken( return { success: true, token: decrypt(user_data.authToken) }; } + +async function resolveGdriveToken( + user_data: UserData, + exp_data: ExperimentData, +): Promise { + const gdrive = user_data.connectedAccounts?.gdrive; + + if (!gdrive) { + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Google Drive account for this experiment's owner", + }; + } + + if (gdrive.tokenExpiresAt > Date.now()) { + return { success: true, token: decrypt(gdrive.encryptedToken) }; + } + + const tokenUrl = process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token"; + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: decrypt(gdrive.encryptedRefreshToken), + client_id: process.env.GDRIVE_CLIENT_ID as string, + client_secret: process.env.GDRIVE_CLIENT_SECRET as string, + }); + + let tokenResponse: Response; + try { + tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown network error"; + return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; + } + + if (!tokenResponse.ok) { + const detail = await tokenResponse.text(); + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: detail || "Refresh token is not valid" }; + } + + const tokenData = await tokenResponse.json(); + + const newTokenExpiresAt = Date.now() + tokenData.expires_in * 1000; + + const update: Record = { + "connectedAccounts.gdrive.encryptedToken": encrypt(tokenData.access_token), + "connectedAccounts.gdrive.tokenExpiresAt": newTokenExpiresAt, + }; + + // Only rotate the refresh token when the provider actually issued a new + // one — otherwise leave the existing one in place. + if (tokenData.refresh_token) { + update["connectedAccounts.gdrive.encryptedRefreshToken"] = encrypt(tokenData.refresh_token); + } + + await db.doc(`users/${exp_data.owner}`).update(update); + + return { success: true, token: tokenData.access_token }; +} + +export default async function resolveToken( + user_data: UserData, + exp_data: ExperimentData, +): Promise { + if (!exp_data.storageProvider || exp_data.storageProvider === "osf") { + return resolveOsfToken(user_data, exp_data); + } + + if (exp_data.storageProvider === "gdrive") { + return resolveGdriveToken(user_data, exp_data); + } + + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: `Unsupported storage provider: ${exp_data.storageProvider}`, + }; +} diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index 933fe67..45f2111 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -1,7 +1,8 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; -import { osfProvider } from "./providers/osf.js"; +import { getProvider } from "./providers/index.js"; +import { ContainerRef, StorageProviderId } from "./providers/types.js"; import resolveToken from "./resolve-token.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from "./interfaces.js"; @@ -134,16 +135,23 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho return; } - const container = { provider: "osf" as const, filesLink: data.osfFilesLink }; + // Provider/container come from the queue doc's provider-migration fields + // when present; legacy entries (queued before this generalization) fall + // back to the OSF shape built from osfFilesLink. + const providerId: StorageProviderId = (data.storageProvider as StorageProviderId) || "osf"; + const provider = getProvider(providerId); + const container: ContainerRef = data.providerContainer + ? (data.providerContainer as ContainerRef) + : { provider: "osf", filesLink: data.osfFilesLink }; // Collision cache: only entries queued after the cache existed carry a // claimToken. Entries queued before it skip the cache entirely — legacy - // behavior, OSF's own 409 backstop still applies to them. + // behavior, the provider's own conflict backstop still applies to them. if (data.claimToken) { let claimResult: Awaited>; try { claimResult = await claimFilename(data.experimentID, data.filename, data.claimToken, () => - osfProvider.listFiles({ token }, container) + provider.listFiles({ token }, container) ); } catch (e) { if (e instanceof CollisionCacheUnavailableError) { @@ -169,7 +177,7 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho // Attempt the upload try { - const result = await osfProvider.writeSessionFile( + const result = await provider.writeSessionFile( { token }, container, data.filename, @@ -192,11 +200,11 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho } // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); - console.log(`Upload ${queueDoc.id} marked complete — file already exists in OSF.`); + console.log(`Upload ${queueDoc.id} marked complete — file already exists provider-side.`); return; } - await handleRetryFailure(docRef, data, `OSF error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); + await handleRetryFailure(docRef, data, `Provider error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await handleRetryFailure(docRef, data, `Upload exception: ${detail}`); From 8f70d234a8c24b6a27cfd318191ba57ce37f585a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 09:24:30 -0400 Subject: [PATCH 033/181] fix: write dual-run disagreement logs before responding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collisionCacheDisagreement audit entry was written after the 400 response was already sent, so observers of the log (including the step-3a integration test under parallel-suite load) raced the write. Logs in the disagreement branch now land before the response in both apidata and apibase64 — same fix class as the earlier get-condition log race. Co-Authored-By: Claude Fable 5 --- functions/src/api-base64.ts | 4 +++- functions/src/api-data.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 9861daf..73c32fb 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -219,12 +219,14 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: // says it's taken. OSF is still the backstop — record the // disagreement and confirm the claim (the name is now provably taken). await confirmClaim(experimentID, filename, claimToken); - res.status(400).json(MESSAGES.OSF_FILE_EXISTS); + // Logs before response — see the matching comment in api-data.ts: + // responding first races observers of the log against the write. await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", { collisionCacheDisagreement: true, direction: "cache-free-provider-conflict", }); + res.status(400).json(MESSAGES.OSF_FILE_EXISTS); return; } // Queue all other failures for retry. The claim stays pending so the diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 958d1d9..06a1f17 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -255,12 +255,15 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // says it's taken. OSF is still the backstop — record the // disagreement and confirm the claim (the name is now provably taken). await confirmClaim(experimentID, filename, claimToken); - res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); + // Logs are written BEFORE the response here (unlike other branches): + // the disagreement entry is the dual-run's whole audit trail, and + // responding first races observers of the log against the write. await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", { collisionCacheDisagreement: true, direction: "cache-free-provider-conflict", }); + res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); return; } // Queue all other failures for retry. The claim stays pending so the From c65373282c90bcff2c9d9d97e793b6682890a767 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 09:24:30 -0400 Subject: [PATCH 034/181] feat: provider OAuth connect flow (gdrive) alongside the OSF identity flow Build step 4b, completing build step 4 of the provider migration. The OSF OAuth flow is an identity flow (signup/sign-in/linking with Firebase custom tokens) and stays byte-identical. New providers get a separate storage-grant flow for already-authenticated users: - providers/oauth-config.ts: per-provider OAuth registry (gdrive only for now; env-driven, call-time reads; figshare becomes a config addition) - generateOAuthState optionally takes a provider: state doc records it and the response includes a ready-made authorize URL with access_type=offline&prompt=consent (without which Google never issues a refresh token) - connectProvider: CSRF-state validation (single-use, provider-matched), verifyIdToken uid ownership, code exchange, hard-fail if the exchange returns no refresh_token (no half-connected accounts), sibling-safe encrypted persistence into connectedAccounts. - disconnectProvider: same auth, FieldValue.delete of the map entry - gdrive token refresh extracted to providers/gdrive-oauth.ts, shared by resolveToken and a new scheduled pass (refreshExpiringGdriveTokens, 10-min window) that cannot break the OSF pass - design doc: documents the pre-existing test hazard that the OSF refresh path has no URL override and can hit production accounts.osf.io from emulator tests TDD: 17 contract tests reviewed red first; refresh-helper tests use per-token assertions and a discriminating fetch mock so the helper's collection-wide scan stays isolated from parallel suites. Full emulator suite green twice consecutively (27 suites, 191 tests). Co-Authored-By: Claude Fable 5 --- docs/provider-migration-design.md | 7 + functions/.env.datapipe-test | 7 +- .../__tests__/oauth-connect-emulator.test.js | 507 ++++++++++++++++++ .../oauth-connect-refresh-emulator.test.js | 191 +++++++ ...oauth-connect-scheduled-regression.test.js | 90 ++++ functions/src/connect-provider.ts | 193 +++++++ functions/src/generate-oauth-state.ts | 47 +- functions/src/index.ts | 3 + functions/src/providers/gdrive-oauth.ts | 75 +++ functions/src/providers/oauth-config.ts | 44 ++ functions/src/resolve-token.ts | 50 +- functions/src/scheduled-token-refresh.ts | 53 ++ 12 files changed, 1217 insertions(+), 50 deletions(-) create mode 100644 functions/src/__tests__/oauth-connect-emulator.test.js create mode 100644 functions/src/__tests__/oauth-connect-refresh-emulator.test.js create mode 100644 functions/src/__tests__/oauth-connect-scheduled-regression.test.js create mode 100644 functions/src/connect-provider.ts create mode 100644 functions/src/providers/gdrive-oauth.ts create mode 100644 functions/src/providers/oauth-config.ts diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 0987307..b9fc536 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -357,6 +357,13 @@ provider, it does not trigger a redesign. ## Open questions +- **Test-suite hazard (pre-existing, discovered during step 4b)**: the OSF + token-refresh path has no URL override (unlike GDRIVE_TOKEN_URL), so an + emulator test that seeds a refresh-due OSF user makes a REAL network call + to production accounts.osf.io using the credentials in functions/.env. + The scheduled-refresh regression test deliberately pins a network-free + path because of this. Fix: introduce an OSF_TOKEN_URL override mirroring + the gdrive pattern, then pin the live-refresh branch properly. - Decide the exact collision-cache TTL window (90 days proposed, not yet validated against real usage patterns). - Decide the UX for Dataverse's federated `serverUrl` requirement (does diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test index aad45d6..a28fb52 100644 --- a/functions/.env.datapipe-test +++ b/functions/.env.datapipe-test @@ -1,2 +1,7 @@ GDRIVE_API_BASE=http://127.0.0.1:3579 -GDRIVE_TOKEN_URL=http://127.0.0.1:3579/token +GDRIVE_TOKEN_URL=http://127.0.0.1:3580/token +GDRIVE_AUTHORIZE_URL=http://127.0.0.1:3580/authorize +GDRIVE_CLIENT_ID=test-client-id +GDRIVE_CLIENT_SECRET=test-client-secret +GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/gdrive +TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab diff --git a/functions/src/__tests__/oauth-connect-emulator.test.js b/functions/src/__tests__/oauth-connect-emulator.test.js new file mode 100644 index 0000000..c1d0c58 --- /dev/null +++ b/functions/src/__tests__/oauth-connect-emulator.test.js @@ -0,0 +1,507 @@ +/** + * @jest-environment node + */ + +// RED-phase tests for step 4b (docs/provider-migration-design.md, +// scratchpad/step4b-oauth-connect-spec.md), cases 1-9 of the test plan. +// +// generateOAuthState (generate-oauth-state.ts) exists today but only knows +// the legacy, provider-less OSF flow -- it ignores any `provider` field in +// the POST body entirely, so case 1 (no provider) is a regression guard +// expected to PASS today, while cases 2-3 (provider handling, unknown- +// provider validation) fail red because that branch doesn't exist yet. +// +// connectProvider and disconnectProvider (functions/src/connect-provider.ts, +// exported as connectprovider/disconnectprovider per index.ts's lowercase +// export convention -- see apiData -> apidata) do not exist at all yet, so +// every request to their emulator URLs 404s. Cases 4-9 are all red for that +// reason; none of the validation/persistence logic described in the spec +// exists to exercise yet. Where a case's setup depends on case 2's +// (not-yet-existing) provider-aware generateOAuthState -- e.g. case 4's +// "state from (2)" -- this file still calls the real endpoint the way the +// finished feature is meant to be exercised; today that just means the +// created state doc won't carry `provider: "gdrive"` yet, which is +// irrelevant since connectProvider 404s long before it would read that +// field anyway. +// +// Real Auth-emulator idTokens: the Auth emulator's accounts:signUp REST +// endpoint (http://localhost:9099/identitytoolkit.googleapis.com/v1/ +// accounts:signUp?key=fake) returns a real idToken + localId for any +// email/password; localId is used as the uid so connectProvider's (future) +// auth.verifyIdToken(idToken) check matches -- there is no other way to get +// a verifiable idToken against the emulator's Auth backend. +// +// Mock OAuth token server: a fixed port (3580, this file only -- see +// functions/.env.datapipe-test) express server standing in for Google's +// token endpoint. Fixed, not listen(0), for the same reason as +// gdrive-emulator.test.js's mock Drive server: the emulator-hosted +// connectProvider function has no other way to discover where the mock +// lives, since GDRIVE_TOKEN_URL is read from the Functions emulator's own +// env, not passed at request time. Port 3579 (also fixed) is reserved for +// gdrive-emulator.test.js's mock Drive API and is never touched here. +// +// TOKEN_ENCRYPTION_KEY: this file's process sets the SAME fixed 64-hex value +// baked into functions/.env.datapipe-test so the Firestore-persisted +// encryptedToken -- written by the separate Functions-emulator process -- +// can be decrypted and verified here. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +// Must match functions/.env.datapipe-test exactly. +const TOKEN_ENCRYPTION_KEY = "ab".repeat(32); +const GDRIVE_AUTHORIZE_URL = "http://127.0.0.1:3580/authorize"; +const GDRIVE_CLIENT_ID = "test-client-id"; +const GDRIVE_REDIRECT_URI = "http://localhost:3000/oauth2/gdrive"; +const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file"; +const TOKEN_PORT = 3580; + +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +let db; +let mockTokenServer; +let decrypt; + +beforeAll(async () => { + process.env.TOKEN_ENCRYPTION_KEY = TOKEN_ENCRYPTION_KEY; + ({ decrypt } = await import("../../lib/crypto-utils.js")); + + let app; + try { + app = getApp("oauth-connect-test"); + } catch { + app = initializeApp(config, "oauth-connect-test"); + } + db = getFirestore(app); + + mockTokenServer = await createMockTokenServer(); +}); + +afterEach(() => { + mockTokenServer.reset(); +}); + +afterAll(() => { + mockTokenServer.server.close(); +}); + +// ---- helpers ---- + +// A minimal stand-in for Google's token endpoint: records every received +// form-encoded body and returns a configurable response. Defaults to a +// happy-path grant so tests that don't care about the exchange details +// (e.g. auth-failure cases, which never reach the exchange) don't need to +// configure it. +function createMockTokenServer() { + const app = express(); + app.use(express.urlencoded({ extended: false })); + + function defaultResponse() { + return { + status: 200, + body: { + access_token: "mock-access-token", + refresh_token: "mock-refresh-token", + expires_in: 3600, + }, + }; + } + + let nextResponse = defaultResponse(); + const receivedRequests = []; + + app.post("/token", (req, res) => { + receivedRequests.push({ ...req.body }); + res.status(nextResponse.status).json(nextResponse.body); + }); + + return new Promise((resolve) => { + const server = app.listen(TOKEN_PORT, () => { + resolve({ + server, + setNextResponse(status, body) { + nextResponse = { status, body }; + }, + getLastRequest() { + return receivedRequests[receivedRequests.length - 1]; + }, + reset() { + receivedRequests.length = 0; + nextResponse = defaultResponse(); + }, + }); + }); + }); +} + +async function signUpEmulatorUser() { + const email = `oauth-connect-${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 generateState(provider) { + const res = await fetch(`${FUNCTIONS_BASE}/generateoauthstate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(provider ? { provider } : {}), + }); + const body = await res.json(); + return { status: res.status, body }; +} + +async function createStateDoc(overrides = {}) { + const state = randomUUID(); + await db.collection("oauth_states").doc(state).set({ + createdAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, + provider: "gdrive", + ...overrides, + }); + return state; +} + +async function getStateDoc(state) { + return db.collection("oauth_states").doc(state).get(); +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +async function postJson(url, payload) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; +} + +function callConnectProvider(payload) { + return postJson(`${FUNCTIONS_BASE}/connectprovider`, payload); +} + +function callDisconnectProvider(payload) { + return postJson(`${FUNCTIONS_BASE}/disconnectprovider`, payload); +} + +// ---- cases ---- + +describe("1. generateOAuthState without provider (regression guard)", () => { + it("returns { state } only, with no authorizeUrl, and the state doc has no provider field -- expected to PASS today", async () => { + const { status, body } = await generateState(); + + expect(status).toBe(200); + expect(typeof body.state).toBe("string"); + expect(body.authorizeUrl).toBeUndefined(); + expect(Object.keys(body).sort()).toEqual(["state"]); + + const stateDoc = await getStateDoc(body.state); + expect(stateDoc.exists).toBe(true); + expect(stateDoc.data().provider).toBeUndefined(); + }); +}); + +describe("2. generateOAuthState with provider gdrive", () => { + it("returns { state, authorizeUrl } with the correct query params, and records provider on the state doc", async () => { + const { status, body } = await generateState("gdrive"); + + expect(status).toBe(200); + expect(typeof body.state).toBe("string"); + expect(typeof body.authorizeUrl).toBe("string"); + + const url = new URL(body.authorizeUrl); + expect(`${url.origin}${url.pathname}`).toBe(GDRIVE_AUTHORIZE_URL); + expect(url.searchParams.get("client_id")).toBe(GDRIVE_CLIENT_ID); + expect(url.searchParams.get("redirect_uri")).toBe(GDRIVE_REDIRECT_URI); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe(GDRIVE_SCOPE); + expect(url.searchParams.get("state")).toBe(body.state); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("prompt")).toBe("consent"); + + const stateDoc = await getStateDoc(body.state); + expect(stateDoc.data().provider).toBe("gdrive"); + }); +}); + +describe("3. generateOAuthState with unknown provider", () => { + it("returns 400", async () => { + const { status } = await generateState("not-a-real-provider"); + expect(status).toBe(400); + }); +}); + +describe("4. connectProvider happy path", () => { + it("exchanges the code, persists an encrypted gdrive connection, and deletes the state (single-use)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { body: stateBody } = await generateState("gdrive"); + const state = stateBody.state; + + mockTokenServer.setNextResponse(200, { + access_token: "case4-access-token", + refresh_token: "case4-refresh-token", + expires_in: 3600, + }); + + const before = Date.now(); + const { status, body } = await callConnectProvider({ + provider: "gdrive", + code: "case4-auth-code", + state, + uid, + idToken, + }); + const after = Date.now(); + + expect(status).toBe(200); + expect(body).toEqual({ success: true, provider: "gdrive" }); + + const userData = await getUserData(uid); + const gdrive = userData.connectedAccounts.gdrive; + expect(gdrive.authMethod).toBe("oauth2"); + expect(gdrive.encryptedToken.startsWith("v1:")).toBe(true); + expect(decrypt(gdrive.encryptedToken)).toBe("case4-access-token"); + expect(gdrive.tokenExpiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000 - 5000); + expect(gdrive.tokenExpiresAt).toBeLessThanOrEqual(after + 3600 * 1000 + 5000); + + const lastRequest = mockTokenServer.getLastRequest(); + expect(lastRequest.grant_type).toBe("authorization_code"); + expect(lastRequest.code).toBe("case4-auth-code"); + expect(lastRequest.client_id).toBe(GDRIVE_CLIENT_ID); + expect(lastRequest.redirect_uri).toBe(GDRIVE_REDIRECT_URI); + expect(typeof lastRequest.client_secret).toBe("string"); + expect(lastRequest.client_secret.length).toBeGreaterThan(0); + + const stateDoc = await getStateDoc(state); + expect(stateDoc.exists).toBe(false); + }); +}); + +describe("5. connectProvider state validation", () => { + it("rejects a state issued without a provider (legacy OSF state) as a provider mismatch", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { body: legacyStateBody } = await generateState(); // no provider -- legacy OSF state + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case5-mismatch-code", + state: legacyStateBody.state, + uid, + idToken, + }); + + expect(status).toBe(400); + }); + + it("rejects a reused (already-consumed) state", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const state = await createStateDoc(); + await db.collection("oauth_states").doc(state).delete(); // simulate prior consumption + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case5-reused-code", + state, + uid, + idToken, + }); + + expect(status).toBe(400); + }); + + it("rejects an expired state", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const state = await createStateDoc({ expiresAt: Date.now() - 1000 }); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case5-expired-code", + state, + uid, + idToken, + }); + + expect(status).toBe(400); + }); +}); + +describe("6. connectProvider auth failures", () => { + it("returns 401 when idToken is missing, and persists nothing", async () => { + const { uid } = await signUpEmulatorUser(); + const state = await createStateDoc(); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case6-missing-idtoken", + state, + uid, + }); + + expect(status).toBe(401); + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.gdrive).toBeUndefined(); + }); + + it("returns 403 when idToken belongs to a different emulator user than uid, and persists nothing", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + const state = await createStateDoc(); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case6-mismatched-user", + state, + uid: userA.uid, + idToken: userB.idToken, + }); + + expect(status).toBe(403); + const userAData = await getUserData(userA.uid); + expect(userAData?.connectedAccounts?.gdrive).toBeUndefined(); + }); +}); + +describe("7. token exchange without a refresh_token", () => { + it("returns 400 and persists nothing (half-connected-account guard)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const state = await createStateDoc(); + + mockTokenServer.setNextResponse(200, { + access_token: "case7-access-token", + expires_in: 3600, + // no refresh_token -- Google only issues one with access_type=offline&prompt=consent + }); + + const { status, body } = await callConnectProvider({ + provider: "gdrive", + code: "case7-no-refresh-code", + state, + uid, + idToken, + }); + + expect(status).toBe(400); + expect(body.error).toBe("Token exchange failed"); + + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.gdrive).toBeUndefined(); + }); +}); + +describe("8. connectProvider does not clobber sibling providers", () => { + it("leaves a pre-existing connectedAccounts.dataverse entry intact after connecting gdrive", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const dataverseEntry = { + authMethod: "static-token", + encryptedToken: "dataverse-token-placeholder", + serverUrl: "https://demo.dataverse.org", + }; + await db + .collection("users") + .doc(uid) + .set({ connectedAccounts: { dataverse: dataverseEntry } }, { merge: true }); + + const { body: stateBody } = await generateState("gdrive"); + mockTokenServer.setNextResponse(200, { + access_token: "case8-access-token", + refresh_token: "case8-refresh-token", + expires_in: 3600, + }); + + const { status } = await callConnectProvider({ + provider: "gdrive", + code: "case8-code", + state: stateBody.state, + uid, + idToken, + }); + + expect(status).toBe(200); + const userData = await getUserData(uid); + expect(userData.connectedAccounts.dataverse).toEqual(dataverseEntry); + expect(userData.connectedAccounts.gdrive).toBeDefined(); + }); +}); + +describe("9. disconnectProvider", () => { + it("removes connectedAccounts.gdrive while leaving siblings intact", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const dataverseEntry = { + authMethod: "static-token", + encryptedToken: "dataverse-token-placeholder-9", + serverUrl: "https://demo.dataverse.org", + }; + await db + .collection("users") + .doc(uid) + .set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + encryptedToken: "pre-existing-gdrive-token", + encryptedRefreshToken: "pre-existing-gdrive-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + }, + dataverse: dataverseEntry, + }, + }); + + const { status, body } = await callDisconnectProvider({ provider: "gdrive", uid, idToken }); + + expect(status).toBe(200); + expect(body).toEqual({ success: true }); + + const userData = await getUserData(uid); + expect(userData.connectedAccounts.gdrive).toBeUndefined(); + expect(userData.connectedAccounts.dataverse).toEqual(dataverseEntry); + }); + + it("returns 403 for a wrong-user idToken and leaves the entry untouched", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + const gdriveEntry = { + authMethod: "oauth2", + encryptedToken: "userA-gdrive-token", + encryptedRefreshToken: "userA-gdrive-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + }; + await db.collection("users").doc(userA.uid).set({ connectedAccounts: { gdrive: gdriveEntry } }); + + const { status } = await callDisconnectProvider({ + provider: "gdrive", + uid: userA.uid, + idToken: userB.idToken, + }); + + expect(status).toBe(403); + const userAData = await getUserData(userA.uid); + expect(userAData.connectedAccounts.gdrive).toEqual(gdriveEntry); + }); +}); diff --git a/functions/src/__tests__/oauth-connect-refresh-emulator.test.js b/functions/src/__tests__/oauth-connect-refresh-emulator.test.js new file mode 100644 index 0000000..bee1eed --- /dev/null +++ b/functions/src/__tests__/oauth-connect-refresh-emulator.test.js @@ -0,0 +1,191 @@ +/** + * @jest-environment node + */ + +// RED-phase test for step 4b (scratchpad/step4b-oauth-connect-spec.md), case +// 10 of the test plan. +// +// refreshExpiringGdriveTokens does not exist yet -- the spec calls for it to +// be extracted out of scheduled-token-refresh.ts (which today only knows the +// OSF refresh pass) as a new exported helper, sharing its actual refresh +// logic with resolve-token.ts via a shared providers/gdrive-oauth.ts module. +// Importing a named export that the compiled module doesn't provide fails at +// ESM module-evaluation time ("does not provide an export named +// 'refreshExpiringGdriveTokens'"), which fails EVERY test in whatever file +// performs the import -- so this case lives in its own file, per the +// build-step instructions, to keep that red from masking the +// connectProvider/disconnectProvider/generateOAuthState cases in +// oauth-connect-emulator.test.js or the OSF regression guard in +// oauth-connect-scheduled-regression.test.js. +// +// This test never touches port 3580 (the mock token server used by the +// connectProvider tests): it mocks global.fetch directly, in-process, the +// same way resolve-token-gdrive.test.js's cases 9-10 do for resolveToken's +// gdrive refresh path. TOKEN_ENCRYPTION_KEY/GDRIVE_* env vars here are +// scoped to this jest process only and don't need to match +// functions/.env.datapipe-test (which is loaded by the separate Functions- +// emulator process for the HTTP-endpoint tests). + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import { refreshExpiringGdriveTokens } from "../../lib/scheduled-token-refresh.js"; +import { decrypt } from "../../lib/crypto-utils.js"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; + +let db; +const ORIGINAL_FETCH = global.fetch; +const ORIGINAL_ENV = { + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + GDRIVE_TOKEN_URL: process.env.GDRIVE_TOKEN_URL, + GDRIVE_CLIENT_ID: process.env.GDRIVE_CLIENT_ID, + GDRIVE_CLIENT_SECRET: process.env.GDRIVE_CLIENT_SECRET, +}; + +beforeAll(async () => { + let app; + try { + app = getApp("oauth-connect-refresh-test"); + } catch { + app = initializeApp(config, "oauth-connect-refresh-test"); + } + db = getFirestore(app); + + // Scoped to this process only -- mirrors resolve-token-gdrive.test.js. + process.env.TOKEN_ENCRYPTION_KEY = "22".repeat(32); + process.env.GDRIVE_TOKEN_URL = "https://gdrive-token.mock.test/token"; + process.env.GDRIVE_CLIENT_ID = "test-gdrive-client-id-refresh"; + process.env.GDRIVE_CLIENT_SECRET = "test-gdrive-client-secret-refresh"; +}); + +afterAll(() => { + process.env.TOKEN_ENCRYPTION_KEY = ORIGINAL_ENV.TOKEN_ENCRYPTION_KEY; + process.env.GDRIVE_TOKEN_URL = ORIGINAL_ENV.GDRIVE_TOKEN_URL; + process.env.GDRIVE_CLIENT_ID = ORIGINAL_ENV.GDRIVE_CLIENT_ID; + process.env.GDRIVE_CLIENT_SECRET = ORIGINAL_ENV.GDRIVE_CLIENT_SECRET; + global.fetch = ORIGINAL_FETCH; +}); + +// The helper legitimately scans the ENTIRE users collection, and the +// Firestore emulator is shared across parallel jest workers — other suites +// (resolve-token-gdrive, gdrive-emulator, oauth-connect) seed their own +// gdrive users, some deliberately expired, which this helper WILL pick up +// mid-run. Two isolation rules follow: +// 1. Never assert on global fetch call counts — only on calls carrying +// THIS test's (unique) refresh token. +// 2. The fetch mock must answer foreign refresh tokens with a failure — +// the helper's failure path leaves the user untouched, so we can't +// corrupt another suite's seeded tokens. +function installDiscriminatingFetchMock(ownToken, ownResponse) { + global.fetch = jest.fn(async (_url, options) => { + const params = new URLSearchParams(options?.body); + if (params.get("refresh_token") === ownToken) { + return ownResponse; + } + return { ok: false, status: 400, text: () => Promise.resolve("foreign-token-rejected") }; + }); +} + +function callsForToken(token) { + return global.fetch.mock.calls.filter(([, options]) => { + const params = new URLSearchParams(options?.body); + return params.get("refresh_token") === token; + }); +} + +const WINDOW_MS = 10 * 60 * 1000; // matches the spec's default window + +async function createGdriveUser(uid, overrides = {}) { + const gdrive = { + authMethod: "oauth2", + encryptedToken: "plain-access-token", + encryptedRefreshToken: "plain-refresh-token", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, + }; + await db.collection("users").doc(uid).set({ connectedAccounts: { gdrive } }); + return gdrive; +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +describe("10. refreshExpiringGdriveTokens", () => { + it("refreshes and persists a new encrypted token for a user inside the window", async () => { + const uid = `refresh-window-${randomUUID()}`; + const ownRefreshToken = `own-refresh-${randomUUID()}`; + await createGdriveUser(uid, { + tokenExpiresAt: Date.now() + 2 * 60 * 1000, + encryptedRefreshToken: ownRefreshToken, + }); + + installDiscriminatingFetchMock(ownRefreshToken, { + ok: true, + json: () => + Promise.resolve({ + access_token: "refreshed-token", + expires_in: 3600, + refresh_token: "refreshed-refresh-token", + }), + }); + + const before = Date.now(); + await refreshExpiringGdriveTokens(WINDOW_MS); + const after = Date.now(); + + expect(callsForToken(ownRefreshToken)).toHaveLength(1); + const persisted = await getUserData(uid); + const gdrive = persisted.connectedAccounts.gdrive; + expect(decrypt(gdrive.encryptedToken)).toBe("refreshed-token"); + expect(decrypt(gdrive.encryptedRefreshToken)).toBe("refreshed-refresh-token"); + expect(gdrive.tokenExpiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000 - 5000); + expect(gdrive.tokenExpiresAt).toBeLessThanOrEqual(after + 3600 * 1000 + 5000); + }); + + it("leaves a user outside the window untouched and never calls fetch for them", async () => { + const uid = `refresh-outside-${randomUUID()}`; + const ownRefreshToken = `outside-refresh-${randomUUID()}`; + const original = await createGdriveUser(uid, { + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + encryptedRefreshToken: ownRefreshToken, + }); + + installDiscriminatingFetchMock(ownRefreshToken, { + ok: true, + json: () => Promise.resolve({ access_token: "should-never-be-used", expires_in: 3600 }), + }); + + await refreshExpiringGdriveTokens(WINDOW_MS); + + expect(callsForToken(ownRefreshToken)).toHaveLength(0); + const persisted = await getUserData(uid); + expect(persisted.connectedAccounts.gdrive).toEqual(original); + }); + + it("leaves the connection as-was and does not throw when the refresh request fails", async () => { + const uid = `refresh-fail-${randomUUID()}`; + const ownRefreshToken = `fail-refresh-${randomUUID()}`; + const original = await createGdriveUser(uid, { + tokenExpiresAt: Date.now() + 2 * 60 * 1000, + encryptedRefreshToken: ownRefreshToken, + }); + + installDiscriminatingFetchMock(ownRefreshToken, { + ok: false, + status: 400, + text: () => Promise.resolve("invalid_grant"), + }); + + await expect(refreshExpiringGdriveTokens(WINDOW_MS)).resolves.not.toThrow(); + + expect(callsForToken(ownRefreshToken)).toHaveLength(1); + const persisted = await getUserData(uid); + expect(persisted.connectedAccounts.gdrive).toEqual(original); + }); +}); diff --git a/functions/src/__tests__/oauth-connect-scheduled-regression.test.js b/functions/src/__tests__/oauth-connect-scheduled-regression.test.js new file mode 100644 index 0000000..06c9502 --- /dev/null +++ b/functions/src/__tests__/oauth-connect-scheduled-regression.test.js @@ -0,0 +1,90 @@ +/** + * @jest-environment node + */ + +// RED-phase regression guard for step 4b (scratchpad/step4b-oauth-connect- +// spec.md), case 11 of the test plan. +// +// scheduledTokenRefresh (scheduled-token-refresh.ts) is an onSchedule +// function. The Functions emulator exposes scheduled functions for manual +// invocation over HTTP at the same host:port/project/region/name URL as any +// other function (the Local Emulator Suite's documented manual-trigger +// support for onSchedule functions). This test pins its EXISTING, +// OSF-only observable behavior for one user, so that case 10's gdrive +// generalization -- which the spec requires to wrap the new gdrive pass so +// a gdrive failure "can't break the OSF pass" -- cannot silently change it. +// +// It is kept in its own file (not sharing oauth-connect-refresh-emulator. +// test.js) precisely because that file imports a not-yet-existing named +// export and fails at module load, which would fail every test in the file +// -- this regression guard needs to run and report its own, independent +// result. +// +// IMPORTANT deviation from a literal reading of the spec: NEXT_PUBLIC_OSF_ENV +// is "" (not unset) in functions/.env (loaded ahead of .env.datapipe-test, +// which doesn't override it), so refresh-token.ts's hardcoded token URL +// resolves to the REAL "https://accounts.osf.io/oauth2/token" -- and .env +// also carries real-looking CLIENT_ID/CLIENT_SECRET values. Empirically +// invoking the scheduled function against a seeded user whose refresh token +// is due for refresh made a genuine network call to production OSF (it +// returned {"error":"invalid_request"} for the bogus refresh token). Doing +// that from a repeatable test/CI run is exactly the kind of non-hermetic, +// production-touching side effect this suite should avoid -- there is no +// GDRIVE_TOKEN_URL-style override for OSF's endpoint to redirect it to a +// local mock. So instead of pinning the live-refresh branch, this test pins +// the query + per-user-skip behavior that scheduled-token-refresh.ts already +// has for a user with no refreshToken (see its "if (!userData.refreshToken) +// ... continue" branch): the user still matches the query but is skipped +// before any network call happens, which is fully safe to exercise here. +// The live-refresh branch itself could not be safely pinned without hitting +// production OSF -- flagged in the build-step report rather than forced. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +// Verified empirically against this repo's firebase-tools (15.8.0): since +// firebase.json's emulator suite doesn't include the Pub/Sub emulator, +// onSchedule functions are registered for manual HTTP invocation under a +// "-" suffixed trigger name (the emulator logs "function ignored +// because the pubsub emulator does not exist or is not running" for the +// bare name, but still lists "...-0" as a valid HTTP target and executes it +// normally when POSTed). The bare, unsuffixed name 404s. +const SCHEDULED_URL = "http://localhost:5001/datapipe-test/us-central1/scheduledtokenrefresh-0"; + +let db; + +beforeAll(async () => { + let app; + try { + app = getApp("oauth-connect-scheduled-regression-test"); + } catch { + app = initializeApp(config, "oauth-connect-scheduled-regression-test"); + } + db = getFirestore(app); +}); + +describe("11. OSF scheduled pass (regression guard)", () => { + it("matches an OAuth user within the expiration window who has no refreshToken, skips them without a network call, and leaves the doc untouched -- expected to PASS today", async () => { + const uid = `scheduled-osf-regression-${randomUUID()}`; + const original = { + email: `${uid}@example.test`, + usingPersonalToken: false, + refreshTokenExpires: Date.now(), // within the 2-week window -- matches the query + // refreshToken deliberately absent -- exercises the existing + // "if (!userData.refreshToken) { ...; continue; }" skip branch, so + // this never reaches the OSF token endpoint. + }; + await db.collection("users").doc(uid).set(original); + + const response = await fetch(SCHEDULED_URL, { method: "POST" }); + expect(response.ok).toBe(true); + + const persisted = (await db.collection("users").doc(uid).get()).data(); + expect(persisted).toEqual(original); + }); +}); diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts new file mode 100644 index 0000000..9bba0f5 --- /dev/null +++ b/functions/src/connect-provider.ts @@ -0,0 +1,193 @@ +// Provider connect/disconnect flow (docs/provider-migration-design.md, +// scratchpad/step4b-oauth-connect-spec.md). +// +// This is a storage GRANT flow for already-authenticated users, distinct +// from oauth2-callback.ts's OSF IDENTITY flow (signup/sign-in/account +// linking, Firebase custom tokens). There is no signup path here, ever — +// the caller must already hold a valid Firebase idToken for the uid they +// claim. + +import { onRequest } from "firebase-functions/v2/https"; +import { FieldValue } from "firebase-admin/firestore"; +import { db, auth } from "./app.js"; +import { encrypt } from "./crypto-utils.js"; +import { getOAuthConfig } from "./providers/oauth-config.js"; + +type AuthCheckResult = + | { ok: true } + | { ok: false; status: number; error: string }; + +async function verifyOwnership(uid: string, idToken: string | undefined): Promise { + if (!idToken) { + return { ok: false, status: 401, error: 'Authentication required' }; + } + try { + const decodedToken = await auth.verifyIdToken(idToken); + if (decodedToken.uid !== uid) { + return { ok: false, status: 403, error: 'User ID does not match authenticated user' }; + } + return { ok: true }; + } catch { + return { ok: false, status: 401, error: 'Invalid authentication token' }; + } +} + +export const connectProvider = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { provider, code, state, uid, idToken } = req.body || {}; + + if (!provider || !code || !state || !uid) { + res.status(400).json({ error: 'Missing required parameters' }); + return; + } + + let config; + try { + config = getOAuthConfig(provider); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + // Server-side CSRF validation: verify the state was issued by our + // server. Same oauth_states collection + semantics as the OSF callback + // (oauth2-callback.ts): exists, not expired, single-use delete. + const stateRef = db.collection('oauth_states').doc(state); + const stateDoc = await stateRef.get(); + if (!stateDoc.exists) { + res.status(400).json({ error: 'Invalid state parameter' }); + return; + } + const stateData = stateDoc.data(); + if (stateData && stateData.expiresAt < Date.now()) { + await stateRef.delete(); + res.status(400).json({ error: 'State parameter has expired' }); + return; + } + // Delete after use — each state token is single-use. + await stateRef.delete(); + + // The state must have been issued for this exact provider (a legacy + // OSF state, issued with no provider at all, must never be accepted + // here). + if (!stateData || stateData.provider !== provider) { + res.status(400).json({ error: 'State was not issued for this provider' }); + return; + } + + // Verify that the caller owns the uid they claim. No signup path here. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + // Exchange the authorization code for tokens. + const params = new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: config.redirectUri, + grant_type: 'authorization_code', + }); + + let tokenResponse: Response; + try { + tokenResponse = await fetch(config.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + } catch (e) { + console.error('Token exchange network error:', e instanceof Error ? e.message : 'Unknown error'); + res.status(400).json({ error: 'Token exchange failed' }); + return; + } + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + console.error('Token exchange failed:', errorText); + res.status(400).json({ error: 'Token exchange failed' }); + return; + } + + const tokenData = await tokenResponse.json(); + + // Hard-fail on a missing refresh_token: Google only issues one with + // access_type=offline&prompt=consent, so its absence means we'd + // otherwise persist a half-connected account with no way to refresh. + if (!tokenData.access_token || !tokenData.refresh_token || !tokenData.expires_in) { + res.status(400).json({ error: 'Token exchange failed' }); + return; + } + + // Dot-path persist via set()+mergeFields: creates users/{uid} if it + // doesn't exist yet (a freshly-signed-up user may have no Firestore + // doc at all), while touching only connectedAccounts. and + // leaving any sibling provider connections untouched. + const fieldPath = `connectedAccounts.${provider}`; + await db.doc(`users/${uid}`).set( + { + connectedAccounts: { + [provider]: { + authMethod: 'oauth2', + encryptedToken: encrypt(tokenData.access_token), + encryptedRefreshToken: encrypt(tokenData.refresh_token), + tokenExpiresAt: Date.now() + tokenData.expires_in * 1000, + }, + }, + }, + { mergeFields: [fieldPath] } + ); + + res.status(200).json({ success: true, provider }); + } catch (error) { + console.error('Error connecting provider:', error instanceof Error ? error.message : 'Unknown error'); + res.status(500).json({ error: 'Failed to connect provider' }); + } +}); + +export const disconnectProvider = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { provider, uid, idToken } = req.body || {}; + + if (!provider || !uid) { + res.status(400).json({ error: 'Missing required parameters' }); + return; + } + + try { + getOAuthConfig(provider); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + await db.doc(`users/${uid}`).update({ + [`connectedAccounts.${provider}`]: FieldValue.delete(), + }); + + res.status(200).json({ success: true }); + } catch (error) { + console.error('Error disconnecting provider:', error instanceof Error ? error.message : 'Unknown error'); + res.status(500).json({ error: 'Failed to disconnect provider' }); + } +}); diff --git a/functions/src/generate-oauth-state.ts b/functions/src/generate-oauth-state.ts index 78d3b61..610324f 100644 --- a/functions/src/generate-oauth-state.ts +++ b/functions/src/generate-oauth-state.ts @@ -1,5 +1,6 @@ import { onRequest } from "firebase-functions/v2/https"; import { db } from "./app.js"; +import { getOAuthConfig } from "./providers/oauth-config.js"; export const generateOAuthState = onRequest({ cors: true }, async (req, res) => { try { @@ -8,17 +9,53 @@ export const generateOAuthState = onRequest({ cors: true }, async (req, res) => return; } + // Optional `provider` in the body. Absent → exactly today's legacy + // behavior (the OSF flow keeps working byte-identically): a bare + // { state } response and a state doc with no provider field. + const { provider } = req.body || {}; + const state = crypto.randomUUID(); + const stateData: Record = { + createdAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, + }; + + let authorizeUrl: string | undefined; + + if (provider) { + let config; + try { + config = getOAuthConfig(provider); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + stateData.provider = provider; + + const url = new URL(config.authorizeUrl); + url.searchParams.set('client_id', config.clientId); + url.searchParams.set('redirect_uri', config.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', config.scope); + url.searchParams.set('state', state); + for (const [key, value] of Object.entries(config.extraAuthParams)) { + url.searchParams.set(key, value); + } + authorizeUrl = url.toString(); + } // Store in Firestore with a 10-minute expiry. // The callback endpoint will look this up to verify the state // was actually issued by our server, then delete it (single-use). - await db.collection('oauth_states').doc(state).set({ - createdAt: Date.now(), - expiresAt: Date.now() + 10 * 60 * 1000, - }); + await db.collection('oauth_states').doc(state).set(stateData); + + const response: Record = { state }; + if (authorizeUrl) { + response.authorizeUrl = authorizeUrl; + } - res.status(200).json({ state }); + res.status(200).json(response); } catch (error) { console.error('Error generating OAuth state:', error instanceof Error ? error.message : 'Unknown error'); res.status(500).json({ error: 'Failed to generate state' }); diff --git a/functions/src/index.ts b/functions/src/index.ts index ecbb6c1..40adaf6 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -11,6 +11,7 @@ import { scheduledUploadRetry } from "./scheduled-upload-retry.js"; import { scheduledPendingRecovery } from "./scheduled-pending-recovery.js"; import { apiQueueStatus } from "./api-queue-status.js"; import { generateOAuthState } from "./generate-oauth-state.js"; +import { connectProvider, disconnectProvider } from "./connect-provider.js"; import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; @@ -31,6 +32,8 @@ export { scheduledPendingRecovery as scheduledpendingrecovery, apiQueueStatus as apiqueuestatus, generateOAuthState as generateoauthstate, + connectProvider as connectprovider, + disconnectProvider as disconnectprovider, saveOsfToken as saveosftoken, getOsfToken as getosftoken, onUserDeleted as onuserdeleted diff --git a/functions/src/providers/gdrive-oauth.ts b/functions/src/providers/gdrive-oauth.ts new file mode 100644 index 0000000..3a80b66 --- /dev/null +++ b/functions/src/providers/gdrive-oauth.ts @@ -0,0 +1,75 @@ +// Shared gdrive OAuth token-refresh logic (scratchpad/step4b-oauth-connect- +// spec.md). Extracted out of resolve-token.ts so the same refresh+persist +// path can be called both lazily (resolve-token.ts, on-demand when a token +// has expired) and proactively (scheduled-token-refresh.ts's +// refreshExpiringGdriveTokens, run on a schedule ahead of expiry). +// +// Uses the runtime's global `fetch`, not the "node-fetch" package — matches +// resolve-token.ts's existing OSF refresh sibling (refresh-token.ts) and is +// pinned by resolve-token-gdrive.test.js, which mocks global.fetch. + +import { decrypt, encrypt } from "../crypto-utils.js"; +import { db } from "../app.js"; +import { OAuth2AccountConnection } from "./types.js"; + +export type GdriveRefreshResult = + | { success: true; accessToken: string } + | { success: false; error: string; detail: string }; + +/** + * Refreshes a single user's gdrive access token using their stored refresh + * token, and persists the new (encrypted) access token / expiry — rotating + * the refresh token too, if the provider issued a new one. Does not check + * whether the current token is actually expired; callers decide when to + * invoke this. + */ +export async function refreshGdriveToken( + uid: string, + connection: OAuth2AccountConnection +): Promise { + const tokenUrl = process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token"; + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: decrypt(connection.encryptedRefreshToken), + client_id: process.env.GDRIVE_CLIENT_ID as string, + client_secret: process.env.GDRIVE_CLIENT_SECRET as string, + }); + + let tokenResponse: Response; + try { + tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown network error"; + return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; + } + + if (!tokenResponse.ok) { + const detail = await tokenResponse.text(); + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: detail || "Refresh token is not valid" }; + } + + const tokenData = await tokenResponse.json(); + + const newTokenExpiresAt = Date.now() + tokenData.expires_in * 1000; + + const update: Record = { + "connectedAccounts.gdrive.encryptedToken": encrypt(tokenData.access_token), + "connectedAccounts.gdrive.tokenExpiresAt": newTokenExpiresAt, + }; + + // Only rotate the refresh token when the provider actually issued a new + // one — otherwise leave the existing one in place. + if (tokenData.refresh_token) { + update["connectedAccounts.gdrive.encryptedRefreshToken"] = encrypt(tokenData.refresh_token); + } + + await db.doc(`users/${uid}`).update(update); + + return { success: true, accessToken: tokenData.access_token }; +} diff --git a/functions/src/providers/oauth-config.ts b/functions/src/providers/oauth-config.ts new file mode 100644 index 0000000..ee5a1de --- /dev/null +++ b/functions/src/providers/oauth-config.ts @@ -0,0 +1,44 @@ +// Provider OAuth registry (docs/provider-migration-design.md, +// scratchpad/step4b-oauth-connect-spec.md). +// +// Only 'gdrive' is registered today. 'osf' deliberately has no entry here — +// the OSF identity flow (oauth2-callback.ts) is a separate, untouched +// legacy path with its own env vars (CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). +// Structured so a provider like figshare is a config addition, not a +// rewrite. + +export interface OAuthConfig { + authorizeUrl: string; + tokenUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scope: string; + extraAuthParams: Record; +} + +// Each entry is a factory (not a plain object) so env vars are read at +// CALL time, not module load — mirrors providers/gdrive.ts's getApiBase(). +const CONFIG_FACTORIES: Record OAuthConfig> = { + gdrive: () => ({ + authorizeUrl: + process.env.GDRIVE_AUTHORIZE_URL || "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token", + clientId: process.env.GDRIVE_CLIENT_ID as string, + clientSecret: process.env.GDRIVE_CLIENT_SECRET as string, + redirectUri: process.env.GDRIVE_REDIRECT_URI as string, + scope: "https://www.googleapis.com/auth/drive.file", + // Without these, Google won't issue a refresh_token on the consent + // grant — a half-connected account (access token, no refresh token) + // is treated as a hard failure downstream. + extraAuthParams: { access_type: "offline", prompt: "consent" }, + }), +}; + +export function getOAuthConfig(provider: string): OAuthConfig { + const factory = CONFIG_FACTORIES[provider]; + if (!factory) { + throw new Error(`Unknown or unsupported OAuth provider: ${provider}`); + } + return factory(); +} diff --git a/functions/src/resolve-token.ts b/functions/src/resolve-token.ts index 2ca0033..47884cb 100644 --- a/functions/src/resolve-token.ts +++ b/functions/src/resolve-token.ts @@ -1,6 +1,6 @@ -import { decrypt, encrypt } from "./crypto-utils.js"; +import { decrypt } from "./crypto-utils.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; -import { db } from "./app.js"; +import { refreshGdriveToken } from "./providers/gdrive-oauth.js"; import { ExperimentData, UserData } from './interfaces'; type TokenResult = { @@ -63,51 +63,13 @@ async function resolveGdriveToken( return { success: true, token: decrypt(gdrive.encryptedToken) }; } - const tokenUrl = process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token"; - const params = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: decrypt(gdrive.encryptedRefreshToken), - client_id: process.env.GDRIVE_CLIENT_ID as string, - client_secret: process.env.GDRIVE_CLIENT_SECRET as string, - }); - - let tokenResponse: Response; - try { - tokenResponse = await fetch(tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: params.toString(), - }); - } catch (e) { - const detail = e instanceof Error ? e.message : "Unknown network error"; - return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; - } - - if (!tokenResponse.ok) { - const detail = await tokenResponse.text(); - return { success: false, error: "INVALID_REFRESH_TOKEN", detail: detail || "Refresh token is not valid" }; - } - - const tokenData = await tokenResponse.json(); + const refreshResult = await refreshGdriveToken(exp_data.owner, gdrive); - const newTokenExpiresAt = Date.now() + tokenData.expires_in * 1000; - - const update: Record = { - "connectedAccounts.gdrive.encryptedToken": encrypt(tokenData.access_token), - "connectedAccounts.gdrive.tokenExpiresAt": newTokenExpiresAt, - }; - - // Only rotate the refresh token when the provider actually issued a new - // one — otherwise leave the existing one in place. - if (tokenData.refresh_token) { - update["connectedAccounts.gdrive.encryptedRefreshToken"] = encrypt(tokenData.refresh_token); + if (!refreshResult.success) { + return { success: false, error: refreshResult.error, detail: refreshResult.detail }; } - await db.doc(`users/${exp_data.owner}`).update(update); - - return { success: true, token: tokenData.access_token }; + return { success: true, token: refreshResult.accessToken }; } export default async function resolveToken( diff --git a/functions/src/scheduled-token-refresh.ts b/functions/src/scheduled-token-refresh.ts index 9d68824..1cf7afc 100644 --- a/functions/src/scheduled-token-refresh.ts +++ b/functions/src/scheduled-token-refresh.ts @@ -1,10 +1,54 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { db } from "./app.js"; import { refreshAndUpdateUser } from "./refresh-token.js"; +import { refreshGdriveToken } from "./providers/gdrive-oauth.js"; import { decrypt } from "./crypto-utils.js"; import { UserData } from "./interfaces.js"; const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; +const GDRIVE_DEFAULT_WINDOW_MS = 10 * 60 * 1000; + +/** + * Proactively refreshes gdrive access tokens for users whose token expires + * within `windowMs` (default 10 minutes). Mirrors the OSF pass's cadence + * convention, but on a much shorter window since gdrive access tokens are + * short-lived (~1 hour) rather than the ~1-month OSF refresh-token window. + * + * Failures are logged and skipped — a single user's refresh failure must + * never abort the whole pass, and (in 4b) there is no user-visible state + * change on failure: the connection is simply left as-is. + */ +export async function refreshExpiringGdriveTokens(windowMs: number = GDRIVE_DEFAULT_WINDOW_MS): Promise { + const expirationThreshold = Date.now() + windowMs; + + const usersSnapshot = await db + .collection("users") + .where("connectedAccounts.gdrive.tokenExpiresAt", "<", expirationThreshold) + .get(); + + if (usersSnapshot.empty) { + return; + } + + for (const userDoc of usersSnapshot.docs) { + const userData = userDoc.data() as UserData; + const gdrive = userData.connectedAccounts?.gdrive; + const userId = userDoc.id; + + if (!gdrive) { + continue; + } + + try { + const result = await refreshGdriveToken(userId, gdrive); + if (!result.success) { + console.error(`Failed to refresh gdrive token for user ${userId}: ${result.detail}`); + } + } catch (error) { + console.error(`Error refreshing gdrive token for user ${userId}:`, error); + } + } +} /** * Scheduled function that runs weekly to proactively refresh OAuth tokens @@ -68,4 +112,13 @@ export const scheduledTokenRefresh = onSchedule("0 2 * * 0", async () => { console.log( `Token refresh complete. Success: ${successCount}, Failed: ${failCount}` ); + + // gdrive pass runs after the OSF pass, wrapped so a gdrive-side failure + // (e.g. a query error) can never break/roll back the OSF pass above — + // refreshExpiringGdriveTokens itself already isolates per-user failures. + try { + await refreshExpiringGdriveTokens(); + } catch (error) { + console.error("Error during gdrive token refresh pass:", error); + } }); From 6b9eb59bef48df75613b63fdb6abba4cb865d9be Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 10:03:47 -0400 Subject: [PATCH 035/181] feat: server-side experiment creation for provider-backed experiments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 7a. OSF experiment creation stays browser-driven and untouched; non-OSF providers get a server-side endpoint since their container creation (createDataContainer) is server-only. - create-experiment.ts: verifies idToken ownership, resolves the provider token, creates the storage container, and batch-writes the experiment doc + users.experiments arrayUnion with exact parity to the client's id format (12-char nanoid alphabet) and default fields (including the requiredFields ["trial_type"] default the client hardcodes); rejects osf/unknown providers - firestore.rules: verifyFields split into baseFields + a conditional hasAll — provider-shaped docs require storageProvider+providerContainer, legacy docs still require the OSF trio; users rules (and the clients-cannot-write-connectedAccounts guarantee) unchanged and pinned by a new rules test - scheduled-pending-recovery: promoteToQueue now passes storageProvider/providerContainer through with omit-if-undefined — fixes a real bug where recovery of a gdrive experiment's pending upload threw on undefined osfFilesLink and was silently swallowed - /api/createexperiment rewrite; verifyOwnership shared from connect-provider; nanoid added to functions deps; gdrive-emulator's mock server gained the same EADDRINUSE retry its port-3579 sibling uses TDD: 10 contract cases reviewed red first (rules gap, endpoint 404s, and the recovery bug all demonstrated before implementation). Full emulator suite green twice consecutively (29 suites, 205 tests). Co-Authored-By: Claude Fable 5 --- __tests__/firestore-rules.test.js | 146 +++++++ firebase.json | 4 + firestore.rules | 8 +- functions/package-lock.json | 19 + functions/package.json | 1 + .../create-experiment-emulator.test.js | 372 ++++++++++++++++++ .../src/__tests__/gdrive-emulator.test.js | 51 ++- ...nding-recovery-provider-regression.test.js | 131 ++++++ functions/src/connect-provider.ts | 4 +- functions/src/create-experiment.ts | 173 ++++++++ functions/src/index.ts | 4 +- functions/src/providers/index.ts | 2 +- functions/src/scheduled-pending-recovery.ts | 25 +- 13 files changed, 916 insertions(+), 24 deletions(-) create mode 100644 functions/src/__tests__/create-experiment-emulator.test.js create mode 100644 functions/src/__tests__/pending-recovery-provider-regression.test.js create mode 100644 functions/src/create-experiment.ts diff --git a/__tests__/firestore-rules.test.js b/__tests__/firestore-rules.test.js index 7259f34..00a97f1 100644 --- a/__tests__/firestore-rules.test.js +++ b/__tests__/firestore-rules.test.js @@ -121,4 +121,150 @@ describe('/experiments', () => { await assertFails(getDoc(doc(user123.firestore(), 'experiments/456'))); }); +}); + +// --------------------------------------------------------------------------- +// step 7a: firestore.rules generalization (scratchpad/step7a-create-endpoint- +// spec.md). These are ADDITIVE describe blocks -- none of the cases above are +// altered. They exercise the NEW rules shape (baseFields() minus the OSF trio +// plus a storageProvider/providerContainer-OR-osfRepo/osfComponent/ +// osfFilesLink conditional) that firestore.rules does not implement yet. +// +// Field-set parity: `baseFields()` below is the current verifyFields() hasAll +// list (['active', 'activeBase64', 'activeConditionAssignment', 'id', +// 'osfRepo', 'osfComponent', 'osfFilesLink', 'owner', 'title', 'sessions', +// 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', +// 'requiredFields', 'maxSessions', 'limitSessions']) MINUS the three OSF +// fields, exactly as the spec's new baseFields() is defined to be. +// +// Expected-red summary (see build-step report for the verified run): +// - case 1 (legacy OSF create): both sub-cases already PASS today -- pinned +// regression guards, not exercising the gap. +// - case 2 (gdrive-shaped UPDATE succeeds for the owner): RED today -- current +// verifyFields() unconditionally requires osfRepo/osfComponent/osfFilesLink, +// which a gdrive-shaped doc never has. +// - case 3 (gdrive-shaped update validation): both assertFails sub-cases +// already hold true today, but not for the reason the new rules will +// enforce -- today ANY gdrive-shaped doc is denied (missing the OSF trio) +// regardless of providerContainer or ownership; they're pinned here as +// "must remain denied after the generalization too", not proof of the gap. +// - case 4 (/users hasOnly unchanged): already PASSes today -- pinned +// regression guard that clients still cannot write connectedAccounts. +describe('/experiments — provider-migration generalization (step 7a)', () => { + function baseFields(overrides = {}) { + return { + active: false, + activeBase64: false, + activeConditionAssignment: false, + id: overrides.id, + owner: overrides.owner, + title: 'Test experiment', + sessions: 0, + nConditions: 1, + currentCondition: 0, + useValidation: true, + allowJSON: true, + allowCSV: true, + requiredFields: [], + maxSessions: 1, + limitSessions: false, + ...overrides, + }; + } + + describe('1. legacy OSF experiment create (regression guard)', () => { + it('succeeds with all OSF fields present -- expected to PASS today and after generalization', async () => { + const docId = 'exp-7a-legacy-create-1'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertSucceeds(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + id: docId, + owner: 'user123', + osfRepo: 'abc12', + osfComponent: 'def34', + osfFilesLink: 'https://files.osf.io/v1/resources/abc12/providers/osfstorage/', + }))); + }); + + it('fails when osfFilesLink is missing and no storageProvider is present -- pinned contract', async () => { + const docId = 'exp-7a-legacy-create-2'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + id: docId, + owner: 'user123', + osfRepo: 'abc12', + osfComponent: 'def34', + // osfFilesLink deliberately omitted; no storageProvider either. + }))); + }); + }); + + describe('2. gdrive-shaped experiment update by owner', () => { + it('succeeds for the owner when the doc carries storageProvider + providerContainer instead of OSF fields', async () => { + const docId = 'exp-7a-gdrive-update-1'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + providerContainer: { provider: 'gdrive', folderId: 'folder-abc' }, + }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertSucceeds( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { active: true }) + ); + }); + }); + + describe('3. gdrive-shaped update validation', () => { + it('fails when providerContainer is missing even though storageProvider is present', async () => { + const docId = 'exp-7a-gdrive-update-2'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + // providerContainer deliberately omitted. + }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertFails( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { active: true }) + ); + }); + + it('fails when a non-owner attempts to update a gdrive-shaped experiment', async () => { + const docId = 'exp-7a-gdrive-update-3'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + providerContainer: { provider: 'gdrive', folderId: 'folder-abc' }, + }), + }); + + const user456 = testEnv.authenticatedContext('user456'); + await assertFails( + updateDoc(doc(user456.firestore(), `experiments/${docId}`), { active: true }) + ); + }); + }); + + describe('4. /users hasOnly unchanged (regression guard)', () => { + it('rejects account-creation writes that include connectedAccounts -- clients can never write it', async () => { + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails(setDoc(doc(user123.firestore(), 'users/user123'), { + email: 'john@doe.com', + experiments: ['exp1'], + osfToken: '', + connectedAccounts: { gdrive: { authMethod: 'oauth2' } }, + })); + }); + }); }); \ No newline at end of file diff --git a/firebase.json b/firebase.json index d5122af..a5a24f2 100644 --- a/firebase.json +++ b/firebase.json @@ -53,6 +53,10 @@ { "source": "/api/queuestatus", "function": "apiqueuestatus" + }, + { + "source": "/api/createexperiment", + "function": "createexperiment" } ] }, diff --git a/firestore.rules b/firestore.rules index ba93a78..aebdb59 100644 --- a/firestore.rules +++ b/firestore.rules @@ -17,8 +17,14 @@ service cloud.firestore { (isAccountCreation() || isTokenMethodUpdate() || isExperimentsUpdate()); } match /experiments/{experimentId} { + function baseFields() { + return request.resource.data.keys().hasAll(['active', 'activeBase64', 'activeConditionAssignment', 'id', 'owner', 'title', 'sessions', 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', 'requiredFields', 'maxSessions', 'limitSessions']) + } function verifyFields() { - return request.resource.data.keys().hasAll(['active', 'activeBase64', 'activeConditionAssignment', 'id', 'osfRepo', 'osfComponent', 'osfFilesLink', 'owner', 'title', 'sessions', 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', 'requiredFields', 'maxSessions', 'limitSessions']) + return baseFields() && + (('storageProvider' in request.resource.data) + ? request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']) + : request.resource.data.keys().hasAll(['osfRepo', 'osfComponent', 'osfFilesLink'])); } allow read: if(request.auth.uid != null) && resource.data.owner == request.auth.uid; diff --git a/functions/package-lock.json b/functions/package-lock.json index a89b634..2d79546 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -15,6 +15,7 @@ "firebase-functions": "^7.2.2", "is-base64": "^1.1.0", "joi": "^17.7.0", + "nanoid": "^5.1.16", "node-fetch": "^3.2.10" }, "devDependencies": { @@ -13021,6 +13022,24 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", diff --git a/functions/package.json b/functions/package.json index 55da299..dc05543 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,6 +26,7 @@ "firebase-functions": "^7.2.2", "is-base64": "^1.1.0", "joi": "^17.7.0", + "nanoid": "^5.1.16", "node-fetch": "^3.2.10" }, "devDependencies": { diff --git a/functions/src/__tests__/create-experiment-emulator.test.js b/functions/src/__tests__/create-experiment-emulator.test.js new file mode 100644 index 0000000..da788ed --- /dev/null +++ b/functions/src/__tests__/create-experiment-emulator.test.js @@ -0,0 +1,372 @@ +/** + * @jest-environment node + */ + +// RED-phase integration tests for step 7a (scratchpad/step7a-create-endpoint- +// spec.md), cases 5-9 of the test plan. +// +// createExperiment (functions/src/create-experiment.ts) does not exist yet: +// it isn't implemented, isn't exported from index.ts, and has no +// firebase.json rewrite. Every request to its emulator URL therefore 404s +// today -- a missing-behavior failure, not a mock-server/transport bug. +// Following the lowercase function-name convention (apiData -> apidata, +// connectProvider -> connectprovider), the URL under test is +// http://localhost:5001/datapipe-test/us-central1/createexperiment. +// +// Mock Google Drive: createDataContainer's two calls (find-or-create the +// shared "DataPipe" root folder, then always-create the experiment folder) +// are served by a fixed-port (3579) express server, reusing the same +// GDRIVE_API_BASE=http://127.0.0.1:3579 wiring in functions/.env.datapipe-test +// that gdrive-emulator.test.js's mock Drive server already uses -- there is +// only one GDRIVE_API_BASE for the whole Functions-emulator process, so any +// gdrive-touching suite in this test-ci run must bind that same address. +// Because gdrive-emulator.test.js's suite ALSO binds port 3579 and Jest may +// schedule the two test files onto different, truly-concurrent workers, this +// file's listen() retries on EADDRINUSE (with backoff) instead of assuming +// the port is free -- whichever suite starts first grabs it, the other waits +// for that suite's afterAll() to release it. This is defensive, not a claim +// that the two suites are meant to run interleaved: no two tests actually +// hold the port at the same instant. +// +// Auth: real Auth-emulator idTokens via accounts:signUp, exactly like +// oauth-connect-emulator.test.js's signUpEmulatorUser helper -- the future +// createExperiment endpoint is spec'd to verify ownership the same way +// connect-provider.ts's verifyOwnership does (401 missing, 403 mismatch). +// +// connectedAccounts.gdrive is seeded with a bare-plaintext encryptedToken +// (no "v1:" prefix), relying on crypto-utils.ts's decrypt() plaintext +// fallback -- same convention as gdrive-emulator.test.js, sidestepping the +// need for this process and the Functions-emulator child process to agree on +// TOKEN_ENCRYPTION_KEY. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const FOLDER_MIME = "application/vnd.google-apps.folder"; +const DRIVE_PORT = 3579; +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const CREATE_EXPERIMENT_URL = `${FUNCTIONS_BASE}/createexperiment`; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +// ---- helpers ---- + +function parseQuery(q) { + const nameMatch = /name\s*=\s*'([^']*)'/.exec(q || ""); + const parentMatch = /'([^']*)'\s+in\s+parents/.exec(q || ""); + const folderOnly = /mimeType\s*=\s*'application\/vnd\.google-apps\.folder'/.test(q || ""); + return { + name: nameMatch ? nameMatch[1] : null, + parent: parentMatch ? parentMatch[1] : null, + folderOnly, + }; +} + +// Minimal mock Drive: only what createDataContainer needs (a folder lookup by +// name+parent, and a folder create), plus a forceStatus(name, status) hook +// for case 9. No upload/download routes -- this endpoint never touches file +// content. +function createMockDriveServer() { + const app = express(); + app.use(express.json()); + + const filesById = new Map(); + const createCountsByName = new Map(); + const forcedStatus = new Map(); + let nextSeq = 1; + + app.get("/drive/v3/files", (req, res) => { + const { name, parent, folderOnly } = parseQuery(req.query.q); + let matches = Array.from(filesById.values()).filter((f) => { + if (parent && !f.parents.includes(parent)) return false; + if (name && f.name !== name) return false; + if (folderOnly && f.mimeType !== FOLDER_MIME) return false; + return true; + }); + matches.sort((a, b) => a.__seq - b.__seq); + res.status(200).json({ files: matches.map((f) => ({ id: f.id, name: f.name, mimeType: f.mimeType })) }); + }); + + app.post("/drive/v3/files", (req, res) => { + const payload = req.body || {}; + createCountsByName.set(payload.name, (createCountsByName.get(payload.name) || 0) + 1); + + const forced = forcedStatus.get(payload.name); + if (forced && forced !== 200 && forced !== 201) { + res.status(forced).json({ errors: [{ reason: "mockForced", message: `mock-forced-status-${forced}` }] }); + return; + } + + const id = `mock-folder-${nextSeq++}`; + filesById.set(id, { id, name: payload.name, mimeType: payload.mimeType, parents: payload.parents || [], __seq: nextSeq }); + res.status(200).json({ id, name: payload.name }); + }); + + return new Promise((resolve, reject) => { + const tryListen = (retriesLeft) => { + const server = app.listen(DRIVE_PORT); + server.once("listening", () => { + resolve({ + server, + getCreateCount: (name) => createCountsByName.get(name) || 0, + getFolderId: (name) => { + for (const f of filesById.values()) { + if (f.name === name) return f.id; + } + return null; + }, + forceStatus: (name, status) => forcedStatus.set(name, status), + reset: () => { + filesById.clear(); + createCountsByName.clear(); + forcedStatus.clear(); + nextSeq = 1; + }, + }); + }); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); // up to ~30s, in case gdrive-emulator.test.js's suite holds the port + }); +} + +async function signUpEmulatorUser() { + const email = `create-experiment-${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, payload) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; +} + +function callCreateExperiment(payload) { + return postJson(CREATE_EXPERIMENT_URL, payload); +} + +async function seedGdriveUser(uid, overrides = {}) { + await db.collection("users").doc(uid).set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + encryptedToken: "create-experiment-plaintext-token", // plaintext fallback, see header comment + encryptedRefreshToken: "create-experiment-plaintext-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + providerAccountId: "create-experiment-acct", + ...overrides, + }, + }, + }); +} + +async function experimentsForOwner(uid) { + const snap = await db.collection("experiments").where("owner", "==", uid).get(); + return snap.docs; +} + +let db; +let mockDrive; + +beforeAll(async () => { + mockDrive = await createMockDriveServer(); + + let app; + try { + app = getApp("create-experiment-test"); + } catch { + app = initializeApp(config, "create-experiment-test"); + } + db = getFirestore(app); +}); + +afterEach(() => { + mockDrive.reset(); +}); + +afterAll(() => { + mockDrive.server.close(); +}); + +describe("5. createExperiment happy path (gdrive)", () => { + it("returns 200 and creates an experiment doc matching the client's default field set, plus provider fields and no OSF fields", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case5 Experiment ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + // Same id format as lib/experiment-creation.js's customAlphabet( + // "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12). + expect(body.experimentID).toMatch(/^[0-9A-Za-z]{12}$/); + + const folderId = mockDrive.getFolderId(title); + expect(typeof folderId).toBe("string"); + expect(body.providerContainer).toEqual({ provider: "gdrive", folderId }); + + // Mock Drive saw the DataPipe-root find/create and the experiment-folder + // create with name=title. + expect(mockDrive.getCreateCount("DataPipe")).toBe(1); + expect(mockDrive.getCreateCount(title)).toBe(1); + + const expDoc = await db.collection("experiments").doc(body.experimentID).get(); + expect(expDoc.exists).toBe(true); + const expData = expDoc.data(); + + // Exact default-field parity with createExperimentDocument in + // lib/experiment-creation.js (verified by reading that file): title, + // active:false, activeBase64:false, activeConditionAssignment:false, + // sessions:0, id, owner, nConditions:1, currentCondition:0, + // useValidation:true, allowJSON:true, allowCSV:true, + // requiredFields:["trial_type"] (NOT [] -- the client hardcodes + // ["trial_type"], it is not a parameterized default), limitSessions:false, + // maxSessions:1 -- PLUS storageProvider/providerContainer instead of + // osfRepo/osfComponent/osfFilesLink. + expect(expData).toEqual({ + title, + active: false, + activeBase64: false, + activeConditionAssignment: false, + sessions: 0, + limitSessions: false, + maxSessions: 1, + id: body.experimentID, + owner: uid, + nConditions: 1, + currentCondition: 0, + useValidation: true, + allowJSON: true, + allowCSV: true, + requiredFields: ["trial_type"], + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId }, + }); + expect(expData.osfRepo).toBeUndefined(); + expect(expData.osfComponent).toBeUndefined(); + expect(expData.osfFilesLink).toBeUndefined(); + + const userDoc = await db.collection("users").doc(uid).get(); + expect(userDoc.data().experiments).toContain(body.experimentID); + }); +}); + +describe("6. createExperiment provider validation", () => { + it("returns 400 for provider 'osf' (OSF creation stays browser-driven)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const title = `Case6 OSF ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "osf", title, idToken, uid }); + + expect(status).toBe(400); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); + + it("returns 400 for an unknown provider", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const title = `Case6 Unknown ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "not-a-real-provider", title, idToken, uid }); + + expect(status).toBe(400); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("7. createExperiment with no connected gdrive account", () => { + it("returns 400 surfacing PROVIDER_NOT_CONNECTED", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // Deliberately no connectedAccounts.gdrive seeded. + const title = `Case7 ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining(MESSAGES.PROVIDER_NOT_CONNECTED)); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("8. createExperiment auth failures", () => { + it("returns 403 for a wrong-user idToken and creates nothing", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + await seedGdriveUser(userA.uid); + const title = `Case8 wrong-user ${randomUUID()}`; + + const { status } = await callCreateExperiment({ + provider: "gdrive", + title, + idToken: userB.idToken, + uid: userA.uid, + }); + + expect(status).toBe(403); + expect((await experimentsForOwner(userA.uid)).length).toBe(0); + const userAData = (await db.collection("users").doc(userA.uid).get()).data(); + expect(userAData.experiments || []).toEqual([]); + }); + + it("returns 401 for a missing idToken and creates nothing", async () => { + const { uid } = await signUpEmulatorUser(); + const title = `Case8 missing-idtoken ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "gdrive", title, uid }); + + expect(status).toBe(401); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("9. createExperiment Drive container-creation failure", () => { + it("returns 502 when Drive folder creation fails, and creates nothing", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case9 ${randomUUID()}`; + + // The DataPipe root-folder create still succeeds; only the + // experiment-named folder create is forced to fail. + mockDrive.forceStatus(title, 500); + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(502); + expect(body.error).toBe("Failed to create storage container"); + expect((await experimentsForOwner(uid)).length).toBe(0); + const userData = (await db.collection("users").doc(uid).get()).data(); + expect(userData?.experiments || []).toEqual([]); + }); +}); diff --git a/functions/src/__tests__/gdrive-emulator.test.js b/functions/src/__tests__/gdrive-emulator.test.js index 1487e25..32dccd7 100644 --- a/functions/src/__tests__/gdrive-emulator.test.js +++ b/functions/src/__tests__/gdrive-emulator.test.js @@ -297,23 +297,42 @@ function createMockDriveServer() { res.status(200).json({ access_token: "mock-refreshed-token", expires_in: 3600, token_type: "Bearer" }); }); - return new Promise((resolve) => { - const server = app.listen(DRIVE_PORT, () => { - resolve({ - server, - port: DRIVE_PORT, - getUploadCount: (name) => uploadCountsByName.get(name) || 0, - getUpdateCount: (id) => updateCountsById.get(id) || 0, - forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), - reset: () => { - filesById.clear(); - uploadCountsByName.clear(); - updateCountsById.clear(); - forcedStatus.clear(); - nextSeq = 1; - }, + // Fixed-port bind, shared with create-experiment-emulator.test.js's mock + // Drive server (both read the same GDRIVE_API_BASE=http://127.0.0.1:3579 + // wired into functions/.env.datapipe-test). Jest may schedule the two test + // files onto different, truly-concurrent workers, so this retries on + // EADDRINUSE (with backoff) instead of assuming the port is free -- + // whichever suite starts first grabs it, the other waits for that suite's + // afterAll() to release it. Defensive only: no two tests actually hold the + // port at the same instant. + return new Promise((resolve, reject) => { + const tryListen = (retriesLeft) => { + const server = app.listen(DRIVE_PORT); + server.once("listening", () => { + resolve({ + server, + port: DRIVE_PORT, + getUploadCount: (name) => uploadCountsByName.get(name) || 0, + getUpdateCount: (id) => updateCountsById.get(id) || 0, + forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), + reset: () => { + filesById.clear(); + uploadCountsByName.clear(); + updateCountsById.clear(); + forcedStatus.clear(); + nextSeq = 1; + }, + }); }); - }); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); // up to ~30s, in case create-experiment-emulator.test.js's suite holds the port }); } diff --git a/functions/src/__tests__/pending-recovery-provider-regression.test.js b/functions/src/__tests__/pending-recovery-provider-regression.test.js new file mode 100644 index 0000000..ad90c2b --- /dev/null +++ b/functions/src/__tests__/pending-recovery-provider-regression.test.js @@ -0,0 +1,131 @@ +/** + * @jest-environment node + */ + +// RED-phase regression test for step 7a's scheduled-pending-recovery.ts audit +// (scratchpad/step7a-create-endpoint-spec.md, case 10 of the test plan). +// +// The audit: scheduledPendingRecovery's re-queue path (promoteToQueue in +// functions/src/scheduled-pending-recovery.ts) builds its uploadQueue doc by +// hand, reading only `expData.osfFilesLink` off the recovered experiment -- +// it never reads/forwards `expData.storageProvider` / +// `expData.providerContainer`. Unlike queue-upload.ts (which explicitly +// passes storageProvider/providerContainer through, see api-data.ts's calls), +// this path has no such wiring. A recovered pending upload for a gdrive +// experiment would therefore fall back to the legacy OSF shape in the queue +// doc and fail when scheduled-upload-retry.ts later tries to process it. +// This test seeds exactly that scenario and asserts the queue doc carries the +// provider fields -- expected RED today. +// +// Seam: scheduled-pending-recovery.ts exports only `scheduledPendingRecovery` +// (an onSchedule-wrapped function); `recoverPendingUploads`/`promoteToQueue` +// are private. Rather than going through the Functions-emulator's HTTP +// manual-trigger URL (the "scheduledpendingrecovery-0" pattern established by +// oauth-connect-scheduled-regression.test.js's case 11), this test uses a +// more direct seam: firebase-functions v2's onSchedule() implementation +// (node_modules/firebase-functions/lib/v2/providers/scheduler.js) stashes the +// raw, unwrapped handler on the returned function as `.run` (`func.run = +// handler`). Dynamically importing the COMPILED module (functions/lib/, +// requires `npm run build` first -- same convention as +// oauth-connect-emulator.test.js's `await import("../../lib/crypto-utils.js")`) +// and calling `scheduledPendingRecovery.run()` invokes recoverPendingUploads() +// directly in THIS process. +// +// That matters because recoverPendingUploads() only recovers files older +// than STALE_THRESHOLD_MS (15 minutes) -- a real pending-data file would need +// to sit in the emulator for 15 real minutes before this suite could observe +// it being recovered, which is impractical for CI. Because `.run()` executes +// the real handler in-process (not over HTTP to the separate +// Functions-emulator child process), this test can mock the global `Date.now` +// that recoverPendingUploads() reads to compute its cutoff, shifting the +// cutoff forward past the (really, just-created) file's real timeCreated -- +// without touching STALE_THRESHOLD_MS or any other production code. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { getStorage } from "firebase-admin/storage"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +jest.setTimeout(30000); + +const config = { + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}; + +const STALE_THRESHOLD_MS = 15 * 60 * 1000; + +let db; +let bucket; +let scheduledPendingRecovery; + +beforeAll(async () => { + // A NAMED app for this test's own seeding/assertions -- deliberately not + // "[DEFAULT]", so that dynamically importing the compiled + // scheduled-pending-recovery.js below (whose app.js calls bare + // initializeApp() with no name) doesn't collide with an already-existing + // default app in this same module registry. + let app; + try { + app = getApp("pending-recovery-regression-test"); + } catch { + app = initializeApp(config, "pending-recovery-regression-test"); + } + db = getFirestore(app); + bucket = getStorage(app).bucket(); + + ({ scheduledPendingRecovery } = await import("../../lib/scheduled-pending-recovery.js")); +}); + +async function seedPendingFile(experimentID, filename, data) { + const storagePath = `pending-data/${experimentID}/${filename}_${Date.now()}`; + const envelope = { experimentID, filename, data }; + const file = bucket.file(storagePath); + await file.save(JSON.stringify(envelope), { contentType: "application/json" }); + return storagePath; +} + +describe("10. scheduled-pending-recovery carries provider fields through for a gdrive experiment", () => { + it("promotes a stale pending file for a gdrive experiment into uploadQueue with storageProvider + providerContainer set", async () => { + const experimentID = `pending-recovery-gdrive-${randomUUID()}`; + const filename = `case10-${randomUUID()}.json`; + const owner = `pending-recovery-owner-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + + await db.collection("experiments").doc(experimentID).set({ + active: true, + owner, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId }, + // osfFilesLink deliberately absent -- a gdrive experiment has none. + }); + + await seedPendingFile(experimentID, filename, `[{"trial_type":"html-keyboard-response"}]`); + + // Shift the recovery pass's notion of "now" forward so its + // STALE_THRESHOLD_MS cutoff falls after this file's real (just-now) + // timeCreated, without waiting 15 real minutes. + const realNow = Date.now(); + const nowSpy = jest.spyOn(Date, "now").mockReturnValue(realNow + STALE_THRESHOLD_MS + 5 * 60 * 1000); + try { + await scheduledPendingRecovery.run({}); + } finally { + nowSpy.mockRestore(); + } + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueDoc = await db.collection("uploadQueue").doc(docId).get(); + expect(queueDoc.exists).toBe(true); + + const queueData = queueDoc.data(); + expect(queueData.experimentID).toBe(experimentID); + expect(queueData.owner).toBe(owner); + // The actual gap: promoteToQueue must pass these through, the same way + // queue-upload.ts's callers in api-data.ts already do. + expect(queueData.storageProvider).toBe("gdrive"); + expect(queueData.providerContainer).toEqual({ provider: "gdrive", folderId }); + expect(queueData.osfFilesLink).toBeUndefined(); + }); +}); diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts index 9bba0f5..bb57964 100644 --- a/functions/src/connect-provider.ts +++ b/functions/src/connect-provider.ts @@ -13,11 +13,11 @@ import { db, auth } from "./app.js"; import { encrypt } from "./crypto-utils.js"; import { getOAuthConfig } from "./providers/oauth-config.js"; -type AuthCheckResult = +export type AuthCheckResult = | { ok: true } | { ok: false; status: number; error: string }; -async function verifyOwnership(uid: string, idToken: string | undefined): Promise { +export async function verifyOwnership(uid: string, idToken: string | undefined): Promise { if (!idToken) { return { ok: false, status: 401, error: 'Authentication required' }; } diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts new file mode 100644 index 0000000..32c22a2 --- /dev/null +++ b/functions/src/create-experiment.ts @@ -0,0 +1,173 @@ +// Server-side experiment creation for non-OSF storage providers +// (scratchpad/step7a-create-endpoint-spec.md, docs/provider-migration-design.md). +// +// OSF experiment creation stays entirely browser-driven (see +// lib/experiment-creation.js) -- the browser calls the OSF API directly and +// batch-writes Firestore with a Firebase client SDK, which is fine because +// the OSF token flow already lives client-side. New providers (starting with +// gdrive) need a server-side path instead: createDataContainer is +// server-only (it needs the decrypted, possibly-refreshed provider token +// that only resolve-token.ts can produce), and the resulting container ref +// must be folded into the experiment doc atomically with its creation. +// +// This endpoint intentionally mirrors createExperimentDocument in +// lib/experiment-creation.js field-for-field (including the +// requiredFields: ["trial_type"] default, which the client hardcodes rather +// than parameterizes) so that gdrive- and OSF-created experiment docs stay +// uniform for every other consumer (api-data.ts, the dashboard, etc.). + +import { onRequest } from "firebase-functions/v2/https"; +import { FieldValue } from "firebase-admin/firestore"; +import { customAlphabet } from "nanoid"; +import { db } from "./app.js"; +import { verifyOwnership } from "./connect-provider.js"; +import resolveToken from "./resolve-token.js"; +import { getProvider, listProviders } from "./providers/index.js"; +import { ContainerRef, StorageProviderId } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; +import MESSAGES from "./api-messages.js"; + +// Same alphabet/length as lib/experiment-creation.js's +// customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12) +// -- experiment ids must stay uniform whether created client-side (OSF) or +// server-side (gdrive and later providers). +const generateExperimentId = customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + 12 +); + +interface ExperimentSettingsOverrides { + nConditions?: number; + useValidation?: boolean; + allowJSON?: boolean; + allowCSV?: boolean; + requiredFields?: string[]; + limitSessions?: boolean; + maxSessions?: number; +} + +export const createExperiment = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const { + provider, + title, + idToken, + uid, + experimentSettings, + }: { + provider?: string; + title?: string; + idToken?: string; + uid?: string; + experimentSettings?: ExperimentSettingsOverrides; + } = req.body || {}; + + if (!provider || !title || !uid) { + res.status(400).json(MESSAGES.MISSING_PARAMETER); + return; + } + + // Verify the caller owns the uid they claim -- same shape as + // connect-provider.ts's storage-grant flow (401 missing/invalid token, + // 403 uid mismatch). No signup path here, ever. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + // OSF creation stays browser-driven; only registered NON-osf providers + // may be created through this endpoint. + if (provider === "osf" || !listProviders().includes(provider as StorageProviderId)) { + res.status(400).json({ error: "Unsupported provider" }); + return; + } + + const storageProvider = getProvider(provider as StorageProviderId); + + const userDocRef = db.doc(`users/${uid}`); + const userDoc = await userDocRef.get(); + // A freshly-signed-up user may have no Firestore doc yet -- treat that + // the same as "no connected accounts" rather than throwing, so the + // PROVIDER_NOT_CONNECTED surface below applies uniformly. + const userData: UserData = (userDoc.data() as UserData) || ({} as UserData); + + const tokenResult = await resolveToken(userData, { + storageProvider: provider as StorageProviderId, + owner: uid, + } as ExperimentData); + + if (!tokenResult.success) { + const errorMessage = + MESSAGES[tokenResult.error as keyof typeof MESSAGES] || MESSAGES.TOKEN_RESOLUTION_ERROR; + res.status(400).json(errorMessage); + return; + } + + let providerContainer: ContainerRef; + try { + providerContainer = await storageProvider.createDataContainer( + { token: tokenResult.token }, + { name: title } + ); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + res.status(502).json({ error: "Failed to create storage container", detail }); + return; + } + + const settings = experimentSettings || {}; + const nConditions = settings.nConditions ?? 1; + const useValidation = settings.useValidation ?? true; + const allowJSON = settings.allowJSON ?? true; + const allowCSV = settings.allowCSV ?? true; + const requiredFields = settings.requiredFields ?? ["trial_type"]; + const limitSessions = settings.limitSessions ?? false; + const maxSessions = settings.maxSessions ?? 1; + + const experimentID = generateExperimentId(); + + const experimentDocRef = db.collection("experiments").doc(experimentID); + + const batch = db.batch(); + batch.set(experimentDocRef, { + title, + active: false, + activeBase64: false, + activeConditionAssignment: false, + sessions: 0, + limitSessions, + maxSessions, + id: experimentID, + owner: uid, + nConditions, + currentCondition: 0, + useValidation, + allowJSON, + allowCSV, + requiredFields, + storageProvider: provider, + providerContainer, + }); + // set+merge (not update) -- a freshly-signed-up user may have no + // Firestore doc yet, same rationale as connect-provider.ts's + // set()+mergeFields for connectedAccounts. + batch.set( + userDocRef, + { experiments: FieldValue.arrayUnion(experimentID) }, + { merge: true } + ); + + await batch.commit(); + + res.status(200).json({ success: true, experimentID, providerContainer }); + } catch (error) { + console.error("Error creating experiment:", error instanceof Error ? error.message : "Unknown error"); + res.status(500).json({ error: "Failed to create experiment" }); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index 40adaf6..082cbde 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ import { connectProvider, disconnectProvider } from "./connect-provider.js"; import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; +import { createExperiment } from "./create-experiment.js"; setGlobalOptions({ maxInstances: 20 @@ -36,5 +37,6 @@ export { disconnectProvider as disconnectprovider, saveOsfToken as saveosftoken, getOsfToken as getosftoken, - onUserDeleted as onuserdeleted + onUserDeleted as onuserdeleted, + createExperiment as createexperiment }; diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index ff02741..19ba505 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -26,7 +26,7 @@ export function getProviderForExperiment(exp_data: ExperimentData): { }; } -export { registerProvider, getProvider } from "./registry.js"; +export { registerProvider, getProvider, listProviders } from "./registry.js"; export { osfProvider } from "./osf.js"; export { gdriveProvider } from "./gdrive.js"; export * from "./types.js"; diff --git a/functions/src/scheduled-pending-recovery.ts b/functions/src/scheduled-pending-recovery.ts index 15c9800..212d603 100644 --- a/functions/src/scheduled-pending-recovery.ts +++ b/functions/src/scheduled-pending-recovery.ts @@ -139,13 +139,20 @@ async function promoteToQueue( const now = Timestamp.now(); const nextRetryAt = Timestamp.fromMillis(now.toMillis() + 60 * 1000); // 1 minute — retry soon - transaction.set(docRef, { + // osfFilesLink/storageProvider/providerContainer are included only when + // present -- Firestore rejects undefined field values, and a gdrive + // experiment has no osfFilesLink just as a legacy OSF experiment has no + // storageProvider/providerContainer. Same omit-if-undefined convention as + // queue-upload.ts, whose callers (api-data.ts) already pass these + // through; this re-queue path must carry them too, or a recovered + // pending upload for a gdrive experiment falls back to the legacy OSF + // shape and fails when scheduled-upload-retry.ts processes it. + const queueDocData: Record = { experimentID, owner: expData.owner, filename, storagePath, dataType: "data", - osfFilesLink: expData.osfFilesLink, status: "pending", errorCode: 0, retryCount: 0, @@ -157,7 +164,19 @@ async function promoteToQueue( failureReason: "Recovered from interrupted upload (server restart or memory limit)", deduplicationKey, sessionIncremented: false, - }); + }; + + if (expData.osfFilesLink !== undefined) { + queueDocData.osfFilesLink = expData.osfFilesLink; + } + if (expData.storageProvider !== undefined) { + queueDocData.storageProvider = expData.storageProvider; + } + if (expData.providerContainer !== undefined) { + queueDocData.providerContainer = expData.providerContainer; + } + + transaction.set(docRef, queueDocData); return true; }); From be36c94e8d58a3d891df223db59dab0a16f5b30c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 10:50:47 -0400 Subject: [PATCH 036/181] feat: provider selector, connect UI, and provider-aware dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 7b, completing the frontend (design-doc step 7). - lib/provider-config.js: STORAGE_PROVIDERS map — new providers become config entries (name, isConnected, container link/label); OSF keeps its bespoke legacy UI deliberately - admin/new.js: storage selector (native radio inputs — Chakra's compound RadioGroup defers state outside act(), breaking synchronous tests; OSF path byte-identical and pinned by regression test). gdrive branch: connect CTA when unlinked, title-only form posting to /api/createexperiment via createProviderExperiment, routes to the new experiment on success - ProviderConnections + account.js Storage Providers section: connect via generateOAuthState {provider} -> authorizeUrl redirect; disconnect via /api/disconnectprovider; status reads connectedAccounts reactively - pages/oauth2/connect.js: storage-grant OAuth callback (CSRF-checked, sign-in required, posts to /api/connectprovider) — separate from the OSF identity callback by design - ExperimentInfo renders the provider container link for provider-backed experiments; legacy OSF rows unchanged. QueuePanel copy generalized to provider-neutral phrasing - firebase.json: /api/connectprovider + /api/disconnectprovider rewrites TDD: 14 RTL contract cases reviewed red first (3 as pinned regression guards). Next.js production build clean; full emulator suite green twice (35 suites, 223 tests, --maxWorkers=2). Deploy note: GDRIVE_REDIRECT_URI must point at /oauth2/connect. Co-Authored-By: Claude Fable 5 --- __tests__/connect-callback-page.test.jsx | 115 +++++++++ __tests__/experiment-info.test.jsx | 51 ++++ __tests__/new-experiment-page.test.jsx | 218 +++++++++++++++++ __tests__/provider-config.test.js | 41 ++++ __tests__/provider-connections.test.jsx | 114 +++++++++ __tests__/queue-panel.test.jsx | 69 ++++++ components/account/ProviderConnections.js | 113 +++++++++ components/dashboard/ExperimentInfo.js | 56 +++-- components/dashboard/QueuePanel.js | 26 +- firebase.json | 8 + lib/experiment-creation.js | 38 +++ lib/provider-config.js | 14 ++ pages/admin/account.js | 8 + pages/admin/new.js | 279 +++++++++++++++------- pages/oauth2/connect.js | 185 ++++++++++++++ 15 files changed, 1216 insertions(+), 119 deletions(-) create mode 100644 __tests__/connect-callback-page.test.jsx create mode 100644 __tests__/experiment-info.test.jsx create mode 100644 __tests__/new-experiment-page.test.jsx create mode 100644 __tests__/provider-config.test.js create mode 100644 __tests__/provider-connections.test.jsx create mode 100644 __tests__/queue-panel.test.jsx create mode 100644 components/account/ProviderConnections.js create mode 100644 lib/provider-config.js create mode 100644 pages/oauth2/connect.js diff --git a/__tests__/connect-callback-page.test.jsx b/__tests__/connect-callback-page.test.jsx new file mode 100644 index 0000000..8b83c6f --- /dev/null +++ b/__tests__/connect-callback-page.test.jsx @@ -0,0 +1,115 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +const mockGetIdToken = jest.fn(() => Promise.resolve("id-token-123")); +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: { uid: "user-1", getIdToken: () => mockGetIdToken() } }, + db: {}, +})); + +// UserContext is re-provided per test via +// so signed-in vs signed-out can vary within this file without re-mocking +// the module. +jest.mock("../lib/context", () => ({ + UserContext: require("react").createContext({ user: null, loading: false }), +})); + +const mockPush = jest.fn(); +let mockQuery = {}; +jest.mock("next/router", () => ({ + useRouter: () => ({ query: mockQuery, push: mockPush }), +})); + +import { UserContext } from "../lib/context"; +import ConnectCallbackPage from "../pages/oauth2/connect"; + +function renderPage({ user = { uid: "user-1" } } = {}) { + return render( + + + + + + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIdToken.mockClear(); + mockGetIdToken.mockImplementation(() => Promise.resolve("id-token-123")); + global.fetch = jest.fn(); + localStorage.clear(); + mockQuery = {}; +}); + +describe("oauth2/connect callback page", () => { + it("9. happy path posts to connectprovider and routes to /admin/account on success", async () => { + mockQuery = { code: "auth-code-1", state: "state-abc" }; + localStorage.setItem("latestCSRFToken", "state-abc"); + localStorage.setItem("providerConnectFlow", "gdrive"); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderPage(); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/connectprovider"); + expect(JSON.parse(options.body)).toEqual({ + provider: "gdrive", + code: "auth-code-1", + state: "state-abc", + uid: "user-1", + idToken: "id-token-123", + }); + + await waitFor(() => + expect(mockPush).toHaveBeenCalledWith("/admin/account") + ); + }); + + it("10. state mismatch shows error UI and does not call connectprovider", async () => { + mockQuery = { code: "auth-code-1", state: "state-abc" }; + localStorage.setItem("latestCSRFToken", "different-state"); + localStorage.setItem("providerConnectFlow", "gdrive"); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderPage(); + + await waitFor(() => + expect( + screen.getByText(/invalid state|csrf/i) + ).toBeInTheDocument() + ); + expect( + screen.getByRole("link", { name: /admin.*account|account/i }) + ).toHaveAttribute("href", "/admin/account"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("11. signed-out user shows error UI with a sign-in link and does not call connectprovider", async () => { + mockQuery = { code: "auth-code-1", state: "state-abc" }; + localStorage.setItem("latestCSRFToken", "state-abc"); + localStorage.setItem("providerConnectFlow", "gdrive"); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderPage({ user: null }); + + await waitFor(() => + expect( + screen.getByRole("link", { name: /sign in/i }) + ).toBeInTheDocument() + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/experiment-info.test.jsx b/__tests__/experiment-info.test.jsx new file mode 100644 index 0000000..d6f52c2 --- /dev/null +++ b/__tests__/experiment-info.test.jsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +import ExperimentInfo from "../components/dashboard/ExperimentInfo"; + +function renderInfo(data) { + return render( + + + + ); +} + +describe("ExperimentInfo — legacy OSF experiments (pinned regression)", () => { + it("12. renders OSF Project and OSF Data Component links for legacy experiments", () => { + renderInfo({ + id: "exp1", + osfRepo: "abc12", + osfComponent: "def34", + sessions: 3, + }); + + expect(screen.getByText("OSF Project")).toBeInTheDocument(); + expect(screen.getByText("OSF Data Component")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /abc12/ })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /def34/ })).toBeInTheDocument(); + }); +}); + +describe("ExperimentInfo — provider-aware rendering", () => { + it("13. renders Google Drive Folder link for gdrive experiments; OSF labels are absent", () => { + renderInfo({ + id: "exp2", + storageProvider: "gdrive", + providerContainer: { folderId: "folder123" }, + sessions: 5, + }); + + expect(screen.getByText("Google Drive Folder")).toBeInTheDocument(); + const link = screen.getByRole("link", { name: /folder123/ }); + expect(link).toHaveAttribute( + "href", + "https://drive.google.com/drive/folders/folder123" + ); + + expect(screen.queryByText("OSF Project")).not.toBeInTheDocument(); + expect(screen.queryByText("OSF Data Component")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/new-experiment-page.test.jsx b/__tests__/new-experiment-page.test.jsx new file mode 100644 index 0000000..638630c --- /dev/null +++ b/__tests__/new-experiment-page.test.jsx @@ -0,0 +1,218 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +// Mock firebase since test env doesn't have NEXT_PUBLIC_FIREBASE_CONFIG. +// auth.currentUser mirrors a signed-in user with a working getIdToken(). +const mockGetIdToken = jest.fn(() => Promise.resolve("id-token-123")); +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: { uid: "user-1", getIdToken: () => mockGetIdToken() } }, + db: {}, +})); + +// Mock context to provide a signed-in user (this page is wrapped in AuthCheck). +jest.mock("../lib/context", () => ({ + UserContext: require("react").createContext({ + user: { uid: "user-1" }, + loading: false, + }), +})); + +// lib/experiment-creation.js (imported transitively by pages/admin/new.js) +// pulls in `nanoid`, which ships ESM-only and isn't transformed by Jest by +// default (`Cannot use import statement outside a module`). Mock it out +// rather than touching jest.config.js's transformIgnorePatterns. +jest.mock("nanoid", () => ({ + customAlphabet: () => () => "mocked-id", +})); + +// firebase/firestore's `doc` (and friends used transitively by +// lib/experiment-creation.js) must not touch a real Firestore instance. +jest.mock("firebase/firestore", () => ({ + doc: jest.fn(() => ({})), + writeBatch: jest.fn(() => ({ + set: jest.fn(), + update: jest.fn(), + commit: jest.fn(() => Promise.resolve()), + })), + arrayUnion: jest.fn((v) => v), + setDoc: jest.fn(() => Promise.resolve()), +})); + +// The page navigates via the `Router` singleton default export (see +// pages/admin/new.js: `import Router from "next/router"`), while AuthCheck +// uses the `useRouter()` hook. Mock both from the same module. +const mockPush = jest.fn(); +jest.mock("next/router", () => ({ + __esModule: true, + default: { push: (...args) => mockPush(...args) }, + useRouter: () => ({ push: mockPush, pathname: "/admin/new", query: {} }), +})); + +jest.mock("react-firebase-hooks/firestore", () => ({ + useDocumentData: jest.fn(), +})); + +import { useDocumentData } from "react-firebase-hooks/firestore"; +import NewExperimentPage from "../pages/admin/new"; + +function renderPage() { + return render( + + + + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIdToken.mockClear(); + mockGetIdToken.mockImplementation(() => Promise.resolve("id-token-123")); + global.fetch = jest.fn(); +}); + +describe("NewExperimentPage — OSF path (pinned regression)", () => { + it("2. default render shows the OSF form exactly as today", () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", usingPersonalToken: false }, + false, + undefined, + ]); + + renderPage(); + + expect(screen.getByText("Existing OSF Project")).toBeInTheDocument(); + expect( + screen.getByText("New OSF Data Component Name") + ).toBeInTheDocument(); + expect(screen.getByText("Storage Location")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument(); + }); +}); + +describe("NewExperimentPage — Google Drive provider selector", () => { + // NOTE ON INTERACTION MECHANICS: the spec allows a RadioGroup or a Select + // for "Where should data be stored?". These assertions target the option + // label text via getByLabelText, which works for either control as long + // as the GREEN implementation gives the Google Drive option an + // accessible name of "Google Drive" (radio input's associated label, or + // an {data.id} - - OSF Project - - {data.osfRepo} - - - - OSF Data Component - - {data.osfComponent} - - + {provider ? ( + + {provider.containerLabel} + + {data.providerContainer?.folderId} + + + ) : ( + <> + + OSF Project + + {data.osfRepo} + + + + OSF Data Component + + {data.osfComponent} + + + + )} Completed Sessions {data.sessions} diff --git a/components/dashboard/QueuePanel.js b/components/dashboard/QueuePanel.js index 0dfda45..795b00b 100644 --- a/components/dashboard/QueuePanel.js +++ b/components/dashboard/QueuePanel.js @@ -19,16 +19,16 @@ function friendlyReason(reason) { return "Upload was interrupted by a server restart or memory limit."; } if (reason.includes("Upload exception") || reason.includes("fetch failed")) { - return "Could not connect to OSF."; + return "Could not connect to your storage provider."; } if (reason.includes("OSF error 503") || reason.includes("OSF error 502")) { - return "OSF was temporarily unavailable."; + return "Your storage provider was temporarily unavailable."; } if (reason.includes("OSF error 429")) { - return "OSF rate-limited the request."; + return "Your storage provider rate-limited the request."; } if (reason.includes("OSF error 401") || reason.includes("OSF error 403")) { - return "Authentication error. Your OSF token may need to be refreshed."; + return "Authentication error. Your storage provider connection may need to be refreshed."; } return reason; } @@ -156,13 +156,13 @@ export default function QueuePanel({ entries, experimentId }) { let alertDescription; if (allFailed) { - alertTitle = `${plural(failedCount, "file")} could not be uploaded to OSF.`; - alertDescription = "All retries were exhausted. Download these files and upload them to your OSF project manually to prevent data loss."; + alertTitle = `${plural(failedCount, "file")} could not be uploaded to your storage provider.`; + alertDescription = "All retries were exhausted. Download these files and upload them to your storage provider manually to prevent data loss."; } else if (failedCount > 0) { - alertTitle = `${plural(entries.length, "file")} did not upload to OSF.`; + alertTitle = `${plural(entries.length, "file")} did not upload to your storage provider.`; alertDescription = `${plural(pendingCount, "file")} still being retried. ${plural(failedCount, "file")} failed permanently. You can download all files below.`; } else { - alertTitle = `${plural(pendingCount, "file")} did not upload to OSF.`; + alertTitle = `${plural(pendingCount, "file")} did not upload to your storage provider.`; alertDescription = "DataPipe is retrying automatically. You can also download the files now."; } @@ -183,7 +183,7 @@ export default function QueuePanel({ entries, experimentId }) { When a participant submits data, DataPipe tries to upload it to - your OSF project immediately. If that fails, DataPipe saves a + your storage provider immediately. If that fails, DataPipe saves a copy and retries automatically. Common reasons include: @@ -192,17 +192,17 @@ export default function QueuePanel({ entries, experimentId }) { can occasionally exceed the server's memory capacity. - OSF unavailable — OSF may be temporarily - down or rate-limiting requests. + Storage provider unavailable — Your storage + provider may be temporarily down or rate-limiting requests. Configuration issue — There may be a problem - with your OSF project settings or authentication token. + with your storage provider settings or authentication token. Files are stored for up to 7 days. If retries don't succeed, - download the files and upload them to OSF manually. + download the files and upload them to your storage provider manually. diff --git a/firebase.json b/firebase.json index a5a24f2..c8987ef 100644 --- a/firebase.json +++ b/firebase.json @@ -57,6 +57,14 @@ { "source": "/api/createexperiment", "function": "createexperiment" + }, + { + "source": "/api/connectprovider", + "function": "connectprovider" + }, + { + "source": "/api/disconnectprovider", + "function": "disconnectprovider" } ] }, diff --git a/lib/experiment-creation.js b/lib/experiment-creation.js index 4dfea55..e655102 100644 --- a/lib/experiment-creation.js +++ b/lib/experiment-creation.js @@ -155,6 +155,44 @@ export async function createExperimentDocument(experimentData) { return id; } +// Non-OSF providers (gdrive, and later figshare/dataverse) are created +// server-side via /api/createexperiment (see functions/src/create-experiment.ts) +// rather than the browser-driven OSF path above -- the server needs the +// decrypted provider token that only resolve-token.ts can produce. This +// helper just calls that endpoint and normalizes the response shape to +// match createExperiment()'s { experimentId } contract. +export async function createProviderExperiment(provider, title) { + const user = auth.currentUser; + if (!user) { + throw new Error("User not authenticated"); + } + + const idToken = await user.getIdToken(); + + const response = await fetch("/api/createexperiment", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + provider, + title, + uid: user.uid, + idToken, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || `Failed to create experiment: ${response.status}`); + } + + return { + experimentId: data.experimentID, + }; +} + export async function createExperiment(params) { const { title, diff --git a/lib/provider-config.js b/lib/provider-config.js new file mode 100644 index 0000000..63bcb96 --- /dev/null +++ b/lib/provider-config.js @@ -0,0 +1,14 @@ +// Frontend registry of non-OSF storage providers. OSF is deliberately NOT +// included here -- it keeps its bespoke legacy UI (identity flow, PAT flow, +// existing new-experiment form, existing dashboard links). Adding a new +// provider (e.g. figshare) should only require a new entry in this map. +export const STORAGE_PROVIDERS = { + gdrive: { + id: "gdrive", + name: "Google Drive", + isConnected: (userDoc) => !!userDoc?.connectedAccounts?.gdrive, + containerLink: (exp) => + `https://drive.google.com/drive/folders/${exp.providerContainer?.folderId}`, + containerLabel: "Google Drive Folder", + }, +}; diff --git a/pages/admin/account.js b/pages/admin/account.js index c91f7c0..4ee7649 100644 --- a/pages/admin/account.js +++ b/pages/admin/account.js @@ -5,6 +5,7 @@ import ChangePassword from "../../components/account/ChangePassword"; import DeleteAccount from "../../components/account/DeleteAccount"; import { useState, useContext } from "react"; import SelectAuth from "../../components/account/SelectAuth"; +import ProviderConnections from "../../components/account/ProviderConnections"; import { UserContext } from "../../lib/context"; import { useDocumentData } from "react-firebase-hooks/firestore"; import { doc } from "firebase/firestore"; @@ -46,6 +47,13 @@ export default function AccountPage({}) { )} + {/* Storage Providers Section */} + + + Storage Providers + + + {/* Account Section - only for email users */} {!isOAuthUser && ( <> diff --git a/pages/admin/new.js b/pages/admin/new.js index 0b010e9..1baabe7 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -6,10 +6,12 @@ import { UserContext } from "../../lib/context"; import { useDocumentData } from "react-firebase-hooks/firestore"; import Link from "next/link"; import Router from "next/router"; -import { createExperiment } from "../../lib/experiment-creation"; +import { createExperiment, createProviderExperiment } from "../../lib/experiment-creation"; +import { STORAGE_PROVIDERS } from "../../lib/provider-config"; import { Button, Stack, + HStack, Heading, Field, Input, @@ -41,9 +43,16 @@ function NewExperimentForm() { const [osfComponentName, setOsfComponentName] = useState(""); const [region, setRegion] = useState("us"); + const [provider, setProvider] = useState("osf"); + const [gdriveTitle, setGdriveTitle] = useState(""); + const [gdriveTitleError, setGdriveTitleError] = useState(false); + const [gdriveSubmitting, setGdriveSubmitting] = useState(false); + const [gdriveError, setGdriveError] = useState(null); + const [data, loading, error] = useDocumentData(doc(db, "users", user.uid)); const isValid = data && (data.usingPersonalToken ? data.osfTokenValid : data.refreshToken !== ""); + const gdriveConnected = STORAGE_PROVIDERS.gdrive.isConnected(data); const handleSubmit = async () => { setIsSubmitting(true); @@ -84,102 +93,196 @@ function NewExperimentForm() { } }; + const handleGdriveSubmit = async () => { + setGdriveSubmitting(true); + setGdriveError(null); + + if (gdriveTitle.length === 0) { + setGdriveTitleError(true); + setGdriveSubmitting(false); + return; + } + + try { + const result = await createProviderExperiment("gdrive", gdriveTitle); + Router.push(`/admin/${result.experimentId}`); + } catch (err) { + console.error(err); + setGdriveSubmitting(false); + setGdriveError(err.message); + } + }; + return ( <> {loading && } - {isValid && ( + {!loading && ( Create a New Experiment - - Title - { - setTitle(e.target.value); - setTitleError(false); - }} - /> - - This field is required - - - - Existing OSF Project - - - {`https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/`} - - setOsfRepo(e.target.value)} - /> - - - Cannot connect to this OSF component - - - - New OSF Data Component Name - { - setOsfComponentName(e.target.value); - setDataComponentError(false); - }} - /> - - This field is required - - - DataPipe will create a new component with this name in the OSF - project and store all data in it. - - + - Storage Location - - setRegion(e.target.value)} - > - - - - - - - - Choose the region where the data will be stored. - + Where should data be stored? + + + setProvider("osf")} + /> + OSF + + + setProvider("gdrive")} + /> + Google Drive + + - + + {provider === "osf" && isValid && ( + <> + + Title + { + setTitle(e.target.value); + setTitleError(false); + }} + /> + + This field is required + + + + Existing OSF Project + + + {`https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/`} + + setOsfRepo(e.target.value)} + /> + + + Cannot connect to this OSF component + + + + New OSF Data Component Name + { + setOsfComponentName(e.target.value); + setDataComponentError(false); + }} + /> + + This field is required + + + DataPipe will create a new component with this name in the OSF + project and store all data in it. + + + + Storage Location + + setRegion(e.target.value)} + > + + + + + + + + Choose the region where the data will be stored. + + + + + )} + + {provider === "osf" && !isValid && ( + + + DataPipe sends experiment data directly to your OSF project. + Connect your OSF account to get started. + + + + + + )} + + {provider === "gdrive" && !gdriveConnected && ( + + + DataPipe sends experiment data directly to your Google Drive. + Connect your Google Drive account to get started. + + + + + + )} + + {provider === "gdrive" && gdriveConnected && ( + <> + {gdriveError && ( + + {gdriveError} + + )} + + Title + { + setGdriveTitle(e.target.value); + setGdriveTitleError(false); + }} + /> + + This field is required + + + + + )} )} - {!loading && !isValid && ( - - - Create a New Experiment - - DataPipe sends experiment data directly to your OSF project. - Connect your OSF account to get started. - - - - - - - )} ); } diff --git a/pages/oauth2/connect.js b/pages/oauth2/connect.js new file mode 100644 index 0000000..690811a --- /dev/null +++ b/pages/oauth2/connect.js @@ -0,0 +1,185 @@ +import { VStack, Heading, Text, Button, Alert, Card, Spinner, Center } from "@chakra-ui/react"; +import { useEffect, useContext, useReducer, useRef } from "react"; +import { useRouter } from "next/router"; +import Link from "next/link"; +import { UserContext } from "../../lib/context"; +import { auth } from "../../lib/firebase"; + +// Redirect target for provider (non-OSF) OAuth connect flows -- distinct +// from pages/oauth2/callback.js, which handles the OSF IDENTITY flow +// (signup/sign-in/account linking). This page only ever grants storage +// access to an already-authenticated user (see connect-provider.ts). +// GDRIVE_REDIRECT_URI (and future provider redirect URIs) must point here. + +const initialState = { + status: "processing", + error: null, +}; + +function connectReducer(state, action) { + switch (action.type) { + case "ERROR": + return { ...state, status: "error", error: action.error }; + case "SIGNED_OUT": + return { ...state, status: "signed-out" }; + default: + return state; + } +} + +function useProviderConnectCallback() { + const { user } = useContext(UserContext); + const router = useRouter(); + const [state, dispatch] = useReducer(connectReducer, initialState); + const processingRef = useRef(false); + + const urlCode = router.query.code; + const urlState = router.query.state; + const urlError = router.query.error; + + useEffect(() => { + if (urlError) { + dispatch({ type: "ERROR", error: `OAuth error: ${urlError}` }); + return; + } + + if (!urlCode || !urlState) { + return; + } + + const storedState = localStorage.getItem("latestCSRFToken") || ""; + const provider = localStorage.getItem("providerConnectFlow") || ""; + + // CSRF check first, mirroring pages/oauth2/callback.js -- this must + // reject before we even look at sign-in state. + if (urlState !== storedState) { + dispatch({ + type: "ERROR", + error: "Invalid state parameter. Possible CSRF attack.", + }); + return; + } + + if (!user?.uid) { + dispatch({ type: "SIGNED_OUT" }); + return; + } + + const processCallback = async () => { + if (processingRef.current) return; + processingRef.current = true; + + try { + const idToken = await auth.currentUser.getIdToken(); + + const res = await fetch("/api/connectprovider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + code: urlCode, + state: urlState, + uid: user.uid, + idToken, + }), + }); + + const json = await res.json(); + + if (!res.ok || !json.success) { + throw new Error(json.error || `Failed to connect account (${res.status})`); + } + + localStorage.removeItem("latestCSRFToken"); + localStorage.removeItem("providerConnectFlow"); + + router.push("/admin/account"); + } catch (err) { + console.error("Provider connect callback error:", err); + dispatch({ type: "ERROR", error: err.message }); + processingRef.current = false; + } + }; + + processCallback(); + }, [urlCode, urlState, urlError, user?.uid]); + + return state; +} + +function ProviderConnectCallbackPage() { + const { status, error } = useProviderConnectCallback(); + + const renderContent = () => { + switch (status) { + case "signed-out": + return ( + + + + + Sign-in Required + + You must be signed in to connect a storage provider account. + + + + + + + + + + + ); + + case "error": + return ( + + + + + Connection Failed + {error} + + + + + + + + + + ); + + case "processing": + default: + return ( + +
+ +
+ + Connecting your account... + + + Please wait while we finish connecting your storage provider. + +
+ ); + } + }; + + return ( + + + + Storage Provider Connection + {renderContent()} + + + + ); +} + +export default ProviderConnectCallbackPage; From 564012ab19ccc8068c4a432ce25932907394069d Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 10:51:23 -0400 Subject: [PATCH 037/181] docs: deployment checklist for gdrive launch; cap CI jest workers - design doc gains a consolidated deployment checklist (OAuth app verification, functions env vars, TTL policy, refresh-query index, FAQ pointer, deploy order) - CI runs jest with --maxWorkers=2: the emulator-backed suites contend under full parallel load (pre-existing data-emulator timing flake) Co-Authored-By: Claude Fable 5 --- .github/workflows/node.js.yml | 5 ++++- docs/provider-migration-design.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 23284f2..46514c8 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -62,4 +62,7 @@ jobs: - name: Select project run: firebase use test - name: Launch firestore emulator and test - run: firebase emulators:exec 'npm run test-ci' + # maxWorkers=2: the emulator-backed suites contend under full + # parallel load (pre-existing data-emulator timing flake); capping + # workers reproduces consistently-green runs. + run: firebase emulators:exec 'npm run test-ci -- --maxWorkers=2' diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index b9fc536..9fe3008 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -355,6 +355,34 @@ provider, it does not trigger a redesign. flow has more failure modes than a single PUT; verify behavior under concurrent submissions, including media-sized files. +## Deployment checklist (gdrive launch) + +Accumulated from build steps 1–7; everything below is required before the +Google Drive provider is announced: + +1. **Google OAuth app** (step 0): register the OAuth client (drive.file + scope), set the consent screen, and complete Google's verification to + published status — until then refresh tokens last 7 days and the app is + capped at 100 users. Weeks of lead time. +2. **Functions env**: `GDRIVE_CLIENT_ID`, `GDRIVE_CLIENT_SECRET`, + `GDRIVE_REDIRECT_URI` (must point at `https://pipe.jspsych.org/oauth2/connect`). + `GDRIVE_API_BASE`/`GDRIVE_TOKEN_URL`/`GDRIVE_AUTHORIZE_URL` default to + the real Google endpoints and need no production values. +3. **Firestore TTL policy** on `filenameClaims` `expiresAt` field + (console/gcloud). Cost-boundedness only — correctness never depends on + it. +4. **Firestore index**: the scheduled gdrive refresh queries + `connectedAccounts.gdrive.tokenExpiresAt` — confirm the single-field + index exists in production (auto-indexing normally covers it; the + emulator does not prove it). +5. **FAQ / user docs**: announce Drive support, its quota caveat (15 GB + shared with Gmail/Photos), and the app-created "DataPipe" folder + behavior. Copy deliberately not drafted by the migration — researcher- + facing wording is an editorial decision. +6. **Deploy order**: functions + rules + hosting can ship together; the + collision cache dual-runs against OSF's 409 for legacy experiments, so + no data migration or flag-flip is needed. + ## Open questions - **Test-suite hazard (pre-existing, discovered during step 4b)**: the OSF From 7bdbe21f4afbbe40a61c1c186ce8fa3f0e3bd271 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 23 Jul 2026 18:18:02 -0400 Subject: [PATCH 038/181] fix: address Copilot review findings on PR #154 - api-data/api-base64 record queue failures as "Provider error " (they can come from any provider now); QueuePanel maps both the new prefix and the legacy "OSF error" strings still stored in queue docs - gdrive writeSessionFile builds its FileRef from the already-fallbacked storedFilename, restoring the FileRef.name contract without the cast - test-env GDRIVE_REDIRECT_URI points at /oauth2/connect, the route that actually exists Co-Authored-By: Claude Fable 5 --- __tests__/queue-panel.test.jsx | 17 +++++++++++++++++ components/dashboard/QueuePanel.js | 9 ++++++--- functions/.env.datapipe-test | 2 +- .../__tests__/oauth-connect-emulator.test.js | 2 +- functions/src/__tests__/upload-queue.test.js | 4 ++-- functions/src/api-base64.ts | 2 +- functions/src/api-data.ts | 2 +- functions/src/providers/gdrive.ts | 2 +- 8 files changed, 30 insertions(+), 10 deletions(-) diff --git a/__tests__/queue-panel.test.jsx b/__tests__/queue-panel.test.jsx index 8d5b679..1a83e36 100644 --- a/__tests__/queue-panel.test.jsx +++ b/__tests__/queue-panel.test.jsx @@ -29,6 +29,13 @@ const entries = [ failureReason: "OSF error 503: Service Unavailable", createdAt: new Date(), }, + { + id: "e3", + filename: "sub-03_data.csv", + status: "failed", + failureReason: "Provider error 429: Too Many Requests", + createdAt: new Date(), + }, ]; function renderPanel() { @@ -65,5 +72,15 @@ describe("QueuePanel — provider-neutral copy", () => { expect( screen.queryByText(/^OSF was temporarily unavailable\.?$/i) ).not.toBeInTheDocument(); + + // Both the legacy "OSF error " prefix (older queue docs) and + // the current "Provider error " prefix must map to friendly + // copy — neither raw string may reach the UI. + expect( + screen.getByText(/storage provider rate-limited the request/i) + ).toBeInTheDocument(); + expect( + screen.queryByText(/Provider error 429/) + ).not.toBeInTheDocument(); }); }); diff --git a/components/dashboard/QueuePanel.js b/components/dashboard/QueuePanel.js index 795b00b..99c513c 100644 --- a/components/dashboard/QueuePanel.js +++ b/components/dashboard/QueuePanel.js @@ -21,13 +21,16 @@ function friendlyReason(reason) { if (reason.includes("Upload exception") || reason.includes("fetch failed")) { return "Could not connect to your storage provider."; } - if (reason.includes("OSF error 503") || reason.includes("OSF error 502")) { + // Older queue docs say "OSF error "; current writes say + // "Provider error ". Both must keep mapping. + const status = reason.match(/(?:OSF|Provider) error (\d{3})/)?.[1]; + if (status === "503" || status === "502") { return "Your storage provider was temporarily unavailable."; } - if (reason.includes("OSF error 429")) { + if (status === "429") { return "Your storage provider rate-limited the request."; } - if (reason.includes("OSF error 401") || reason.includes("OSF error 403")) { + if (status === "401" || status === "403") { return "Authentication error. Your storage provider connection may need to be refreshed."; } return reason; diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test index a28fb52..6f272c1 100644 --- a/functions/.env.datapipe-test +++ b/functions/.env.datapipe-test @@ -3,5 +3,5 @@ GDRIVE_TOKEN_URL=http://127.0.0.1:3580/token GDRIVE_AUTHORIZE_URL=http://127.0.0.1:3580/authorize GDRIVE_CLIENT_ID=test-client-id GDRIVE_CLIENT_SECRET=test-client-secret -GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/gdrive +GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/connect TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab diff --git a/functions/src/__tests__/oauth-connect-emulator.test.js b/functions/src/__tests__/oauth-connect-emulator.test.js index c1d0c58..fc8128d 100644 --- a/functions/src/__tests__/oauth-connect-emulator.test.js +++ b/functions/src/__tests__/oauth-connect-emulator.test.js @@ -59,7 +59,7 @@ const config = { projectId: "datapipe-test" }; const TOKEN_ENCRYPTION_KEY = "ab".repeat(32); const GDRIVE_AUTHORIZE_URL = "http://127.0.0.1:3580/authorize"; const GDRIVE_CLIENT_ID = "test-client-id"; -const GDRIVE_REDIRECT_URI = "http://localhost:3000/oauth2/gdrive"; +const GDRIVE_REDIRECT_URI = "http://localhost:3000/oauth2/connect"; const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file"; const TOKEN_PORT = 3580; diff --git a/functions/src/__tests__/upload-queue.test.js b/functions/src/__tests__/upload-queue.test.js index dd33fb0..19362a2 100644 --- a/functions/src/__tests__/upload-queue.test.js +++ b/functions/src/__tests__/upload-queue.test.js @@ -198,7 +198,7 @@ describe("queue entry lifecycle in Firestore", () => { await docRef.update({ status: "failed", retryCount: newRetryCount, - failureReason: "OSF error 500: Internal Server Error", + failureReason: "Provider error 500: Internal Server Error", }); } @@ -206,7 +206,7 @@ describe("queue entry lifecycle in Firestore", () => { expect(result.data().status).toBe("failed"); expect(result.data().retryCount).toBe(5); expect(result.data().failureReason).toBe( - "OSF error 500: Internal Server Error" + "Provider error 500: Internal Server Error" ); }); }); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 73c32fb..5fab304 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -237,7 +237,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: dataType: "base64", osfFilesLink: exp_data.osfFilesLink, storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: result.providerStatus || 0, sessionIncremented: false, - failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, + failureReason: `Provider error ${result.providerStatus}: ${result.providerMessage}`, claimToken, }); await cleanupPending(pendingPath); // queue-upload has its own copy diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 06a1f17..2b3a118 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -274,7 +274,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 dataType: "data", osfFilesLink: exp_data.osfFilesLink, storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: result.providerStatus || 0, sessionIncremented: true, - failureReason: `OSF error ${result.providerStatus}: ${result.providerMessage}`, + failureReason: `Provider error ${result.providerStatus}: ${result.providerMessage}`, claimToken, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index 840840f..1a22eb0 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -257,7 +257,7 @@ export const gdriveProvider: StorageProvider = { return { success: true, - fileRef: { id: responseBody.id, name: responseBody.name } as unknown as FileRef, + fileRef: { id: responseBody.id, name: storedFilename }, storedFilename, }; }, From f7e1d82a12a5b3d6dc16109647745a599cc0e5d7 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Fri, 24 Jul 2026 07:53:41 -0400 Subject: [PATCH 039/181] fix: address review findings on PR #154 (minor items) - ProviderConnections: use relative /api/generateoauthstate path, matching the rest of the new provider code's rewrite convention - ExperimentInfo: render per-provider containerLinkText ("Open folder") instead of the raw Drive folder ID as the container link text - api-data/api-base64: cleanupPending in both duplicate 400 branches so duplicate submissions don't take a lap through pending recovery Co-Authored-By: Claude Fable 5 --- __tests__/experiment-info.test.jsx | 2 +- __tests__/provider-config.test.js | 1 + __tests__/provider-connections.test.jsx | 2 +- components/account/ProviderConnections.js | 2 +- components/dashboard/ExperimentInfo.js | 2 +- functions/src/api-base64.ts | 2 ++ functions/src/api-data.ts | 2 ++ lib/provider-config.js | 1 + 8 files changed, 10 insertions(+), 4 deletions(-) diff --git a/__tests__/experiment-info.test.jsx b/__tests__/experiment-info.test.jsx index d6f52c2..90ece43 100644 --- a/__tests__/experiment-info.test.jsx +++ b/__tests__/experiment-info.test.jsx @@ -39,7 +39,7 @@ describe("ExperimentInfo — provider-aware rendering", () => { }); expect(screen.getByText("Google Drive Folder")).toBeInTheDocument(); - const link = screen.getByRole("link", { name: /folder123/ }); + const link = screen.getByRole("link", { name: /Open folder/ }); expect(link).toHaveAttribute( "href", "https://drive.google.com/drive/folders/folder123" diff --git a/__tests__/provider-config.test.js b/__tests__/provider-config.test.js index de97d2b..9ece971 100644 --- a/__tests__/provider-config.test.js +++ b/__tests__/provider-config.test.js @@ -32,6 +32,7 @@ describe("STORAGE_PROVIDERS.gdrive", () => { expect(STORAGE_PROVIDERS.gdrive.containerLabel).toBe( "Google Drive Folder" ); + expect(STORAGE_PROVIDERS.gdrive.containerLinkText).toBe("Open folder"); expect(STORAGE_PROVIDERS.gdrive.id).toBe("gdrive"); }); diff --git a/__tests__/provider-connections.test.jsx b/__tests__/provider-connections.test.jsx index 66dc45d..3637e96 100644 --- a/__tests__/provider-connections.test.jsx +++ b/__tests__/provider-connections.test.jsx @@ -74,7 +74,7 @@ describe("ProviderConnections", () => { await waitFor(() => expect(global.fetch).toHaveBeenCalled()); const [url, options] = global.fetch.mock.calls[0]; - expect(url).toBe(process.env.NEXT_PUBLIC_GENERATE_STATE); + expect(url).toBe("/api/generateoauthstate"); expect(JSON.parse(options.body)).toEqual({ provider: "gdrive" }); await waitFor(() => diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 3ca3df5..3acc468 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -19,7 +19,7 @@ export default function ProviderConnections() { const handleConnect = async (providerId) => { setConnectingId(providerId); try { - const stateRes = await fetch(process.env.NEXT_PUBLIC_GENERATE_STATE, { + const stateRes = await fetch("/api/generateoauthstate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: providerId }), diff --git a/components/dashboard/ExperimentInfo.js b/components/dashboard/ExperimentInfo.js index a1ed83c..3ba6df1 100644 --- a/components/dashboard/ExperimentInfo.js +++ b/components/dashboard/ExperimentInfo.js @@ -25,7 +25,7 @@ export default function ExperimentInfo({ data }) { target="_blank" rel="noopener noreferrer" > - {data.providerContainer?.folderId} + {provider.containerLinkText}
) : ( diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 5fab304..90c9fa9 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -153,6 +153,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: if (!claimResult.claimed) { if (claimResult.reason === "duplicate") { + await cleanupPending(pendingPath); res.status(400).json(MESSAGES.OSF_FILE_EXISTS); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); return; @@ -226,6 +227,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: collisionCacheDisagreement: true, direction: "cache-free-provider-conflict", }); + await cleanupPending(pendingPath); res.status(400).json(MESSAGES.OSF_FILE_EXISTS); return; } diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 2b3a118..fbb9b42 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -187,6 +187,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 if (!claimResult.claimed) { if (claimResult.reason === "duplicate") { + await cleanupPending(pendingPath); res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); return; @@ -263,6 +264,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 collisionCacheDisagreement: true, direction: "cache-free-provider-conflict", }); + await cleanupPending(pendingPath); res.status(400).json({...MESSAGES.OSF_FILE_EXISTS, metadataMessage}); return; } diff --git a/lib/provider-config.js b/lib/provider-config.js index 63bcb96..8a3da6f 100644 --- a/lib/provider-config.js +++ b/lib/provider-config.js @@ -10,5 +10,6 @@ export const STORAGE_PROVIDERS = { containerLink: (exp) => `https://drive.google.com/drive/folders/${exp.providerContainer?.folderId}`, containerLabel: "Google Drive Folder", + containerLinkText: "Open folder", }, }; From 33545db3f575e497da86e5f8d2f7c2a2210f6947 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Fri, 24 Jul 2026 10:48:08 -0400 Subject: [PATCH 040/181] fix: point deployed gdrive OAuth at real Google creds, not emulator mocks The datapipe-test site sent users to a localhost authorize URL when connecting Google Drive. Firebase loads functions/.env.datapipe-test on both `firebase deploy` and the emulator, and that file held the emulator mock values (127.0.0.1 token/authorize servers, test-client-id, localhost redirect), so the deployed functions handed those to the browser. Split the two concerns along Firebase's emulator-only .env.local seam: - functions/.env.local (new, committed, never deployed) holds the mock values for local dev + the CI test suite. Un-ignored in .gitignore so a fresh checkout works with no setup; contents are non-secret test dummies. - functions/.env.datapipe-test now holds the real deployed config: the Google client id and https redirect uri. Authorize/token/api-base URLs are unset so the real Google defaults apply. - firebase-deploy-test.yml injects GDRIVE_CLIENT_SECRET from the TEST_GDRIVE_CLIENT_SECRET GitHub secret. Verified: oauth-connect / gdrive / resolve-token emulator suites stay green (.env.local overrides shadow the real values under the emulator). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/firebase-deploy-test.yml | 3 +++ .gitignore | 2 ++ functions/.env.datapipe-test | 27 +++++++++++++++++----- functions/.env.local | 20 ++++++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 functions/.env.local diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index 430eee3..22f513a 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -56,6 +56,9 @@ jobs: echo "REDIRECT_URI=https://datapipe-test.web.app/oauth2/callback" >> .env echo "TOKEN_ENCRYPTION_KEY=${{ secrets.FIRESTORE_KEY_TEST }}" >> .env echo "NEXT_PUBLIC_OSF_ENV=" >> .env + # Google Drive client secret for the deployed test site. Client id and + # redirect uri are non-secret and live in functions/.env.datapipe-test. + echo "GDRIVE_CLIENT_SECRET=${{ secrets.TEST_GDRIVE_CLIENT_SECRET }}" >> .env - name: Install dependencies and build functions working-directory: functions run: | diff --git a/.gitignore b/.gitignore index 99fca49..d59302a 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,8 @@ yarn-error.log* # local env files .env*.local +# ...except the committed emulator/CI overrides (never deployed by Firebase) +!functions/.env.local # vercel .vercel diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test index 6f272c1..285c4e1 100644 --- a/functions/.env.datapipe-test +++ b/functions/.env.datapipe-test @@ -1,7 +1,22 @@ -GDRIVE_API_BASE=http://127.0.0.1:3579 -GDRIVE_TOKEN_URL=http://127.0.0.1:3580/token -GDRIVE_AUTHORIZE_URL=http://127.0.0.1:3580/authorize -GDRIVE_CLIENT_ID=test-client-id -GDRIVE_CLIENT_SECRET=test-client-secret -GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/connect +# Real config for the DEPLOYED datapipe-test site. +# +# Firebase loads this file both on `firebase deploy` and in the emulator. +# Emulator runs additionally load .env.local, which overrides everything here +# with local mock values -- so the mock Drive/token servers and test dummies +# live in .env.local, NOT here. Anything in this file reaches the live site. +# +# The Google client secret is NOT here (this file is committed). It is +# injected at deploy time from the TEST_GDRIVE_CLIENT_SECRET GitHub secret +# (see .github/workflows/firebase-deploy-test.yml), and for a local +# `firebase deploy` it comes from the git-ignored functions/.env. +# +# GDRIVE_AUTHORIZE_URL / GDRIVE_TOKEN_URL / GDRIVE_API_BASE are intentionally +# unset so the code falls back to the real Google defaults. +GDRIVE_CLIENT_ID=699904257039-6ruej6q9bopica806khlmsqj6jg4eg6c.apps.googleusercontent.com +GDRIVE_REDIRECT_URI=https://datapipe-test.web.app/oauth2/connect + +# NOTE (pre-existing, unrelated to the gdrive fix): this dummy key overrides +# the real FIRESTORE_KEY_TEST secret on the deployed test site because +# .env. beats .env. Left as-is to avoid orphaning already-encrypted +# test tokens; worth revisiting as a separate change. TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab diff --git a/functions/.env.local b/functions/.env.local new file mode 100644 index 0000000..16d4020 --- /dev/null +++ b/functions/.env.local @@ -0,0 +1,20 @@ +# Emulator-only overrides for local dev and the CI test suite. +# +# Firebase loads this file ONLY when running the emulators; it is never +# included in `firebase deploy`. That is exactly why the Google Drive mock +# values live here instead of in .env.datapipe-test -- keeping them out of +# the deployed test site (see .env.datapipe-test for the real values). +# +# It is intentionally committed (see the `!functions/.env.local` exception +# in the root .gitignore) so `npm test` / `firebase emulators:exec` work from +# a fresh checkout, in CI and locally, with no extra setup. Every value here +# is a non-secret test dummy. The oauth-connect / gdrive emulator tests +# assert against these exact values and mock servers bind the fixed ports +# below, so keep them in sync with those tests. +GDRIVE_API_BASE=http://127.0.0.1:3579 +GDRIVE_TOKEN_URL=http://127.0.0.1:3580/token +GDRIVE_AUTHORIZE_URL=http://127.0.0.1:3580/authorize +GDRIVE_CLIENT_ID=test-client-id +GDRIVE_CLIENT_SECRET=test-client-secret +GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/connect +TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab From 022c24ad69e7d008b68712d9a74fdd26594265ae Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 25 Jul 2026 13:03:29 -0400 Subject: [PATCH 041/181] feat: let experiments target a chosen gdrive folder (backend) Backend for per-experiment Google Drive folder selection. The Picker UI (front-end, next) lets a researcher pick an existing Drive folder; these pieces let the picked folder become the experiment's data-folder parent instead of the fixed My Drive/DataPipe. - getProviderAccessToken (/api/getprovideraccesstoken): owner-authenticated endpoint returning a short-lived drive.file access token to the browser for Picker use, minted via resolve-token from the stored refresh token. Returns the access token only -- no refresh token or other user data. - gdriveProvider.createDataContainer: create the experiment folder under a researcher-supplied parentId when present; unchanged My Drive/DataPipe fallback when absent. - createExperiment: plumb optional parentFolderId through to the container. Additive and backward-compatible: parentFolderId is optional and the new endpoint is unused until the Picker UI lands. Emulator tests cover the endpoint's full auth/validation matrix and the parent-placement behavior (plus a default-path regression). Verified green (36/36) + clean build. Co-Authored-By: Claude Opus 4.8 --- firebase.json | 4 + .../create-experiment-emulator.test.js | 47 +++++ ...get-provider-access-token-emulator.test.js | 193 ++++++++++++++++++ functions/src/create-experiment.ts | 8 +- functions/src/get-provider-access-token.ts | 89 ++++++++ functions/src/index.ts | 4 +- functions/src/providers/gdrive.ts | 22 +- 7 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 functions/src/__tests__/get-provider-access-token-emulator.test.js create mode 100644 functions/src/get-provider-access-token.ts diff --git a/firebase.json b/firebase.json index c8987ef..c8614c9 100644 --- a/firebase.json +++ b/firebase.json @@ -65,6 +65,10 @@ { "source": "/api/disconnectprovider", "function": "disconnectprovider" + }, + { + "source": "/api/getprovideraccesstoken", + "function": "getprovideraccesstoken" } ] }, diff --git a/functions/src/__tests__/create-experiment-emulator.test.js b/functions/src/__tests__/create-experiment-emulator.test.js index da788ed..5c24c8c 100644 --- a/functions/src/__tests__/create-experiment-emulator.test.js +++ b/functions/src/__tests__/create-experiment-emulator.test.js @@ -122,6 +122,12 @@ function createMockDriveServer() { } return null; }, + getParents: (name) => { + for (const f of filesById.values()) { + if (f.name === name) return f.parents; + } + return null; + }, forceStatus: (name, status) => forcedStatus.set(name, status), reset: () => { filesById.clear(); @@ -370,3 +376,44 @@ describe("9. createExperiment Drive container-creation failure", () => { expect(userData?.experiments || []).toEqual([]); }); }); + +describe("10. createExperiment with parentFolderId", () => { + it("creates the experiment folder directly under the given parent, skipping the DataPipe-root lookup entirely", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case10 ${randomUUID()}`; + const parentFolderId = `picker-folder-${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ + provider: "gdrive", + title, + idToken, + uid, + parentFolderId, + }); + + expect(status).toBe(200); + const folderId = mockDrive.getFolderId(title); + expect(typeof folderId).toBe("string"); + expect(body.providerContainer).toEqual({ provider: "gdrive", folderId }); + expect(mockDrive.getParents(title)).toEqual([parentFolderId]); + + // The DataPipe-root find-or-create is skipped entirely when a + // researcher-chosen parent is supplied. + expect(mockDrive.getCreateCount("DataPipe")).toBe(0); + }); + + it("still lands under the DataPipe root when parentFolderId is omitted (regression)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case10b ${randomUUID()}`; + + const { status } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(200); + const dataPipeId = mockDrive.getFolderId("DataPipe"); + expect(typeof dataPipeId).toBe("string"); + expect(mockDrive.getParents(title)).toEqual([dataPipeId]); + expect(mockDrive.getCreateCount("DataPipe")).toBe(1); + }); +}); diff --git a/functions/src/__tests__/get-provider-access-token-emulator.test.js b/functions/src/__tests__/get-provider-access-token-emulator.test.js new file mode 100644 index 0000000..f08e84b --- /dev/null +++ b/functions/src/__tests__/get-provider-access-token-emulator.test.js @@ -0,0 +1,193 @@ +/** + * @jest-environment node + */ + +// Emulator integration tests for getProviderAccessToken +// (functions/src/get-provider-access-token.ts), the endpoint the Picker +// front-end (a later build step) calls to obtain a raw Drive access token +// for client-side use. Follows the exact patterns established by +// oauth-connect-emulator.test.js (auth via the Auth emulator's +// accounts:signUp, encrypted-token seeding/decryption) and +// create-experiment-emulator.test.js (postJson/signUpEmulatorUser helpers). +// +// Per index.ts's lowercase export convention (getProviderAccessToken -> +// getprovideraccesstoken), the URL under test is +// http://localhost:5001/datapipe-test/us-central1/getprovideraccesstoken. +// +// No new mock Drive/token server is started here: the happy-path case seeds +// an UNEXPIRED connectedAccounts.gdrive entry (same shape +// create-experiment-emulator.test.js's seedGdriveUser uses), so +// resolve-token.ts's resolveGdriveToken never needs to hit the token +// endpoint at all -- avoiding any risk of colliding with the reserved fixed +// ports (3579 = mock Drive API, 3580 = mock OAuth token server) that other +// suites in this same jest run bind. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test" }; +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const GET_TOKEN_URL = `${FUNCTIONS_BASE}/getprovideraccesstoken`; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +let db; + +beforeAll(() => { + let app; + try { + app = getApp("get-provider-access-token-test"); + } catch { + app = initializeApp(config, "get-provider-access-token-test"); + } + db = getFirestore(app); +}); + +// ---- helpers ---- + +async function signUpEmulatorUser() { + const email = `get-provider-access-token-${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, payload) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; +} + +function getViaGet(payload) { + // 405 case: method must be rejected before body parsing matters, but a GET + // with a query string is enough to exercise the method check. + return fetch(`${GET_TOKEN_URL}?${new URLSearchParams(payload)}`, { method: "GET" }).then( + async (res) => ({ status: res.status, body: await res.json().catch(() => ({})) }) + ); +} + +function callGetProviderAccessToken(payload) { + return postJson(GET_TOKEN_URL, payload); +} + +async function seedGdriveUser(uid, overrides = {}) { + await db.collection("users").doc(uid).set({ + connectedAccounts: { + gdrive: { + authMethod: "oauth2", + // plaintext fallback, same convention as create-experiment-emulator.test.js + encryptedToken: "get-token-plaintext-access-token", + encryptedRefreshToken: "get-token-plaintext-refresh-token", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, // unexpired -- no refresh needed + providerAccountId: "get-token-acct", + ...overrides, + }, + }, + }); +} + +// ---- cases ---- + +describe("getProviderAccessToken method + validation", () => { + it("returns 405 for a GET request", async () => { + const { status } = await getViaGet({ provider: "gdrive", uid: "whoever" }); + expect(status).toBe(405); + }); + + it("returns 400 when provider is missing", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ uid, idToken }); + expect(status).toBe(400); + }); + + it("returns 400 when uid is missing", async () => { + const { idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ provider: "gdrive", idToken }); + expect(status).toBe(400); + }); +}); + +describe("getProviderAccessToken auth failures", () => { + it("returns 401 when idToken is missing", async () => { + const { uid } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ provider: "gdrive", uid }); + expect(status).toBe(401); + }); + + it("returns 403 when idToken belongs to a different emulator user than uid", async () => { + const userA = await signUpEmulatorUser(); + const userB = await signUpEmulatorUser(); + await seedGdriveUser(userA.uid); + + const { status } = await callGetProviderAccessToken({ + provider: "gdrive", + uid: userA.uid, + idToken: userB.idToken, + }); + + expect(status).toBe(403); + }); +}); + +describe("getProviderAccessToken provider validation", () => { + it("returns 400 for provider 'osf' (the OSF identity flow is separate)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ provider: "osf", uid, idToken }); + expect(status).toBe(400); + }); + + it("returns 400 for an unknown provider", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const { status } = await callGetProviderAccessToken({ + provider: "not-a-real-provider", + uid, + idToken, + }); + expect(status).toBe(400); + }); +}); + +describe("getProviderAccessToken token resolution", () => { + it("returns 400 surfacing PROVIDER_NOT_CONNECTED when no gdrive account is connected", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // Deliberately no connectedAccounts.gdrive seeded. + const { status, body } = await callGetProviderAccessToken({ provider: "gdrive", uid, idToken }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining(MESSAGES.PROVIDER_NOT_CONNECTED)); + }); + + it("returns 200 with the decrypted accessToken for a connected gdrive user, and nothing else", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + + const { status, body } = await callGetProviderAccessToken({ provider: "gdrive", uid, idToken }); + + expect(status).toBe(200); + expect(body).toEqual({ accessToken: "get-token-plaintext-access-token" }); + // No refresh token or other user data leaks into the response. + expect(Object.keys(body).sort()).toEqual(["accessToken"]); + }); +}); diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts index 32c22a2..fa19714 100644 --- a/functions/src/create-experiment.ts +++ b/functions/src/create-experiment.ts @@ -59,12 +59,18 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { idToken, uid, experimentSettings, + parentFolderId, }: { provider?: string; title?: string; idToken?: string; uid?: string; experimentSettings?: ExperimentSettingsOverrides; + // Researcher-chosen Drive folder (via the Picker) to create the + // experiment's data folder under, instead of the default DataPipe + // root. Optional and provider-shaped -- createDataContainer ignores it + // for providers that don't understand a parentId. + parentFolderId?: string; } = req.body || {}; if (!provider || !title || !uid) { @@ -113,7 +119,7 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { try { providerContainer = await storageProvider.createDataContainer( { token: tokenResult.token }, - { name: title } + { name: title, ...(parentFolderId ? { parentId: parentFolderId } : {}) } ); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; diff --git a/functions/src/get-provider-access-token.ts b/functions/src/get-provider-access-token.ts new file mode 100644 index 0000000..d686311 --- /dev/null +++ b/functions/src/get-provider-access-token.ts @@ -0,0 +1,89 @@ +// Short-lived provider access token for client-side Google Picker use +// (docs/provider-migration-design.md). The Picker's folder-choosing UI +// (a later, front-end build step) needs a raw Drive access token in the +// browser, but decrypting/refreshing that token is server-only work that +// only resolve-token.ts can do -- this endpoint is the one place that +// hands a decrypted token back to an authenticated caller. +// +// Auth + request shape mirrors connect-provider.ts's storage-grant flow +// (POST only, { provider, uid, idToken } body, verifyOwnership for +// 401/403). Token resolution mirrors create-experiment.ts's use of +// resolve-token.ts, including its MESSAGES mapping on failure. + +import { onRequest } from "firebase-functions/v2/https"; +import { db } from "./app.js"; +import { verifyOwnership } from "./connect-provider.js"; +import resolveToken from "./resolve-token.js"; +import { getOAuthConfig } from "./providers/oauth-config.js"; +import { StorageProviderId } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; +import MESSAGES from "./api-messages.js"; + +export const getProviderAccessToken = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const { + provider, + uid, + idToken, + }: { provider?: string; uid?: string; idToken?: string } = req.body || {}; + + if (!provider || !uid) { + res.status(400).json({ error: "Missing required parameters" }); + return; + } + + // getOAuthConfig only has an entry for OAuth2 providers (gdrive today). + // OSF deliberately has no entry -- its identity flow is a separate, + // legacy path (oauth2-callback.ts) -- so this single check rejects both + // "osf" and any unregistered/unknown provider, same as connect-provider.ts. + try { + getOAuthConfig(provider); + } catch { + res.status(400).json({ error: "Unknown provider" }); + return; + } + + // Verify the caller owns the uid they claim -- same shape as + // connect-provider.ts's storage-grant flow (401 missing/invalid token, + // 403 uid mismatch). No signup path here, ever. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + const userDoc = await db.doc(`users/${uid}`).get(); + // A freshly-signed-up user may have no Firestore doc yet -- treat that + // the same as "no connected accounts" rather than throwing, so + // PROVIDER_NOT_CONNECTED applies uniformly (same as create-experiment.ts). + const userData: UserData = (userDoc.data() as UserData) || ({} as UserData); + + const tokenResult = await resolveToken(userData, { + storageProvider: provider as StorageProviderId, + owner: uid, + } as ExperimentData); + + if (!tokenResult.success) { + const errorMessage = + MESSAGES[tokenResult.error as keyof typeof MESSAGES] || MESSAGES.TOKEN_RESOLUTION_ERROR; + res.status(400).json(errorMessage); + return; + } + + // Only the access token -- never the refresh token or any other user + // data. resolve-token.ts's TokenResult carries no expiry to surface + // alongside it. + res.status(200).json({ accessToken: tokenResult.token }); + } catch (error) { + console.error( + "Error getting provider access token:", + error instanceof Error ? error.message : "Unknown error" + ); + res.status(500).json({ error: "Failed to get provider access token" }); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index 082cbde..c4d9f76 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,6 +16,7 @@ import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; import { createExperiment } from "./create-experiment.js"; +import { getProviderAccessToken } from "./get-provider-access-token.js"; setGlobalOptions({ maxInstances: 20 @@ -38,5 +39,6 @@ export { saveOsfToken as saveosftoken, getOsfToken as getosftoken, onUserDeleted as onuserdeleted, - createExperiment as createexperiment + createExperiment as createexperiment, + getProviderAccessToken as getprovideraccesstoken }; diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index b88e86a..a863b75 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -190,15 +190,27 @@ export const gdriveProvider: StorageProvider = { async createDataContainer(auth: ResolvedAuth, researcherInput: Record): Promise { const name = researcherInput.name as string; - - let rootId = await findFolder(auth, "DataPipe", "root"); - if (!rootId) { - rootId = await createFolder(auth, "DataPipe", "root"); + const parentId = researcherInput.parentId as string | undefined; + + // A researcher-chosen parent (via the Picker) bypasses the DataPipe-root + // convention entirely -- the experiment folder is created directly under + // whatever folder they picked. Absent a parentId, fall back to today's + // behavior: find-or-create a shared "DataPipe" folder at root and nest + // the experiment folder under that. + let targetParentId: string; + if (parentId) { + targetParentId = parentId; + } else { + let rootId = await findFolder(auth, "DataPipe", "root"); + if (!rootId) { + rootId = await createFolder(auth, "DataPipe", "root"); + } + targetParentId = rootId; } // Experiment folders are always created fresh — Drive allows duplicate // names, so there's nothing to find-or-create here. - const folderId = await createFolder(auth, name, rootId); + const folderId = await createFolder(auth, name, targetParentId); return { provider: "gdrive", folderId }; }, From 3e0a7737d73e32bf3977c659ec410e1c99bab49c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 25 Jul 2026 13:20:07 -0400 Subject: [PATCH 042/181] feat: Google Drive folder picker for experiment creation (front-end) Lets researchers optionally choose which existing Drive folder an experiment's data folder is created in, via the Google Picker. If no folder is chosen, behavior is unchanged (My Drive/DataPipe/). - lib/google-picker.js: isolated gapi/Picker loader (memoized, SSR-guarded) exposing pickDriveFolder() -> { id, name } | null (cancel). Builds a folder-only DocsView with setAppId so the drive.file grant ties to our app and the picked folder is writable server-side later. - pages/admin/new.js (gdrive path only): a "Choose Drive folder" button that fetches a short-lived token from /api/getprovideraccesstoken, opens the Picker, and stores the selection; passed to createProviderExperiment. - createProviderExperiment forwards the optional parentFolderId. - firebase-deploy-test.yml: NEXT_PUBLIC_GOOGLE_PICKER_API_KEY (referrer/API restricted, browser-safe) + NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER. Tests: parentFolderId forwarding/omission, picker script single-injection + PICKED/CANCEL paths; existing new-experiment-page regression intact. Verified: 11/11 front-end tests green, build compiles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/workflows/firebase-deploy-test.yml | 5 + __tests__/experiment-creation.test.js | 130 ++++++++++++++++++++ __tests__/google-picker.test.js | 91 ++++++++++++++ lib/experiment-creation.js | 3 +- lib/google-picker.js | 133 +++++++++++++++++++++ pages/admin/new.js | 89 +++++++++++++- 6 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 __tests__/experiment-creation.test.js create mode 100644 __tests__/google-picker.test.js create mode 100644 lib/google-picker.js diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index 22f513a..bf84a6a 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -20,6 +20,11 @@ env: NEXT_PUBLIC_GENERATE_STATE: "https://datapipe-test.web.app/api/generateoauthstate" NEXT_PUBLIC_BASE_URL: "https://datapipe-test.web.app" NEXT_PUBLIC_OSF_ENV: "" + # Google Picker (folder selection for gdrive experiments). The API key is + # restricted to the Picker API + our domains, so it is safe in the browser + # bundle -- not a secret. The project number is public. + NEXT_PUBLIC_GOOGLE_PICKER_API_KEY: "AIzaSyDLg6uprrY5BPjY4ClVZGSvy7sd_ug0t9M" + NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER: "699904257039" jobs: deploy: diff --git a/__tests__/experiment-creation.test.js b/__tests__/experiment-creation.test.js new file mode 100644 index 0000000..d865a31 --- /dev/null +++ b/__tests__/experiment-creation.test.js @@ -0,0 +1,130 @@ +// nanoid v5 ships ESM-only and isn't transformed by the default Jest config +// (node_modules is excluded); experiment-creation.js imports it at the top +// level for the (untested-here) OSF path, so it must be mocked even though +// createProviderExperiment itself never calls it. +jest.mock("nanoid", () => ({ + customAlphabet: () => () => "mocked-id", +})); + +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: null }, + db: {}, +})); + +// firebase/firestore's doc/writeBatch/arrayUnion (used by the untested-here +// OSF path in this module) must not touch a real Firestore instance. +jest.mock("firebase/firestore", () => ({ + doc: jest.fn(() => ({})), + writeBatch: jest.fn(() => ({ + set: jest.fn(), + update: jest.fn(), + commit: jest.fn(() => Promise.resolve()), + })), + arrayUnion: jest.fn((v) => v), +})); + +import { createProviderExperiment } from "../lib/experiment-creation"; +import { auth } from "../lib/firebase"; + +function mockUser({ uid = "user-123", idToken = "id-token-abc" } = {}) { + return { + uid, + getIdToken: jest.fn().mockResolvedValue(idToken), + }; +} + +describe("createProviderExperiment", () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + auth.currentUser = null; + }); + + it("omits parentFolderId from the request body when not provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-1" }), + }); + + const result = await createProviderExperiment("gdrive", "My Experiment"); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/createexperiment"); + const body = JSON.parse(options.body); + expect(body).toEqual({ + provider: "gdrive", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + }); + expect(body.parentFolderId).toBeUndefined(); + expect(result).toEqual({ experimentId: "exp-1" }); + }); + + it("forwards parentFolderId in the request body when provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-2" }), + }); + + const result = await createProviderExperiment( + "gdrive", + "My Experiment", + "folder-xyz" + ); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.parentFolderId).toBe("folder-xyz"); + expect(body).toEqual({ + provider: "gdrive", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + parentFolderId: "folder-xyz", + }); + expect(result).toEqual({ experimentId: "exp-2" }); + }); + + it("omits parentFolderId when it is falsy (empty string)", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-3" }), + }); + + await createProviderExperiment("gdrive", "My Experiment", ""); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.parentFolderId).toBeUndefined(); + }); + + it("throws when the user is not authenticated", async () => { + auth.currentUser = null; + + await expect( + createProviderExperiment("gdrive", "My Experiment") + ).rejects.toThrow("User not authenticated"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("throws the server error message when the request fails", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Forbidden" }), + }); + + await expect( + createProviderExperiment("gdrive", "My Experiment") + ).rejects.toThrow("Forbidden"); + }); +}); diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js new file mode 100644 index 0000000..f96ad0c --- /dev/null +++ b/__tests__/google-picker.test.js @@ -0,0 +1,91 @@ +import { pickDriveFolder } from "../lib/google-picker"; + +// Flushes the microtask queue (all chained Promise .then callbacks that were +// already scheduled) without needing to know exactly how many hops deep the +// module's internal promise chain is. +function flushMicrotasks() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("pickDriveFolder", () => { + beforeEach(() => { + document.body.innerHTML = ""; + delete window.gapi; + delete window.google; + }); + + it("injects the Google API script only once across multiple calls, and resolves/cancels via the picker callback", async () => { + const appendChildSpy = jest.spyOn(document.body, "appendChild"); + + let capturedCallback; + const fakePicker = { setVisible: jest.fn() }; + const fakeGoogle = { + picker: { + ViewId: { FOLDERS: "folders" }, + Action: { PICKED: "picked", CANCEL: "cancel" }, + DocsView: jest.fn().mockImplementation(() => ({ + setMimeTypes: jest.fn().mockReturnThis(), + setSelectFolderEnabled: jest.fn().mockReturnThis(), + setIncludeFolders: jest.fn().mockReturnThis(), + })), + PickerBuilder: jest.fn().mockImplementation(() => ({ + addView: jest.fn().mockReturnThis(), + setOAuthToken: jest.fn().mockReturnThis(), + setDeveloperKey: jest.fn().mockReturnThis(), + setAppId: jest.fn().mockReturnThis(), + setCallback: jest.fn(function (cb) { + capturedCallback = cb; + return this; + }), + build: jest.fn(() => fakePicker), + })), + }, + }; + const fakeGapi = { + load: jest.fn((_name, { callback }) => callback()), + }; + + // Simulate the real <script src="apis.google.com/js/api.js"> tag: once + // it's appended to the DOM, it would set window.gapi and fire "load". + appendChildSpy.mockImplementation((node) => { + if (node.tagName === "SCRIPT") { + window.gapi = fakeGapi; + window.google = fakeGoogle; + node.dispatchEvent(new Event("load")); + } + return node; + }); + + // First call: user cancels the picker. + const firstPick = pickDriveFolder({ + accessToken: "token-1", + apiKey: "key", + appId: "app-id", + }); + await flushMicrotasks(); + expect(typeof capturedCallback).toBe("function"); + capturedCallback({ action: fakeGoogle.picker.Action.CANCEL }); + await expect(firstPick).resolves.toBeNull(); + + // Second call: user picks a folder. + const secondPick = pickDriveFolder({ + accessToken: "token-2", + apiKey: "key", + appId: "app-id", + }); + await flushMicrotasks(); + capturedCallback({ + action: fakeGoogle.picker.Action.PICKED, + docs: [{ id: "folder-1", name: "My Folder" }], + }); + await expect(secondPick).resolves.toEqual({ + id: "folder-1", + name: "My Folder", + }); + + const scriptAppends = appendChildSpy.mock.calls.filter( + ([node]) => node.tagName === "SCRIPT" + ); + expect(scriptAppends).toHaveLength(1); + }); +}); diff --git a/lib/experiment-creation.js b/lib/experiment-creation.js index e655102..4fc22df 100644 --- a/lib/experiment-creation.js +++ b/lib/experiment-creation.js @@ -161,7 +161,7 @@ export async function createExperimentDocument(experimentData) { // decrypted provider token that only resolve-token.ts can produce. This // helper just calls that endpoint and normalizes the response shape to // match createExperiment()'s { experimentId } contract. -export async function createProviderExperiment(provider, title) { +export async function createProviderExperiment(provider, title, parentFolderId) { const user = auth.currentUser; if (!user) { throw new Error("User not authenticated"); @@ -179,6 +179,7 @@ export async function createProviderExperiment(provider, title) { title, uid: user.uid, idToken, + ...(parentFolderId ? { parentFolderId } : {}), }), }); diff --git a/lib/google-picker.js b/lib/google-picker.js new file mode 100644 index 0000000..b5ed252 --- /dev/null +++ b/lib/google-picker.js @@ -0,0 +1,133 @@ +// Encapsulates all Google Picker / gapi interaction so that React +// components stay thin and the Google-specific browser API surface is +// isolated to a single module. No secrets live here -- the apiKey/appId +// are passed in by the caller (sourced from env vars). + +const PICKER_SCRIPT_SRC = "https://apis.google.com/js/api.js"; + +let gapiLoadPromise = null; +let pickerLoadPromise = null; + +function loadGapiScript() { + if (typeof window === "undefined") { + return Promise.reject(new Error("Google Picker is only available in the browser")); + } + + if (window.gapi) { + return Promise.resolve(window.gapi); + } + + if (gapiLoadPromise) { + return gapiLoadPromise; + } + + gapiLoadPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${PICKER_SCRIPT_SRC}"]` + ); + + const handleLoad = () => { + if (window.gapi) { + resolve(window.gapi); + } else { + reject(new Error("Failed to load Google API script")); + } + }; + + if (existingScript) { + // Script tag is already present (e.g. injected by a previous call + // that hasn't finished loading yet) -- just wait for it. + existingScript.addEventListener("load", handleLoad); + existingScript.addEventListener("error", () => + reject(new Error("Failed to load Google API script")) + ); + return; + } + + const script = document.createElement("script"); + script.src = PICKER_SCRIPT_SRC; + script.async = true; + script.defer = true; + script.addEventListener("load", handleLoad); + script.addEventListener("error", () => + reject(new Error("Failed to load Google API script")) + ); + document.body.appendChild(script); + }); + + return gapiLoadPromise; +} + +function loadPicker() { + if (pickerLoadPromise) { + return pickerLoadPromise; + } + + pickerLoadPromise = loadGapiScript().then( + (gapi) => + new Promise((resolve, reject) => { + if (gapi.picker) { + resolve(gapi); + return; + } + gapi.load("picker", { + callback: () => resolve(gapi), + onerror: () => reject(new Error("Failed to load Google Picker API")), + }); + }) + ); + + return pickerLoadPromise; +} + +/** + * Opens the Google Picker configured for selecting a single Drive folder. + * + * @param {Object} options + * @param {string} options.accessToken - short-lived drive.file OAuth token + * @param {string} options.apiKey - Google API developer key + * @param {string} options.appId - Google Cloud project number, ties the + * drive.file grant to our app so the folder is writable server-side later + * @returns {Promise<{id: string, name: string} | null>} resolves with the + * chosen folder's id/name, or null if the user cancelled the picker. + */ +export async function pickDriveFolder({ accessToken, apiKey, appId }) { + await loadPicker(); + const google = window.google; + + if (!google || !google.picker) { + throw new Error("Google Picker failed to initialize"); + } + + return new Promise((resolve, reject) => { + try { + const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) + .setMimeTypes("application/vnd.google-apps.folder") + .setSelectFolderEnabled(true) + .setIncludeFolders(true); + + const picker = new google.picker.PickerBuilder() + .addView(view) + .setOAuthToken(accessToken) + .setDeveloperKey(apiKey) + .setAppId(appId) + .setCallback((data) => { + if (data.action === google.picker.Action.PICKED) { + const doc = data.docs && data.docs[0]; + if (doc) { + resolve({ id: doc.id, name: doc.name }); + } else { + resolve(null); + } + } else if (data.action === google.picker.Action.CANCEL) { + resolve(null); + } + }) + .build(); + + picker.setVisible(true); + } catch (err) { + reject(err); + } + }); +} diff --git a/pages/admin/new.js b/pages/admin/new.js index 1baabe7..5caf100 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -7,6 +7,7 @@ import { useDocumentData } from "react-firebase-hooks/firestore"; import Link from "next/link"; import Router from "next/router"; import { createExperiment, createProviderExperiment } from "../../lib/experiment-creation"; +import { pickDriveFolder } from "../../lib/google-picker"; import { STORAGE_PROVIDERS } from "../../lib/provider-config"; import { Button, @@ -48,6 +49,8 @@ function NewExperimentForm() { const [gdriveTitleError, setGdriveTitleError] = useState(false); const [gdriveSubmitting, setGdriveSubmitting] = useState(false); const [gdriveError, setGdriveError] = useState(null); + const [selectedFolder, setSelectedFolder] = useState(null); + const [folderPickerLoading, setFolderPickerLoading] = useState(false); const [data, loading, error] = useDocumentData(doc(db, "users", user.uid)); @@ -104,7 +107,11 @@ function NewExperimentForm() { } try { - const result = await createProviderExperiment("gdrive", gdriveTitle); + const result = await createProviderExperiment( + "gdrive", + gdriveTitle, + selectedFolder?.id + ); Router.push(`/admin/${result.experimentId}`); } catch (err) { console.error(err); @@ -113,6 +120,56 @@ function NewExperimentForm() { } }; + const handleChooseFolder = async () => { + setFolderPickerLoading(true); + setGdriveError(null); + + try { + const user = auth.currentUser; + if (!user) { + throw new Error("User not authenticated"); + } + const idToken = await user.getIdToken(); + + const response = await fetch("/api/getprovideraccesstoken", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "gdrive", + uid: user.uid, + idToken, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error( + data.error || `Failed to get Google Drive access: ${response.status}` + ); + } + + const folder = await pickDriveFolder({ + accessToken: data.accessToken, + apiKey: process.env.NEXT_PUBLIC_GOOGLE_PICKER_API_KEY, + appId: process.env.NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER, + }); + + if (folder) { + setSelectedFolder(folder); + } + } catch (err) { + console.error(err); + setGdriveError(err.message); + } finally { + setFolderPickerLoading(false); + } + }; + + const handleClearFolder = () => { + setSelectedFolder(null); + }; + return ( <> {loading && <Spinner color="brandTeal.500" size={"xl"} />} @@ -272,6 +329,36 @@ function NewExperimentForm() { This field is required </Field.ErrorText> </Field.Root> + <Field.Root> + <Field.Label>Parent Drive Folder (optional)</Field.Label> + <HStack gap={3}> + <Button + variant="outline" + size="md" + loading={folderPickerLoading} + onClick={handleChooseFolder} + > + Choose Drive folder + </Button> + {selectedFolder && ( + <HStack gap={2}> + <Text fontSize="sm">{selectedFolder.name}</Text> + <Button + variant="ghost" + size="xs" + onClick={handleClearFolder} + > + Clear + </Button> + </HStack> + )} + </HStack> + <Field.HelperText color="gray"> + {selectedFolder + ? "The experiment's data folder will be created inside this folder." + : "If not set, the experiment's data folder will be created in My Drive/DataPipe."} + </Field.HelperText> + </Field.Root> <Button onClick={handleGdriveSubmit} loading={gdriveSubmitting} From 947d9ce4598d6577160eb4e2282cb4eeaf5ecd7c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 14:30:24 -0400 Subject: [PATCH 043/181] fix: make Drive folder picker navigable with breadcrumb path ViewId.FOLDERS renders every folder in one flat list with no hierarchy. Switch to a DOCS view rooted at My Drive (setParent "root") with folder- only mimeTypes, which gives a navigable tree + breadcrumb path so the user can see where a folder lives and drill down, while still only seeing and selecting folders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- __tests__/google-picker.test.js | 3 ++- lib/google-picker.js | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js index f96ad0c..0a70f9b 100644 --- a/__tests__/google-picker.test.js +++ b/__tests__/google-picker.test.js @@ -21,9 +21,10 @@ describe("pickDriveFolder", () => { const fakePicker = { setVisible: jest.fn() }; const fakeGoogle = { picker: { - ViewId: { FOLDERS: "folders" }, + ViewId: { FOLDERS: "folders", DOCS: "docs" }, Action: { PICKED: "picked", CANCEL: "cancel" }, DocsView: jest.fn().mockImplementation(() => ({ + setParent: jest.fn().mockReturnThis(), setMimeTypes: jest.fn().mockReturnThis(), setSelectFolderEnabled: jest.fn().mockReturnThis(), setIncludeFolders: jest.fn().mockReturnThis(), diff --git a/lib/google-picker.js b/lib/google-picker.js index b5ed252..8848341 100644 --- a/lib/google-picker.js +++ b/lib/google-picker.js @@ -101,10 +101,16 @@ export async function pickDriveFolder({ accessToken, apiKey, appId }) { return new Promise((resolve, reject) => { try { - const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) + // A DOCS view rooted at My Drive (setParent "root") gives a navigable + // folder tree with a breadcrumb path -- unlike ViewId.FOLDERS, which + // renders every folder in one flat list with no hierarchy. Restricting + // mimeTypes to folders keeps files out, so the user only sees and picks + // folders while still drilling down through the tree. + const view = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setParent("root") .setMimeTypes("application/vnd.google-apps.folder") - .setSelectFolderEnabled(true) - .setIncludeFolders(true); + .setIncludeFolders(true) + .setSelectFolderEnabled(true); const picker = new google.picker.PickerBuilder() .addView(view) From e7243bd1e5efe3502c272eb6d80c08157e019d9d Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 14:41:46 -0400 Subject: [PATCH 044/181] feat: shared-drive support + picker button contrast for gdrive folders - Picker: add a Shared Drives view (setEnableDrives) alongside the My Drive tree and enable the SUPPORT_DRIVES feature, so folders in shared/team drives are browsable and selectable. - gdrive.ts: pass supportsAllDrives=true on every Drive API call, plus includeItemsFromAllDrives=true on the list/query calls -- without these, a shared-drive folder is invisible to queries and unwritable on the server. Harmless no-ops for My Drive. - new.js: give the "Choose Drive folder" button colorPalette brandTeal so it reads clearly against the background instead of near-invisible gray. Tests updated (picker mock, strict-URL unit assertions). Verified: build clean, 32 unit/front-end + 14 emulator tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- __tests__/google-picker.test.js | 3 +++ functions/src/__tests__/providers-gdrive.test.js | 8 ++++---- functions/src/providers/gdrive.ts | 16 ++++++++++++---- lib/google-picker.js | 14 ++++++++++++-- pages/admin/new.js | 1 + 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js index 0a70f9b..c952c1f 100644 --- a/__tests__/google-picker.test.js +++ b/__tests__/google-picker.test.js @@ -23,14 +23,17 @@ describe("pickDriveFolder", () => { picker: { ViewId: { FOLDERS: "folders", DOCS: "docs" }, Action: { PICKED: "picked", CANCEL: "cancel" }, + Feature: { SUPPORT_DRIVES: "supportDrives" }, DocsView: jest.fn().mockImplementation(() => ({ setParent: jest.fn().mockReturnThis(), setMimeTypes: jest.fn().mockReturnThis(), setSelectFolderEnabled: jest.fn().mockReturnThis(), setIncludeFolders: jest.fn().mockReturnThis(), + setEnableDrives: jest.fn().mockReturnThis(), })), PickerBuilder: jest.fn().mockImplementation(() => ({ addView: jest.fn().mockReturnThis(), + enableFeature: jest.fn().mockReturnThis(), setOAuthToken: jest.fn().mockReturnThis(), setDeveloperKey: jest.fn().mockReturnThis(), setAppId: jest.fn().mockReturnThis(), diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js index 29f0ad2..59906ec 100644 --- a/functions/src/__tests__/providers-gdrive.test.js +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -104,7 +104,7 @@ describe("1. writeSessionFile success", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const { url, options } = callArgs(0); - expect(url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + expect(url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true`); expect(options.method).toBe("POST"); expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); @@ -217,7 +217,7 @@ describe("2. writeSessionFile subfolder", () => { }); const uploadCall = callArgs(2); - expect(uploadCall.url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + expect(uploadCall.url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true`); const uploadBody = uploadCall.options.body.toString(); expect(uploadBody).toContain('"name":"file.csv"'); expect(uploadBody).toContain('"parents":["sub-folder-id"]'); @@ -510,7 +510,7 @@ describe("5. updateFile", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const { url, options } = callArgs(0); - expect(url).toBe(`${API_BASE}/upload/drive/v3/files/gdrive-existing-1?uploadType=media`); + expect(url).toBe(`${API_BASE}/upload/drive/v3/files/gdrive-existing-1?uploadType=media&supportsAllDrives=true`); expect(options.method).toBe("PATCH"); expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); expect(options.body).toBe("updated-data"); @@ -616,7 +616,7 @@ describe("7. downloadFile", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const { url, options } = callArgs(0); - expect(url).toBe(`${API_BASE}/drive/v3/files/gdrive-file-9?alt=media`); + expect(url).toBe(`${API_BASE}/drive/v3/files/gdrive-file-9?alt=media&supportsAllDrives=true`); expect(options.method).toBe("GET"); expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index a863b75..6eee8ce 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -119,6 +119,10 @@ async function findFolder( const url = new URL(`${getApiBase()}/drive/v3/files`); const q = `name='${escapeQueryValue(name)}' and '${parentId}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`; url.searchParams.set("q", q); + // Shared Drives require these on every list/query call, or folders that + // live in a shared drive are invisible to the query. + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("includeItemsFromAllDrives", "true"); const response = await fetch(url.toString(), { method: "GET", @@ -136,7 +140,7 @@ async function findFolder( } async function createFolder(auth: ResolvedAuth, name: string, parentId: string): Promise<string> { - const response = await fetch(`${getApiBase()}/drive/v3/files`, { + const response = await fetch(`${getApiBase()}/drive/v3/files?supportsAllDrives=true`, { method: "POST", headers: { ...authHeaders(auth), @@ -252,7 +256,7 @@ export const gdriveProvider: StorageProvider = { meta.contentType ); - const response = await fetch(`${getApiBase()}/upload/drive/v3/files?uploadType=multipart`, { + const response = await fetch(`${getApiBase()}/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true`, { method: "POST", headers: { ...authHeaders(auth), @@ -288,7 +292,7 @@ export const gdriveProvider: StorageProvider = { meta: FileMeta ): Promise<WriteResult> { const response = await fetch( - `${getApiBase()}/upload/drive/v3/files/${existingFileRef.id}?uploadType=media`, + `${getApiBase()}/upload/drive/v3/files/${existingFileRef.id}?uploadType=media&supportsAllDrives=true`, { method: "PATCH", headers: { @@ -337,6 +341,10 @@ export const gdriveProvider: StorageProvider = { url.searchParams.set("q", q); url.searchParams.set("fields", "nextPageToken,files(id,name,mimeType)"); url.searchParams.set("pageSize", "1000"); + // Shared Drives require these on every list/query call, or folders + // that live in a shared drive are invisible to the query. + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("includeItemsFromAllDrives", "true"); if (pageToken) { url.searchParams.set("pageToken", pageToken); } @@ -378,7 +386,7 @@ export const gdriveProvider: StorageProvider = { _container: ContainerRef, fileRef: FileRef ): Promise<DownloadResult> { - const response = await fetch(`${getApiBase()}/drive/v3/files/${fileRef.id}?alt=media`, { + const response = await fetch(`${getApiBase()}/drive/v3/files/${fileRef.id}?alt=media&supportsAllDrives=true`, { method: "GET", headers: authHeaders(auth), }); diff --git a/lib/google-picker.js b/lib/google-picker.js index 8848341..8299fc4 100644 --- a/lib/google-picker.js +++ b/lib/google-picker.js @@ -106,14 +106,24 @@ export async function pickDriveFolder({ accessToken, apiKey, appId }) { // renders every folder in one flat list with no hierarchy. Restricting // mimeTypes to folders keeps files out, so the user only sees and picks // folders while still drilling down through the tree. - const view = new google.picker.DocsView(google.picker.ViewId.DOCS) + const myDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS) .setParent("root") .setMimeTypes("application/vnd.google-apps.folder") .setIncludeFolders(true) .setSelectFolderEnabled(true); + // A second DOCS view (setEnableDrives) surfaces Shared Drives as their + // own navigable, folder-only tree alongside My Drive above. + const sharedDrivesView = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setEnableDrives(true) + .setMimeTypes("application/vnd.google-apps.folder") + .setIncludeFolders(true) + .setSelectFolderEnabled(true); + const picker = new google.picker.PickerBuilder() - .addView(view) + .addView(myDriveView) + .addView(sharedDrivesView) + .enableFeature(google.picker.Feature.SUPPORT_DRIVES) .setOAuthToken(accessToken) .setDeveloperKey(apiKey) .setAppId(appId) diff --git a/pages/admin/new.js b/pages/admin/new.js index 5caf100..c82ebd4 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -334,6 +334,7 @@ function NewExperimentForm() { <HStack gap={3}> <Button variant="outline" + colorPalette="brandTeal" size="md" loading={folderPickerLoading} onClick={handleChooseFolder} From 0664bd51ba9e6b528080eb9e58c3888d9e96db4f Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 15:37:15 -0400 Subject: [PATCH 045/181] refactor: move token resolution onto the StorageProvider interface resolve-token.ts is now a thin dispatcher that never names a provider: resolveOsfToken/resolveGdriveToken move verbatim onto osfProvider/ gdriveProvider as resolveToken(userData, owner), and the dispatcher delegates through the existing getProviderForExperiment() so the legacy "no storageProvider means OSF" rule stays in exactly one place. The public default export keeps its (user_data, exp_data) signature, so all five callers (api-data, api-base64, scheduled-upload-retry, create-experiment, get-provider-access-token) are untouched. Behavior is preserved for an unregistered provider id: getProvider() throws, but callers distinguish a thrown error (HTTP 500 TOKEN_RESOLUTION_ERROR) from a returned failure (HTTP 400 with a specific message), so the dispatcher catches the lookup and converts it back to the original PROVIDER_NOT_CONNECTED result. Only the lookup is wrapped; resolveToken itself still throws as before. Dispatching through the registry widens resolve-token's import graph to both adapters, which surfaced two pre-existing test-environment fragilities. Fixed in test config only, with no assertion changes: - providers-osf, providers-gdrive, put-file-osf-ref now run under @jest-environment node. Under the project-default jsdom, jose (via firebase-admin/auth) resolves to its ESM-only browser build, which Jest's CJS transform cannot parse; the node environment picks its CJS build. resolve-token-gdrive.test.js has always carried this docblock for the same reason. - resolve-token-gdrive mocks "node-fetch" (ESM-only, no CJS build), matching what the other three adapter suites already do. It never exercises those write paths. Full suite green: 40 suites / 257 tests, unchanged from baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/providers-gdrive.test.js | 12 +++ functions/src/__tests__/providers-osf.test.js | 11 +++ .../src/__tests__/put-file-osf-ref.test.js | 11 +++ .../__tests__/resolve-token-gdrive.test.js | 12 +++ functions/src/providers/gdrive.ts | 28 ++++++ functions/src/providers/osf.ts | 35 ++++++- functions/src/providers/types.ts | 19 ++++ functions/src/resolve-token.ts | 97 ++++--------------- 8 files changed, 145 insertions(+), 80 deletions(-) diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js index 59906ec..30b8692 100644 --- a/functions/src/__tests__/providers-gdrive.test.js +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -1,3 +1,15 @@ +/** + * @jest-environment node + */ + +// Runs in the node environment, not the project-default jsdom: the adapters +// now resolve tokens too (gdrive.ts -> gdrive-oauth.ts, osf.ts -> +// refresh-token.ts, both -> app.js -> firebase-admin/auth -> jwks-rsa -> +// jose). Under jsdom, jose resolves to its ESM-only browser build and Jest's +// CJS transform can't parse it; the node environment picks jose's CJS build. +// Mirrors resolve-token-gdrive.test.js, which has always carried this +// docblock for the same reason. + // RED-phase unit tests for step 4a (docs/provider-migration-design.md, // scratchpad/step4a-gdrive-adapter-spec.md), cases 1-7 of the test plan. // diff --git a/functions/src/__tests__/providers-osf.test.js b/functions/src/__tests__/providers-osf.test.js index 1dd4476..f5cca1f 100644 --- a/functions/src/__tests__/providers-osf.test.js +++ b/functions/src/__tests__/providers-osf.test.js @@ -1,3 +1,14 @@ +/** + * @jest-environment node + */ + +// Runs in the node environment, not the project-default jsdom: osfProvider +// now resolves tokens too (osf.ts -> refresh-token.ts -> app.js -> +// firebase-admin/auth -> jwks-rsa -> jose). Under jsdom, jose resolves to its +// ESM-only browser build and Jest's CJS transform can't parse it; the node +// environment picks jose's CJS build. Mirrors resolve-token-gdrive.test.js, +// which has always carried this docblock for the same reason. + // osfProvider delegates its writes to put-file-osf.js / update-file-osf.js, // which both import their own `fetch` from the "node-fetch" package rather // than using the global fetch. Mocking global.fetch (the pattern used by diff --git a/functions/src/__tests__/put-file-osf-ref.test.js b/functions/src/__tests__/put-file-osf-ref.test.js index c59c295..9fcc31f 100644 --- a/functions/src/__tests__/put-file-osf-ref.test.js +++ b/functions/src/__tests__/put-file-osf-ref.test.js @@ -1,3 +1,14 @@ +/** + * @jest-environment node + */ + +// Runs in the node environment, not the project-default jsdom: osfProvider +// now resolves tokens too (osf.ts -> refresh-token.ts -> app.js -> +// firebase-admin/auth -> jwks-rsa -> jose). Under jsdom, jose resolves to its +// ESM-only browser build and Jest's CJS transform can't parse it; the node +// environment picks jose's CJS build. Mirrors resolve-token-gdrive.test.js, +// which has always carried this docblock for the same reason. + // RED-phase unit tests for step 3b (docs/provider-migration-design.md, // scratchpad/step3b-metadata-ref-spec.md), cases 1-2 of the test plan. // diff --git a/functions/src/__tests__/resolve-token-gdrive.test.js b/functions/src/__tests__/resolve-token-gdrive.test.js index 4803c1c..f124075 100644 --- a/functions/src/__tests__/resolve-token-gdrive.test.js +++ b/functions/src/__tests__/resolve-token-gdrive.test.js @@ -47,6 +47,18 @@ // sibling module's convention, so this file mocks global.fetch rather than // the "node-fetch" module. +// resolveToken now dispatches through the provider registry +// (providers/index.js), which registers the osf and gdrive adapters -- and +// both import their HTTP client from the "node-fetch" package, which is +// ESM-only with no CJS build for Jest's transform to load. This file never +// exercises those write paths (it mocks global.fetch for the token +// endpoint), so node-fetch is mocked out at the module level, exactly as +// providers-osf.test.js / providers-gdrive.test.js already do. +jest.mock("node-fetch", () => ({ + __esModule: true, + default: jest.fn(), +})); + import { initializeApp, getApp } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; import { randomUUID } from "crypto"; diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index 6eee8ce..afaeac2 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -1,4 +1,7 @@ import fetch from "node-fetch"; +import { decrypt } from "../crypto-utils.js"; +import { refreshGdriveToken } from "./gdrive-oauth.js"; +import { UserData } from "../interfaces.js"; import { StorageProvider, ResolvedAuth, @@ -8,6 +11,7 @@ import { WriteResult, DownloadResult, ProviderErrorCode, + TokenResult, } from "./types.js"; // The gdrive container ref shape — only the folderId is meaningful to this @@ -192,6 +196,30 @@ export const gdriveProvider: StorageProvider = { quotaNote: "Free Google accounts share 15 GB across Drive, Gmail, and Photos", }, + async resolveToken(user_data: UserData, owner: string): Promise<TokenResult> { + const gdrive = user_data.connectedAccounts?.gdrive; + + if (!gdrive) { + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Google Drive account for this experiment's owner", + }; + } + + if (gdrive.tokenExpiresAt > Date.now()) { + return { success: true, token: decrypt(gdrive.encryptedToken) }; + } + + const refreshResult = await refreshGdriveToken(owner, gdrive); + + if (!refreshResult.success) { + return { success: false, error: refreshResult.error, detail: refreshResult.detail }; + } + + return { success: true, token: refreshResult.accessToken }; + }, + async createDataContainer(auth: ResolvedAuth, researcherInput: Record<string, unknown>): Promise<ContainerRef> { const name = researcherInput.name as string; const parentId = researcherInput.parentId as string | undefined; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index 1ae6b86..846bd20 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -1,7 +1,9 @@ import fetch from "node-fetch"; import putFileOSF from "../put-file-osf.js"; import updateFileOSF from "../update-file-osf.js"; -import { OSFFile } from "../interfaces.js"; +import { decrypt } from "../crypto-utils.js"; +import { refreshAndUpdateUser } from "../refresh-token.js"; +import { OSFFile, UserData } from "../interfaces.js"; import { StorageProvider, ResolvedAuth, @@ -11,6 +13,7 @@ import { WriteResult, DownloadResult, ProviderErrorCode, + TokenResult, } from "./types.js"; // The OSF container ref shape — only the filesLink is meaningful to this adapter. @@ -19,6 +22,10 @@ export interface OSFContainerRef extends ContainerRef { filesLink: string; } +function hasValidPAT(user_data: UserData): boolean { + return user_data.osfTokenValid && !!user_data.osfToken; +} + function mapStatus(errorCode: number | null): ProviderErrorCode { switch (errorCode) { case 409: @@ -45,6 +52,32 @@ export const osfProvider: StorageProvider = { quotaNote: null, }, + async resolveToken(user_data: UserData, owner: string): Promise<TokenResult> { + if (user_data.usingPersonalToken) { + if (!user_data.osfTokenValid) { + return { success: false, error: "INVALID_OSF_TOKEN", detail: "The OSF token for this experiment is not valid" }; + } + return { success: true, token: decrypt(user_data.osfToken) }; + } + + // OAuth path + if (Date.now() > user_data.authTokenExpires) { + const refreshResult = await refreshAndUpdateUser(owner, decrypt(user_data.refreshToken)); + + if (!refreshResult.success) { + // Fall back to PAT if available + if (hasValidPAT(user_data)) { + return { success: true, token: decrypt(user_data.osfToken) }; + } + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: refreshResult.error || "Refresh token is not valid" }; + } + + return { success: true, token: refreshResult.accessToken! }; + } + + return { success: true, token: decrypt(user_data.authToken) }; + }, + async createDataContainer(): Promise<ContainerRef> { throw new Error("osfProvider.createDataContainer is not implemented"); }, diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index c93caaf..b5e206e 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -2,6 +2,12 @@ // Nothing imports these types yet except the registry; adapters arrive in // later build steps, starting with the OSF refactor. +// interfaces.ts imports StorageProviderId/ContainerRef/FileRef/ +// CollisionCacheState/ConnectedAccounts FROM this module, so a value import +// of UserData here would create a runtime circular dependency. `import type` +// is erased at compile time and is safe. +import type { UserData } from "../interfaces.js"; + export type StorageProviderId = "osf" | "gdrive" | "figshare" | "dataverse"; export type AuthMethod = "oauth2" | "static-token"; @@ -22,6 +28,14 @@ export interface ResolvedAuth { serverUrl?: string; } +// Result of resolving a user's stored credential into a usable token. The +// success variant carries an optional serverUrl for future federated +// providers (Dataverse) and is structurally assignable to ResolvedAuth above, +// so callers can pass it straight into adapter write-path calls. +export type TokenResult = + | { success: true; token: string; serverUrl?: string } + | { success: false; error: string; detail: string }; + // Opaque, provider-shaped reference to the container an experiment writes // into (OSF component, Drive folder, Figshare article, Dataverse dataset). // Only the owning adapter interprets fields beyond `provider`. @@ -96,6 +110,11 @@ export interface StorageProvider { // oauth2 providers only oauth?: OAuthEndpointConfig; + // Decrypts the user's stored credential, checks expiry, and refreshes + + // persists as needed. Failures come back as a TokenResult rather than + // throwing. + resolveToken(userData: UserData, owner: string): Promise<TokenResult>; + // static-token providers only validateStaticToken?(auth: ResolvedAuth): Promise<boolean>; diff --git a/functions/src/resolve-token.ts b/functions/src/resolve-token.ts index 47884cb..7756811 100644 --- a/functions/src/resolve-token.ts +++ b/functions/src/resolve-token.ts @@ -1,92 +1,31 @@ -import { decrypt } from "./crypto-utils.js"; -import { refreshAndUpdateUser } from "./refresh-token.js"; -import { refreshGdriveToken } from "./providers/gdrive-oauth.js"; import { ExperimentData, UserData } from './interfaces'; +import { getProviderForExperiment } from "./providers/index.js"; +import { TokenResult } from "./providers/types.js"; -type TokenResult = { - success: true; - token: string; -} | { - success: false; - error: string; - detail: string; -} - -function hasValidPAT(user_data: UserData): boolean { - return user_data.osfTokenValid && !!user_data.osfToken; -} - -async function resolveOsfToken( - user_data: UserData, - exp_data: ExperimentData, -): Promise<TokenResult> { - if (user_data.usingPersonalToken) { - if (!user_data.osfTokenValid) { - return { success: false, error: "INVALID_OSF_TOKEN", detail: "The OSF token for this experiment is not valid" }; - } - return { success: true, token: decrypt(user_data.osfToken) }; - } +export { TokenResult }; - // OAuth path - if (Date.now() > user_data.authTokenExpires) { - const refreshResult = await refreshAndUpdateUser(exp_data.owner, decrypt(user_data.refreshToken)); - - if (!refreshResult.success) { - // Fall back to PAT if available - if (hasValidPAT(user_data)) { - return { success: true, token: decrypt(user_data.osfToken) }; - } - return { success: false, error: "INVALID_REFRESH_TOKEN", detail: refreshResult.error || "Refresh token is not valid" }; - } - - return { success: true, token: refreshResult.accessToken! }; - } - - return { success: true, token: decrypt(user_data.authToken) }; -} - -async function resolveGdriveToken( +export default async function resolveToken( user_data: UserData, exp_data: ExperimentData, ): Promise<TokenResult> { - const gdrive = user_data.connectedAccounts?.gdrive; - - if (!gdrive) { + // getProviderForExperiment -> getProvider throws for an unregistered/ + // unsupported storageProvider id, but historically an unsupported provider + // here returned a failure result rather than throwing. Callers distinguish + // the two: a returned !success becomes an HTTP 400 with a specific message, + // while a throw is caught elsewhere and becomes an HTTP 500 + // TOKEN_RESOLUTION_ERROR. So the lookup is wrapped to preserve the old + // failure-result behavior; the resolveToken call itself is allowed to + // throw as it always has. + let provider; + try { + ({ provider } = getProviderForExperiment(exp_data)); + } catch { return { success: false, error: "PROVIDER_NOT_CONNECTED", - detail: "No connected Google Drive account for this experiment's owner", + detail: `Unsupported storage provider: ${exp_data.storageProvider}`, }; } - if (gdrive.tokenExpiresAt > Date.now()) { - return { success: true, token: decrypt(gdrive.encryptedToken) }; - } - - const refreshResult = await refreshGdriveToken(exp_data.owner, gdrive); - - if (!refreshResult.success) { - return { success: false, error: refreshResult.error, detail: refreshResult.detail }; - } - - return { success: true, token: refreshResult.accessToken }; -} - -export default async function resolveToken( - user_data: UserData, - exp_data: ExperimentData, -): Promise<TokenResult> { - if (!exp_data.storageProvider || exp_data.storageProvider === "osf") { - return resolveOsfToken(user_data, exp_data); - } - - if (exp_data.storageProvider === "gdrive") { - return resolveGdriveToken(user_data, exp_data); - } - - return { - success: false, - error: "PROVIDER_NOT_CONNECTED", - detail: `Unsupported storage provider: ${exp_data.storageProvider}`, - }; + return provider.resolveToken(user_data, exp_data.owner); } From 313abbf5404f760dfa7cb078082f2b4aa6e2f033 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 15:56:58 -0400 Subject: [PATCH 046/181] refactor: fold OAuth config into the provider registry providers/oauth-config.ts was a second provider table: a CONFIG_FACTORIES map keyed by provider string, parallel to the real registry. Its gdrive entry moves onto gdriveProvider as an oauthConfig() method, and getOAuthConfig() now reads straight off the registry. The file is deleted rather than left as a delegating shim. getOAuthConfig depends on registerProvider() having run, which happens as a side effect of importing providers/index.ts; a shim in the old location would have left that dependency invisible, and an empty registry would surface as a plausible-looking "400 Unknown provider" on every OAuth connect rather than an obvious failure. Exporting from index.ts makes it structural. Behavior is unchanged at all three call sites (connect-provider, generate-oauth-state, get-provider-access-token), which only change their import specifier. Both rejection paths still throw, which those callers rely on as their validation gate for an arbitrary request-body provider string: an unregistered id throws from getProvider, and a registered provider with no oauthConfig (osf) throws explicitly. OSF continues to be rejected by absence rather than by special-casing. Also removes the now-dead OAuthEndpointConfig interface and the unused `oauth?` field on StorageProvider, superseded by oauthConfig(). Full suite green: 40 suites / 257 tests. Note: across ten runs of the suite, two runs each had one unrelated emulator-backed suite fail (metadata-derived-upload, pending-recovery, upload-queue -- a different one each time, all passing in isolation, none touching OAuth config). This matches the emulator contention flake that .github/workflows/node.js.yml already documents and caps maxWorkers for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- functions/src/connect-provider.ts | 2 +- functions/src/generate-oauth-state.ts | 2 +- functions/src/get-provider-access-token.ts | 10 ++--- functions/src/providers/gdrive.ts | 19 ++++++++++ functions/src/providers/index.ts | 24 +++++++++++- functions/src/providers/oauth-config.ts | 44 ---------------------- functions/src/providers/types.ts | 12 ++++-- 7 files changed, 58 insertions(+), 55 deletions(-) delete mode 100644 functions/src/providers/oauth-config.ts diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts index bb57964..e55c7d5 100644 --- a/functions/src/connect-provider.ts +++ b/functions/src/connect-provider.ts @@ -11,7 +11,7 @@ import { onRequest } from "firebase-functions/v2/https"; import { FieldValue } from "firebase-admin/firestore"; import { db, auth } from "./app.js"; import { encrypt } from "./crypto-utils.js"; -import { getOAuthConfig } from "./providers/oauth-config.js"; +import { getOAuthConfig } from "./providers/index.js"; export type AuthCheckResult = | { ok: true } diff --git a/functions/src/generate-oauth-state.ts b/functions/src/generate-oauth-state.ts index 610324f..a043cbf 100644 --- a/functions/src/generate-oauth-state.ts +++ b/functions/src/generate-oauth-state.ts @@ -1,6 +1,6 @@ import { onRequest } from "firebase-functions/v2/https"; import { db } from "./app.js"; -import { getOAuthConfig } from "./providers/oauth-config.js"; +import { getOAuthConfig } from "./providers/index.js"; export const generateOAuthState = onRequest({ cors: true }, async (req, res) => { try { diff --git a/functions/src/get-provider-access-token.ts b/functions/src/get-provider-access-token.ts index d686311..a79c411 100644 --- a/functions/src/get-provider-access-token.ts +++ b/functions/src/get-provider-access-token.ts @@ -14,7 +14,7 @@ import { onRequest } from "firebase-functions/v2/https"; import { db } from "./app.js"; import { verifyOwnership } from "./connect-provider.js"; import resolveToken from "./resolve-token.js"; -import { getOAuthConfig } from "./providers/oauth-config.js"; +import { getOAuthConfig } from "./providers/index.js"; import { StorageProviderId } from "./providers/types.js"; import { ExperimentData, UserData } from "./interfaces.js"; import MESSAGES from "./api-messages.js"; @@ -37,10 +37,10 @@ export const getProviderAccessToken = onRequest({ cors: true }, async (req, res) return; } - // getOAuthConfig only has an entry for OAuth2 providers (gdrive today). - // OSF deliberately has no entry -- its identity flow is a separate, - // legacy path (oauth2-callback.ts) -- so this single check rejects both - // "osf" and any unregistered/unknown provider, same as connect-provider.ts. + // Only OAuth2 providers implement oauthConfig() (gdrive today). OSF + // deliberately does not -- its identity flow is a separate, legacy path + // (oauth2-callback.ts) -- so this single check rejects both "osf" and any + // unregistered/unknown provider, same as connect-provider.ts. try { getOAuthConfig(provider); } catch { diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index afaeac2..2091250 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -12,6 +12,7 @@ import { DownloadResult, ProviderErrorCode, TokenResult, + OAuthConfig, } from "./types.js"; // The gdrive container ref shape — only the folderId is meaningful to this @@ -196,6 +197,24 @@ export const gdriveProvider: StorageProvider = { quotaNote: "Free Google accounts share 15 GB across Drive, Gmail, and Photos", }, + // A method rather than a static object so env vars are read at CALL time, + // not module load -- same reason as getApiBase() above. + oauthConfig(): OAuthConfig { + return { + authorizeUrl: + process.env.GDRIVE_AUTHORIZE_URL || "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token", + clientId: process.env.GDRIVE_CLIENT_ID as string, + clientSecret: process.env.GDRIVE_CLIENT_SECRET as string, + redirectUri: process.env.GDRIVE_REDIRECT_URI as string, + scope: "https://www.googleapis.com/auth/drive.file", + // Without these, Google won't issue a refresh_token on the consent + // grant — a half-connected account (access token, no refresh token) + // is treated as a hard failure downstream. + extraAuthParams: { access_type: "offline", prompt: "consent" }, + }; + }, + async resolveToken(user_data: UserData, owner: string): Promise<TokenResult> { const gdrive = user_data.connectedAccounts?.gdrive; diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index 19ba505..fd885c2 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -1,12 +1,34 @@ import { registerProvider, getProvider } from "./registry.js"; import { osfProvider } from "./osf.js"; import { gdriveProvider } from "./gdrive.js"; -import { StorageProvider, ContainerRef } from "./types.js"; +import { StorageProvider, StorageProviderId, ContainerRef, OAuthConfig } from "./types.js"; import { ExperimentData } from "../interfaces.js"; registerProvider(osfProvider); registerProvider(gdriveProvider); +// OAuth config for the generic storage-GRANT flow +// (docs/provider-migration-design.md, scratchpad/step4b-oauth-connect-spec.md). +// +// Reads straight off the registry above -- there is no second provider table. +// Only 'gdrive' implements oauthConfig() today. 'osf' deliberately does not: +// the OSF identity flow (oauth2-callback.ts) is a separate, untouched legacy +// path with its own env vars (CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). Adding +// figshare means adding an oauthConfig() to its adapter, nothing here. +// +// Both failure modes throw, and callers depend on that: connect-provider.ts, +// generate-oauth-state.ts and get-provider-access-token.ts each wrap this in +// try/catch as their validation gate for an arbitrary request-body `provider` +// string. Returning undefined for either case would let unvalidated input +// through. +export function getOAuthConfig(provider: string): OAuthConfig { + const storageProvider = getProvider(provider as StorageProviderId); + if (!storageProvider.oauthConfig) { + throw new Error(`Provider does not support OAuth2: ${provider}`); + } + return storageProvider.oauthConfig(); +} + export function getProviderForExperiment(exp_data: ExperimentData): { provider: StorageProvider; container: ContainerRef; diff --git a/functions/src/providers/oauth-config.ts b/functions/src/providers/oauth-config.ts deleted file mode 100644 index ee5a1de..0000000 --- a/functions/src/providers/oauth-config.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Provider OAuth registry (docs/provider-migration-design.md, -// scratchpad/step4b-oauth-connect-spec.md). -// -// Only 'gdrive' is registered today. 'osf' deliberately has no entry here — -// the OSF identity flow (oauth2-callback.ts) is a separate, untouched -// legacy path with its own env vars (CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). -// Structured so a provider like figshare is a config addition, not a -// rewrite. - -export interface OAuthConfig { - authorizeUrl: string; - tokenUrl: string; - clientId: string; - clientSecret: string; - redirectUri: string; - scope: string; - extraAuthParams: Record<string, string>; -} - -// Each entry is a factory (not a plain object) so env vars are read at -// CALL time, not module load — mirrors providers/gdrive.ts's getApiBase(). -const CONFIG_FACTORIES: Record<string, () => OAuthConfig> = { - gdrive: () => ({ - authorizeUrl: - process.env.GDRIVE_AUTHORIZE_URL || "https://accounts.google.com/o/oauth2/v2/auth", - tokenUrl: process.env.GDRIVE_TOKEN_URL || "https://oauth2.googleapis.com/token", - clientId: process.env.GDRIVE_CLIENT_ID as string, - clientSecret: process.env.GDRIVE_CLIENT_SECRET as string, - redirectUri: process.env.GDRIVE_REDIRECT_URI as string, - scope: "https://www.googleapis.com/auth/drive.file", - // Without these, Google won't issue a refresh_token on the consent - // grant — a half-connected account (access token, no refresh token) - // is treated as a hard failure downstream. - extraAuthParams: { access_type: "offline", prompt: "consent" }, - }), -}; - -export function getOAuthConfig(provider: string): OAuthConfig { - const factory = CONFIG_FACTORIES[provider]; - if (!factory) { - throw new Error(`Unknown or unsupported OAuth provider: ${provider}`); - } - return factory(); -} diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index b5e206e..967c8ad 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -94,12 +94,14 @@ export interface ProviderCapabilities { quotaNote: string | null; } -export interface OAuthEndpointConfig { +export interface OAuthConfig { authorizeUrl: string; tokenUrl: string; clientId: string; clientSecret: string; + redirectUri: string; scope: string; + extraAuthParams: Record<string, string>; } export interface StorageProvider { @@ -107,8 +109,12 @@ export interface StorageProvider { authMethod: AuthMethod; capabilities: ProviderCapabilities; - // oauth2 providers only - oauth?: OAuthEndpointConfig; + // Optional because only providers on the generic OAuth2 storage-GRANT flow + // have one. OSF deliberately does not -- its OAuth is a separate legacy + // IDENTITY flow (oauth2-callback.ts) with its own env vars -- and that + // absence is precisely what makes getOAuthConfig reject "osf" without + // special-casing it. + oauthConfig?(): OAuthConfig; // Decrypts the user's stored credential, checks expiry, and refreshes + // persists as needed. Failures come back as a TokenResult rather than From 9008f67e593b33136432b2b04d5a82806a77529a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 17:42:35 -0400 Subject: [PATCH 047/181] refactor: move the scheduled refresh pass onto the provider interface scheduled-token-refresh.ts hardcoded an OSF pass followed by a gdrive pass. Each provider's pass now lives on its own adapter as an optional refreshExpiringTokens(), and the scheduled function is a loop over the registry that never names a provider. A provider that omits the method is simply skipped. The window stays provider-owned and the two are NOT interchangeable: OSF's default is 2 weeks against refresh-token expiry (refreshTokenExpires, `<=`), gdrive's is 10 minutes against access-token expiry (tokenExpiresAt, `<`). Both queries, all log strings, the no-refreshToken skip branch and the success/fail tallies move verbatim. Registration order (osf, then gdrive) preserves the historical run order. One deliberate behavior change, commented at the call site: the OSF pass used to be unwrapped, so a hard failure threw out of the scheduled function and the run was marked failed, while the gdrive pass was wrapped and swallowed. A uniform loop has to pick one, so every provider is now wrapped and logged, with no aggregate rethrow. That buys isolation -- one provider's failure can't stop another's pass -- at the cost of a failing OSF pass no longer failing the scheduled run. If we'd rather have the alert, collecting errors and throwing after the loop is a small follow-up. oauth-connect-refresh-emulator.test.js is re-pointed from the removed refreshExpiringGdriveTokens export to gdriveProvider.refreshExpiringTokens with no assertion changes, plus the node-fetch stub the other adapter suites already carry. oauth-connect-scheduled-regression.test.js, which POSTs the scheduled function in the emulator, is untouched and still passes -- it is the regression guard for this change. With this, adding a provider touches only its adapter, one registerProvider line, and the StorageProviderId union: resolve-token, oauth config and scheduled refresh are all closed to modification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../oauth-connect-refresh-emulator.test.js | 16 ++- functions/src/providers/gdrive-oauth.ts | 12 +- functions/src/providers/gdrive.ts | 45 ++++++ functions/src/providers/osf.ts | 65 +++++++++ functions/src/providers/types.ts | 10 ++ functions/src/scheduled-token-refresh.ts | 129 +++--------------- 6 files changed, 159 insertions(+), 118 deletions(-) diff --git a/functions/src/__tests__/oauth-connect-refresh-emulator.test.js b/functions/src/__tests__/oauth-connect-refresh-emulator.test.js index bee1eed..2c625db 100644 --- a/functions/src/__tests__/oauth-connect-refresh-emulator.test.js +++ b/functions/src/__tests__/oauth-connect-refresh-emulator.test.js @@ -29,12 +29,20 @@ import { initializeApp, getApp } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; import { randomUUID } from "crypto"; -import { refreshExpiringGdriveTokens } from "../../lib/scheduled-token-refresh.js"; +import { gdriveProvider } from "../../lib/providers/gdrive.js"; import { decrypt } from "../../lib/crypto-utils.js"; process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; jest.setTimeout(30000); +// gdrive.ts pulls in "node-fetch", which is ESM-only with no CJS build. This +// suite mocks global.fetch directly for the token endpoint (see +// installDiscriminatingFetchMock below) and never exercises gdrive's HTTP +// write paths, so a bare jest.fn() stub is all node-fetch needs here -- +// matches the same mock the other adapter suites already carry (see commit +// 0664bd5). +jest.mock("node-fetch", () => ({ __esModule: true, default: jest.fn() })); + const config = { projectId: "datapipe-test" }; let db; @@ -136,7 +144,7 @@ describe("10. refreshExpiringGdriveTokens", () => { }); const before = Date.now(); - await refreshExpiringGdriveTokens(WINDOW_MS); + await gdriveProvider.refreshExpiringTokens(WINDOW_MS); const after = Date.now(); expect(callsForToken(ownRefreshToken)).toHaveLength(1); @@ -161,7 +169,7 @@ describe("10. refreshExpiringGdriveTokens", () => { json: () => Promise.resolve({ access_token: "should-never-be-used", expires_in: 3600 }), }); - await refreshExpiringGdriveTokens(WINDOW_MS); + await gdriveProvider.refreshExpiringTokens(WINDOW_MS); expect(callsForToken(ownRefreshToken)).toHaveLength(0); const persisted = await getUserData(uid); @@ -182,7 +190,7 @@ describe("10. refreshExpiringGdriveTokens", () => { text: () => Promise.resolve("invalid_grant"), }); - await expect(refreshExpiringGdriveTokens(WINDOW_MS)).resolves.not.toThrow(); + await expect(gdriveProvider.refreshExpiringTokens(WINDOW_MS)).resolves.not.toThrow(); expect(callsForToken(ownRefreshToken)).toHaveLength(1); const persisted = await getUserData(uid); diff --git a/functions/src/providers/gdrive-oauth.ts b/functions/src/providers/gdrive-oauth.ts index 3a80b66..f1e4f20 100644 --- a/functions/src/providers/gdrive-oauth.ts +++ b/functions/src/providers/gdrive-oauth.ts @@ -1,12 +1,12 @@ // Shared gdrive OAuth token-refresh logic (scratchpad/step4b-oauth-connect- -// spec.md). Extracted out of resolve-token.ts so the same refresh+persist -// path can be called both lazily (resolve-token.ts, on-demand when a token -// has expired) and proactively (scheduled-token-refresh.ts's -// refreshExpiringGdriveTokens, run on a schedule ahead of expiry). +// spec.md). Kept separate from gdrive.ts so the same refresh+persist path can +// be called by both of that adapter's entry points: lazily by resolveToken() +// (on-demand, when a token has already expired) and proactively by +// refreshExpiringTokens() (ahead of expiry, from the weekly scheduled pass). // // Uses the runtime's global `fetch`, not the "node-fetch" package — matches -// resolve-token.ts's existing OSF refresh sibling (refresh-token.ts) and is -// pinned by resolve-token-gdrive.test.js, which mocks global.fetch. +// the OSF refresh sibling (refresh-token.ts) and is pinned by +// resolve-token-gdrive.test.js, which mocks global.fetch. import { decrypt, encrypt } from "../crypto-utils.js"; import { db } from "../app.js"; diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index 2091250..f429d67 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -1,5 +1,6 @@ import fetch from "node-fetch"; import { decrypt } from "../crypto-utils.js"; +import { db } from "../app.js"; import { refreshGdriveToken } from "./gdrive-oauth.js"; import { UserData } from "../interfaces.js"; import { @@ -24,6 +25,8 @@ export interface GdriveContainerRef extends ContainerRef { const FOLDER_MIME = "application/vnd.google-apps.folder"; +const GDRIVE_DEFAULT_WINDOW_MS = 10 * 60 * 1000; + // A fixed boundary is fine here — the request body is built and sent in one // shot, never streamed/concatenated across requests, so there's no need for // per-call uniqueness. @@ -239,6 +242,48 @@ export const gdriveProvider: StorageProvider = { return { success: true, token: refreshResult.accessToken }; }, + /** + * Proactively refreshes gdrive access tokens for users whose token expires + * within `windowMs` (default 10 minutes). Mirrors the OSF pass's cadence + * convention, but on a much shorter window since gdrive access tokens are + * short-lived (~1 hour) rather than the ~1-month OSF refresh-token window. + * + * Failures are logged and skipped — a single user's refresh failure must + * never abort the whole pass, and (in 4b) there is no user-visible state + * change on failure: the connection is simply left as-is. + */ + async refreshExpiringTokens(windowMs: number = GDRIVE_DEFAULT_WINDOW_MS): Promise<void> { + const expirationThreshold = Date.now() + windowMs; + + const usersSnapshot = await db + .collection("users") + .where("connectedAccounts.gdrive.tokenExpiresAt", "<", expirationThreshold) + .get(); + + if (usersSnapshot.empty) { + return; + } + + for (const userDoc of usersSnapshot.docs) { + const userData = userDoc.data() as UserData; + const gdrive = userData.connectedAccounts?.gdrive; + const userId = userDoc.id; + + if (!gdrive) { + continue; + } + + try { + const result = await refreshGdriveToken(userId, gdrive); + if (!result.success) { + console.error(`Failed to refresh gdrive token for user ${userId}: ${result.detail}`); + } + } catch (error) { + console.error(`Error refreshing gdrive token for user ${userId}:`, error); + } + } + }, + async createDataContainer(auth: ResolvedAuth, researcherInput: Record<string, unknown>): Promise<ContainerRef> { const name = researcherInput.name as string; const parentId = researcherInput.parentId as string | undefined; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index 846bd20..9daabd0 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -1,6 +1,7 @@ import fetch from "node-fetch"; import putFileOSF from "../put-file-osf.js"; import updateFileOSF from "../update-file-osf.js"; +import { db } from "../app.js"; import { decrypt } from "../crypto-utils.js"; import { refreshAndUpdateUser } from "../refresh-token.js"; import { OSFFile, UserData } from "../interfaces.js"; @@ -22,6 +23,8 @@ export interface OSFContainerRef extends ContainerRef { filesLink: string; } +const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; + function hasValidPAT(user_data: UserData): boolean { return user_data.osfTokenValid && !!user_data.osfToken; } @@ -78,6 +81,68 @@ export const osfProvider: StorageProvider = { return { success: true, token: decrypt(user_data.authToken) }; }, + /** + * Proactively refreshes OAuth tokens for users whose refresh tokens are + * approaching expiration (default 2 weeks). + * + * This prevents token expiration for researchers with active experiments + * who may not have logged in recently. Each successful refresh obtains + * a new refresh token (via rotation), resetting the 1-month expiration window. + */ + async refreshExpiringTokens(windowMs: number = TWO_WEEKS_MS): Promise<void> { + const now = Date.now(); + const expirationThreshold = now + windowMs; + + // Find OAuth users whose refresh tokens expire within the next 2 weeks + // OR have already passed their estimated expiration. We still attempt + // to refresh "expired" tokens because the expiration is our estimate — + // only a failed refresh confirms the token is truly dead. + const usersSnapshot = await db + .collection("users") + .where("usingPersonalToken", "==", false) + .where("refreshTokenExpires", "<=", expirationThreshold) + .get(); + + if (usersSnapshot.empty) { + console.log("No tokens approaching expiration. Nothing to refresh."); + return; + } + + console.log(`Found ${usersSnapshot.size} user(s) with tokens approaching or past estimated expiration.`); + + let successCount = 0; + let failCount = 0; + + for (const userDoc of usersSnapshot.docs) { + const userData = userDoc.data() as UserData; + const userId = userDoc.id; + + if (!userData.refreshToken) { + console.warn(`User ${userId} has no refresh token, skipping.`); + continue; + } + + try { + const result = await refreshAndUpdateUser(userId, decrypt(userData.refreshToken)); + + if (result.success) { + console.log(`Successfully refreshed token for user ${userId}.`); + successCount++; + } else { + console.error(`Failed to refresh token for user ${userId}: ${result.error}`); + failCount++; + } + } catch (error) { + console.error(`Error refreshing token for user ${userId}:`, error); + failCount++; + } + } + + console.log( + `Token refresh complete. Success: ${successCount}, Failed: ${failCount}` + ); + }, + async createDataContainer(): Promise<ContainerRef> { throw new Error("osfProvider.createDataContainer is not implemented"); }, diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 967c8ad..1b98f83 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -121,6 +121,16 @@ export interface StorageProvider { // throwing. resolveToken(userData: UserData, owner: string): Promise<TokenResult>; + // Optional: opt-in proactive refresh, run by the weekly scheduled pass + // (scheduled-token-refresh.ts) ahead of expiry. A provider that omits this + // is simply skipped by that pass. `windowMs` is optional and EACH PROVIDER + // SUPPLIES ITS OWN DEFAULT -- the two existing windows are not + // interchangeable and must never be unified: OSF's is 2 weeks, checked + // against its REFRESH-token expiry (`refreshTokenExpires`), while gdrive's + // is 10 minutes, checked against its ACCESS-token expiry + // (`tokenExpiresAt`). + refreshExpiringTokens?(windowMs?: number): Promise<void>; + // static-token providers only validateStaticToken?(auth: ResolvedAuth): Promise<boolean>; diff --git a/functions/src/scheduled-token-refresh.ts b/functions/src/scheduled-token-refresh.ts index 1cf7afc..e34972a 100644 --- a/functions/src/scheduled-token-refresh.ts +++ b/functions/src/scheduled-token-refresh.ts @@ -1,124 +1,37 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; -import { db } from "./app.js"; -import { refreshAndUpdateUser } from "./refresh-token.js"; -import { refreshGdriveToken } from "./providers/gdrive-oauth.js"; -import { decrypt } from "./crypto-utils.js"; -import { UserData } from "./interfaces.js"; - -const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; -const GDRIVE_DEFAULT_WINDOW_MS = 10 * 60 * 1000; - -/** - * Proactively refreshes gdrive access tokens for users whose token expires - * within `windowMs` (default 10 minutes). Mirrors the OSF pass's cadence - * convention, but on a much shorter window since gdrive access tokens are - * short-lived (~1 hour) rather than the ~1-month OSF refresh-token window. - * - * Failures are logged and skipped — a single user's refresh failure must - * never abort the whole pass, and (in 4b) there is no user-visible state - * change on failure: the connection is simply left as-is. - */ -export async function refreshExpiringGdriveTokens(windowMs: number = GDRIVE_DEFAULT_WINDOW_MS): Promise<void> { - const expirationThreshold = Date.now() + windowMs; - - const usersSnapshot = await db - .collection("users") - .where("connectedAccounts.gdrive.tokenExpiresAt", "<", expirationThreshold) - .get(); - - if (usersSnapshot.empty) { - return; - } - - for (const userDoc of usersSnapshot.docs) { - const userData = userDoc.data() as UserData; - const gdrive = userData.connectedAccounts?.gdrive; - const userId = userDoc.id; - - if (!gdrive) { - continue; - } - - try { - const result = await refreshGdriveToken(userId, gdrive); - if (!result.success) { - console.error(`Failed to refresh gdrive token for user ${userId}: ${result.detail}`); - } - } catch (error) { - console.error(`Error refreshing gdrive token for user ${userId}:`, error); - } - } -} +import { getProvider, listProviders } from "./providers/index.js"; /** * Scheduled function that runs weekly to proactively refresh OAuth tokens - * for users whose refresh tokens are approaching expiration. + * for users whose tokens are approaching expiration. * * This prevents token expiration for researchers with active experiments - * who may not have logged in recently. Each successful refresh obtains - * a new refresh token (via rotation), resetting the 1-month expiration window. + * who may not have logged in recently. Each provider defines its own + * refresh cadence and window via `refreshExpiringTokens` (see + * providers/types.ts); this dispatcher just loops over the registry and + * never names a provider itself. * * Schedule: Every Sunday at 2:00 AM UTC */ export const scheduledTokenRefresh = onSchedule("0 2 * * 0", async () => { - const now = Date.now(); - const expirationThreshold = now + TWO_WEEKS_MS; - - // Find OAuth users whose refresh tokens expire within the next 2 weeks - // OR have already passed their estimated expiration. We still attempt - // to refresh "expired" tokens because the expiration is our estimate — - // only a failed refresh confirms the token is truly dead. - const usersSnapshot = await db - .collection("users") - .where("usingPersonalToken", "==", false) - .where("refreshTokenExpires", "<=", expirationThreshold) - .get(); - - if (usersSnapshot.empty) { - console.log("No tokens approaching expiration. Nothing to refresh."); - return; - } - - console.log(`Found ${usersSnapshot.size} user(s) with tokens approaching or past estimated expiration.`); - - let successCount = 0; - let failCount = 0; - - for (const userDoc of usersSnapshot.docs) { - const userData = userDoc.data() as UserData; - const userId = userDoc.id; - - if (!userData.refreshToken) { - console.warn(`User ${userId} has no refresh token, skipping.`); - continue; - } + // Registration order in providers/index.ts is osf then gdrive, so this + // loop preserves the historical OSF-then-gdrive run order. + for (const id of listProviders()) { + const provider = getProvider(id); + if (!provider.refreshExpiringTokens) continue; try { - const result = await refreshAndUpdateUser(userId, decrypt(userData.refreshToken)); - - if (result.success) { - console.log(`Successfully refreshed token for user ${userId}.`); - successCount++; - } else { - console.error(`Failed to refresh token for user ${userId}: ${result.error}`); - failCount++; - } + await provider.refreshExpiringTokens(); } catch (error) { - console.error(`Error refreshing token for user ${userId}:`, error); - failCount++; + // DELIBERATE BEHAVIOR CHANGE: previously the OSF pass was unwrapped + // (a hard failure, e.g. a Firestore query error, threw out of the + // scheduled function and the run was marked failed), while the + // gdrive pass was wrapped and swallowed. A uniform loop must pick + // one convention -- we wrap every provider, so one provider's + // failure can never prevent another's pass from running. Failures + // are logged rather than thrown, and are not re-thrown in + // aggregate after the loop. + console.error(`Token refresh pass failed for provider ${id}:`, error); } } - - console.log( - `Token refresh complete. Success: ${successCount}, Failed: ${failCount}` - ); - - // gdrive pass runs after the OSF pass, wrapped so a gdrive-side failure - // (e.g. a query error) can never break/roll back the OSF pass above — - // refreshExpiringGdriveTokens itself already isolates per-user failures. - try { - await refreshExpiringGdriveTokens(); - } catch (error) { - console.error("Error during gdrive token refresh pass:", error); - } }); From 419c4b9b2bf01217583919ed09dfdb1ffb022593 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 18:04:15 -0400 Subject: [PATCH 048/181] refactor: thread ResolvedAuth through the provider write path Every call into a StorageProvider method built `{ token }` and dropped the serverUrl that resolveToken already returns. ResolvedAuth has carried an optional serverUrl since the interface was defined, but nothing populated it downstream, so a federated provider had no way to learn which server to address. Dataverse is federated -- Harvard, Borealis, DataverseNL and the rest are separate installations and the URL is per-researcher -- so this had to be closed before that adapter could work at all. Call sites now build one `auth: ResolvedAuth` next to where the token is resolved and pass it through. blockMetadata() and uploadDerivedFiles() take `auth: ResolvedAuth` instead of `token: string`. No behavior change for osf or gdrive: neither sets serverUrl, so it stays undefined and is ignored. Uses that are not provider-method arguments (the direct OSF helpers, log lines, the token handed back by get-provider-access-token) keep the plain string. metadata-derived-upload-emulator.test.js is updated to pass { token: TOKEN } rather than a bare string, matching the new signature -- without it the call still compiled at the JS layer but delivered auth.token === undefined, so the suite would have gone on passing while exercising nothing. Assertions are unchanged. Full suite green: 40 suites / 257 tests, across four runs. A fifth run hit the known shared-bucket race between the two pending-recovery suites (documented in 9008f67); that suite passes in isolation three times over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../metadata-derived-upload-emulator.test.js | 8 ++++++-- functions/src/api-base64.ts | 8 ++++---- functions/src/api-data.ts | 12 ++++++------ functions/src/create-experiment.ts | 6 ++++-- functions/src/metadata-block.ts | 12 ++++++------ functions/src/metadata-derived-upload.ts | 6 +++--- functions/src/scheduled-upload-retry.ts | 10 +++++----- 7 files changed, 34 insertions(+), 28 deletions(-) diff --git a/functions/src/__tests__/metadata-derived-upload-emulator.test.js b/functions/src/__tests__/metadata-derived-upload-emulator.test.js index 275d606..e40cf50 100644 --- a/functions/src/__tests__/metadata-derived-upload-emulator.test.js +++ b/functions/src/__tests__/metadata-derived-upload-emulator.test.js @@ -41,6 +41,10 @@ const db = getFirestore(app); const ROOT = "https://files.osf.io/v1/resources/abc/providers/osfstorage/"; const TOKEN = "test-token"; +// uploadDerivedFiles now takes a ResolvedAuth ({ token, serverUrl? }) rather +// than a bare token string, so that federated providers (Dataverse) can be +// told which server to address. Wrapped here to match that signature. +const AUTH = { token: TOKEN }; const move = (name) => `https://files.osf.io/v1/folder/${name}/`; @@ -97,7 +101,7 @@ describe("uploadDerivedFiles", () => { return fileOk(); }); - await uploadDerivedFiles(files, target, TOKEN); + await uploadDerivedFiles(files, target, AUTH); // Each of the two files under data/ now resolves its own copy of the // "data" folder independently -- one lookup per file, not one shared @@ -122,7 +126,7 @@ describe("uploadDerivedFiles", () => { // file ends up queued for retry rather than lost. mockFetch.mockRejectedValue(new Error("network down")); - await expect(uploadDerivedFiles(files, target, TOKEN)).resolves.toBeUndefined(); + await expect(uploadDerivedFiles(files, target, AUTH)).resolves.toBeUndefined(); const docs = await db.collection("uploadQueue").where("experimentID", "==", experimentID).get(); const queued = docs.docs.map((d) => d.data().filename).sort(); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 90c9fa9..1b5833d 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -9,7 +9,7 @@ import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment } from "./providers/index.js"; -import { WriteResult } from "./providers/types.js"; +import { WriteResult, ResolvedAuth } from "./providers/types.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from './interfaces'; @@ -113,7 +113,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: return; } - const token = tokenResult.token; + const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; const { provider, container } = getProviderForExperiment(exp_data); @@ -124,7 +124,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: let claimResult: Awaited<ReturnType<typeof claimFilename>>; try { claimResult = await claimFilename(experimentID, filename, claimToken, () => - provider.listFiles({ token }, container) + provider.listFiles(auth, container) ); } catch (e) { if (e instanceof CollisionCacheUnavailableError) { @@ -184,7 +184,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: let result: WriteResult; try { result = await provider.writeSessionFile( - { token }, + auth, container, filename, buffer, diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 3852208..a877da1 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -13,7 +13,7 @@ import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment } from "./providers/index.js"; -import { WriteResult } from "./providers/types.js"; +import { WriteResult, ResolvedAuth } from "./providers/types.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData, RequestBody } from './interfaces'; @@ -126,7 +126,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 return; } - const token = tokenResult.token; + const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; //METADATA BLOCK START @@ -140,7 +140,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 //Creates or references a document containing the metadata for the experiment in the metdata collection on Firestore. const metadata_doc_ref: DocumentReference<DocumentData> = db.collection("metadata").doc(experimentID); - const metadataResponse = await blockMetadata(exp_data, token, metadata_doc_ref, data, filename, metadataOptions); + const metadataResponse = await blockMetadata(exp_data, auth, metadata_doc_ref, data, filename, metadataOptions); if (metadataResponse.success === false) { // The pending-data copy is deliberately kept (not cleaned up) here: the @@ -182,7 +182,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 let claimResult: Awaited<ReturnType<typeof claimFilename>>; try { claimResult = await claimFilename(experimentID, filename, claimToken, () => - provider.listFiles({ token }, container) + provider.listFiles(auth, container) ); } catch (e) { if (e instanceof CollisionCacheUnavailableError) { @@ -244,7 +244,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 let result: WriteResult; try { result = await provider.writeSessionFile( - { token }, + auth, container, uploadFilename, data, @@ -332,7 +332,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // The raw data file is safely in OSF; upload the files derived from it // (main data CSV, sidecar CSVs, .psychds-ignore — best-effort: failures are // queued for retry and logged, never failing the submission). - await uploadDerivedFiles(derivedFiles, derivedTarget, token); + await uploadDerivedFiles(derivedFiles, derivedTarget, auth); res.status(201).json({...MESSAGES.SUCCESS, metadataMessage}); }); diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts index fa19714..4db6205 100644 --- a/functions/src/create-experiment.ts +++ b/functions/src/create-experiment.ts @@ -23,7 +23,7 @@ import { db } from "./app.js"; import { verifyOwnership } from "./connect-provider.js"; import resolveToken from "./resolve-token.js"; import { getProvider, listProviders } from "./providers/index.js"; -import { ContainerRef, StorageProviderId } from "./providers/types.js"; +import { ContainerRef, StorageProviderId, ResolvedAuth } from "./providers/types.js"; import { ExperimentData, UserData } from "./interfaces.js"; import MESSAGES from "./api-messages.js"; @@ -115,10 +115,12 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { return; } + const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; + let providerContainer: ContainerRef; try { providerContainer = await storageProvider.createDataContainer( - { token: tokenResult.token }, + auth, { name: title, ...(parentFolderId ? { parentId: parentFolderId } : {}) } ); } catch (e) { diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 5d99a1c..71fae02 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -4,7 +4,7 @@ import produceMetadata from "./metadata-production.js"; import { DocumentReference, DocumentData } from "firebase-admin/firestore"; import { db } from "./app.js"; import { getProviderForExperiment } from "./providers/index.js"; -import { FileRef, ProviderErrorCode } from "./providers/types.js"; +import { FileRef, ProviderErrorCode, ResolvedAuth } from "./providers/types.js"; import buildDerivedFiles, { DerivedFile } from "./metadata-derived-files.js"; import { ExperimentData, Metadata, MetadataResponse } from './interfaces'; @@ -37,7 +37,7 @@ const NON_HEALABLE_CODES: ProviderErrorCode[] = ["AUTH_EXPIRED", "RATE_LIMITED", export default async function blockMetadata( exp_data: ExperimentData, - token: string, // already-resolved provider token (passed by api-data) + auth: ResolvedAuth, // already-resolved provider auth (passed by api-data) metadata_doc_ref: DocumentReference<DocumentData>, data: string, filename: string, @@ -97,7 +97,7 @@ try { // runs again for this experiment. if (metadataFileRef === undefined) { const providerFiles = await provider.listFiles( - { token }, + auth, container ); @@ -115,7 +115,7 @@ try { const serialized = JSON.stringify(payload, null, 2); const response = await provider.writeSessionFile( - { token }, + auth, container, `dataset_description.json`, serialized, @@ -144,7 +144,7 @@ try { // both provider styles identically. async function performUpdate(fileRef: FileRef, serialized: string) { const result = await provider.updateFile( - { token }, + auth, container, fileRef, serialized, @@ -193,7 +193,7 @@ try { metadataMessage = MESSAGES.METADATA_IN_OSF_NOT_IN_FIRESTORE; //Metadata is downloaded from the provider, and is compared to incoming metadata to produce an updated version. - const downloadResult = await provider.downloadFile({ token }, container, metadataFileRef); + const downloadResult = await provider.downloadFile(auth, container, metadataFileRef); if (!downloadResult.success) { throw new Error(`Error downloading metadata file: ${downloadResult.providerMessage}`); diff --git a/functions/src/metadata-derived-upload.ts b/functions/src/metadata-derived-upload.ts index 44620ed..927f264 100644 --- a/functions/src/metadata-derived-upload.ts +++ b/functions/src/metadata-derived-upload.ts @@ -1,5 +1,5 @@ import { getProvider } from "./providers/index.js"; -import { StorageProviderId, ContainerRef } from "./providers/types.js"; +import { StorageProviderId, ContainerRef, ResolvedAuth } from "./providers/types.js"; import queueUpload from "./queue-upload.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; @@ -48,14 +48,14 @@ function contentTypeFor(filename: string): string { export async function uploadDerivedFiles( files: DerivedFile[], target: DerivedUploadTarget, - token: string, + auth: ResolvedAuth, ): Promise<void> { const { provider, container } = resolveProviderAndContainer(target); await Promise.allSettled(files.map(async (file) => { try { const result = await provider.writeSessionFile( - { token }, + auth, container, file.filename, file.content, diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index 45f2111..0fa9022 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -2,7 +2,7 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; import { getProvider } from "./providers/index.js"; -import { ContainerRef, StorageProviderId } from "./providers/types.js"; +import { ContainerRef, StorageProviderId, ResolvedAuth } from "./providers/types.js"; import resolveToken from "./resolve-token.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from "./interfaces.js"; @@ -102,14 +102,14 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho const userData = userDoc.data() as UserData; const expData = expDoc.data() as ExperimentData; - let token: string; + let auth: ResolvedAuth; try { const tokenResult = await resolveToken(userData, expData); if (!tokenResult.success) { await handleRetryFailure(docRef, data, `Token resolution failed: ${tokenResult.error}`); return; } - token = tokenResult.token; + auth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await handleRetryFailure(docRef, data, `Token resolution exception: ${detail}`); @@ -151,7 +151,7 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho let claimResult: Awaited<ReturnType<typeof claimFilename>>; try { claimResult = await claimFilename(data.experimentID, data.filename, data.claimToken, () => - provider.listFiles({ token }, container) + provider.listFiles(auth, container) ); } catch (e) { if (e instanceof CollisionCacheUnavailableError) { @@ -178,7 +178,7 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho // Attempt the upload try { const result = await provider.writeSessionFile( - { token }, + auth, container, data.filename, fileData, From 16a5cd094907c4468023145344f2215f66dc0d37 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 18:18:11 -0400 Subject: [PATCH 049/181] feat: Dataverse storage adapter (backend only, not yet exposed) Adds providers/dataverse.ts implementing StorageProvider, plus 31 unit tests. This is the first static-token provider and the first federated one -- each institution runs its own installation, so serverUrl travels in both ResolvedAuth and the ContainerRef, and an experiment records which server its dataset lives on. The API contract was verified against Dataverse's Java source and its IQSS integration tests rather than the published guides, which are wrong in places. Three findings drove the implementation: - /add returns 200, not the 201 the docs claim (the source returns ok() and DuplicateFilesIT asserts 200). Both are accepted defensively. - Duplicate filenames are SILENTLY RENAMED (README.md -> README-1.md, asserted verbatim in DuplicateFilesIT), so storedFilename is read from the response's `label` and never assumed to match the request. Dataverse cannot produce a NAME_CONFLICT; the Firestore collision cache stays the only duplicate gate. - tabIngest is sent as "false" on every upload. Omitted, it defaults to true and Dataverse rewrites researchers' CSVs into archival .tab files. updateFile is DELETE + re-add, because /api/files/{id}/replace is unavailable on a never-published draft and DataPipe keeps datasets in draft indefinitely. That makes it non-atomic -- the same caveat the design doc already records for Figshare. A failed delete aborts rather than re-adding, so it can never silently duplicate. listFiles re-joins directoryLabel with label. Returning the bare label would have dropped subfolder files to the dataset root on update, and -- worse -- broken collision-cache rehydration, which claims path-prefixed filenames: an unmatched claim lets a duplicate through, and Dataverse renames rather than rejecting it. Pinned by a regression test. Error mapping uses the verified statuses: 401 -> AUTH_EXPIRED (Dataverse returns 401 for permission-denied too, not 403), 403 "dataset lock" -> UNAVAILABLE, 400 size/quota -> QUOTA_EXCEEDED, 429 -> RATE_LIMITED with retryAfter null (no Retry-After exists anywhere in the source). NOT wired to the front end and NOT reachable by researchers: there is no static-token connect path yet, so resolveToken returns PROVIDER_NOT_CONNECTED and no experiment can be created against it. The design doc gates this adapter on a live spike (concurrent-write locking) that needs a demo.dataverse.org account; see the accompanying notes. Full suite green across three runs: 41 suites / 288 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/providers-dataverse.test.js | 761 ++++++++++++++++++ functions/src/api-messages.ts | 4 + functions/src/providers/dataverse.ts | 485 +++++++++++ functions/src/providers/index.ts | 3 + 4 files changed, 1253 insertions(+) create mode 100644 functions/src/__tests__/providers-dataverse.test.js create mode 100644 functions/src/providers/dataverse.ts diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js new file mode 100644 index 0000000..1488fc8 --- /dev/null +++ b/functions/src/__tests__/providers-dataverse.test.js @@ -0,0 +1,761 @@ +/** + * @jest-environment node + */ + +// Runs in the node environment, not the project-default jsdom -- mirrors +// providers-gdrive.test.js's docblock. dataverse.ts does not itself pull in +// app.js/firebase-admin (it has no refreshExpiringTokens pass, so there's no +// db query to make), but it does import crypto-utils.ts and interfaces.ts, +// and the docblock + node-fetch mock convention is kept for consistency with +// every other adapter suite (see commit 0664bd5/313abbf/9008f67). + +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { dataverseProvider } from "../../lib/providers/dataverse.js"; + +const SERVER_URL = "https://dataverse.mock.test"; + +beforeEach(() => { + mockFetch.mockClear(); +}); + +function mockResponse({ status, statusText, jsonBody, textBody }) { + return { + status, + statusText, + json: () => Promise.resolve(jsonBody), + text: () => Promise.resolve(textBody), + }; +} + +// Case-insensitive header lookup -- mirrors providers-gdrive.test.js's +// convention, since the exact casing of the headers object isn't spec'd +// beyond "X-Dataverse-key" style. +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +function callArgs(index = 0) { + const [url, options] = mockFetch.mock.calls[index]; + return { url, options }; +} + +const auth = { token: "test-token", serverUrl: SERVER_URL }; + +describe("1. resolveToken", () => { + it("returns the decrypted token AND serverUrl for a connected account", async () => { + const userData = { + connectedAccounts: { + dataverse: { + authMethod: "static-token", + // decrypt() falls back to plaintext for values without the "v1:" + // prefix, so a plain string round-trips through decrypt() intact + // without needing to set up TOKEN_ENCRYPTION_KEY for this test. + encryptedToken: "plaintext-dataverse-token", + serverUrl: SERVER_URL, + }, + }, + }; + + const result = await dataverseProvider.resolveToken(userData, "owner-uid"); + + expect(result).toEqual({ + success: true, + token: "plaintext-dataverse-token", + serverUrl: SERVER_URL, + }); + }); + + it("returns PROVIDER_NOT_CONNECTED when the owner has no dataverse connection", async () => { + const result = await dataverseProvider.resolveToken({ connectedAccounts: {} }, "owner-uid"); + + expect(result).toEqual({ + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Dataverse account for this experiment's owner", + }); + }); + + it("returns PROVIDER_TOKEN_EXPIRED when tokenExpiresAt has passed", async () => { + const userData = { + connectedAccounts: { + dataverse: { + authMethod: "static-token", + encryptedToken: "plaintext-dataverse-token", + serverUrl: SERVER_URL, + tokenExpiresAt: Date.now() - 1000, + }, + }, + }; + + const result = await dataverseProvider.resolveToken(userData, "owner-uid"); + + expect(result).toEqual({ + success: false, + error: "PROVIDER_TOKEN_EXPIRED", + detail: "The Dataverse API token for this experiment's owner has expired", + }); + }); +}); + +describe("2. writeSessionFile", () => { + it("sends X-Dataverse-key, POSTs to the dataset's /add URL, and includes tabIngest:\"false\"", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + data: { files: [{ label: "data.json", dataFile: { id: 42 } }] }, + }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.writeSessionFile(auth, container, "data.json", '{"a":1}', { + size: 8, + contentType: "application/json", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + + expect(url).toBe(`${SERVER_URL}/api/datasets/7/add`); + expect(options.method).toBe("POST"); + expect(header(options.headers, "X-Dataverse-key")).toBe("test-token"); + + const contentType = header(options.headers, "Content-Type"); + expect(contentType).toMatch(/^multipart\/form-data; boundary=/); + + const body = options.body.toString(); + expect(body).toContain('name="file"; filename="data.json"'); + expect(body).toContain('name="jsonData"'); + expect(body).toContain('"tabIngest":"false"'); + + expect(result).toEqual({ + success: true, + fileRef: { name: "data.json", id: "42" }, + storedFilename: "data.json", + }); + }); + + it("returns storedFilename from the response label when Dataverse silently renamed the file", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + data: { files: [{ label: "data-1.json", dataFile: { id: 99 } }] }, + }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.writeSessionFile(auth, container, "data.json", '{"a":1}', { + size: 8, + contentType: "application/json", + }); + + // The request asked for "data.json" -- the response label is "data-1.json" + // (Dataverse's silent-rename behavior on a duplicate name). storedFilename + // MUST reflect the renamed label, not the requested name. + const requestBody = callArgs(0).options.body.toString(); + expect(requestBody).toContain('filename="data.json"'); + + expect(result).toEqual({ + success: true, + fileRef: { name: "data-1.json", id: "99" }, + storedFilename: "data-1.json", + }); + }); + + it("also treats a 201 response as success (defensive per the docs-vs-source discrepancy)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { status: "OK", data: { files: [{ label: "data.json", dataFile: { id: 1 } }] } }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.writeSessionFile(auth, container, "data.json", "x", { + size: 1, + contentType: "text/plain", + }); + + expect(result.success).toBe(true); + }); + + it("sets directoryLabel and strips the path prefix from the uploaded file name", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + data: { files: [{ label: "abc123.json", directoryLabel: "data/raw", dataFile: { id: 5 } }] }, + }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.writeSessionFile(auth, container, "data/raw/abc123.json", "x", { + size: 1, + contentType: "application/json", + }); + + const body = callArgs(0).options.body.toString(); + expect(body).toContain('filename="abc123.json"'); + expect(body).not.toContain('filename="data/raw/abc123.json"'); + expect(body).toContain('"directoryLabel":"data/raw"'); + + expect(result).toEqual({ + success: true, + fileRef: { name: "abc123.json", id: "5" }, + storedFilename: "abc123.json", + }); + }); + + it("omits directoryLabel entirely when the filename has no path prefix", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { files: [{ label: "flat.json", dataFile: { id: 6 } }] } }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + await dataverseProvider.writeSessionFile(auth, container, "flat.json", "x", { + size: 1, + contentType: "application/json", + }); + + const body = callArgs(0).options.body.toString(); + expect(body).not.toContain("directoryLabel"); + }); +}); + +describe("3. error mapping", () => { + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + + it("maps 401 to AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 401, + statusText: "Unauthorized", + jsonBody: { status: "ERROR", message: "Bad API key" }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Bad API key", + retryAfter: null, + }); + }); + + it("maps a 403 dataset-lock message to UNAVAILABLE", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { status: "ERROR", message: "Dataset cannot be edited due to dataset lock." }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 403, + providerMessage: "Dataset cannot be edited due to dataset lock.", + retryAfter: null, + }); + }); + + it("maps a 400 size-limit message to QUOTA_EXCEEDED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 400, + statusText: "Bad Request", + jsonBody: { status: "ERROR", message: "the file exceeds the size limit of 1000 bytes" }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "QUOTA_EXCEEDED", + providerStatus: 400, + providerMessage: "the file exceeds the size limit of 1000 bytes", + retryAfter: null, + }); + }); + + it("maps a 400 quota message (\"remaining storage quota\") to QUOTA_EXCEEDED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 400, + statusText: "Bad Request", + jsonBody: { status: "ERROR", message: "this file exceeds the remaining storage quota" }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result.error).toBe("QUOTA_EXCEEDED"); + }); + + it("maps 429 to RATE_LIMITED with retryAfter always null (Dataverse never sends Retry-After)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 429, + statusText: "Too Many Requests", + jsonBody: { status: "ERROR", message: "slow down" }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 429, + providerMessage: "slow down", + retryAfter: null, + }); + }); + + it("maps anything else (e.g. 500) to UNAVAILABLE", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 500, statusText: "Internal Server Error", jsonBody: undefined })); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: 500, + providerMessage: "Internal Server Error", + retryAfter: null, + }); + }); + + it("tolerates a non-JSON error body and falls back to statusText", async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + statusText: "Unauthorized", + json: () => Promise.reject(new Error("not json")), + }); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Unauthorized", + retryAfter: null, + }); + }); +}); + +describe("4. updateFile", () => { + it("issues the DELETE then re-adds the file, returning the new WriteResult", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK" } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { files: [{ label: "data.json", dataFile: { id: 55 } }] } }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const existingFileRef = { id: "42", name: "data.json" }; + + const result = await dataverseProvider.updateFile(auth, container, existingFileRef, "new-data", { + size: 8, + contentType: "application/json", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const deleteCall = callArgs(0); + expect(deleteCall.url).toBe(`${SERVER_URL}/api/files/42`); + expect(deleteCall.options.method).toBe("DELETE"); + expect(header(deleteCall.options.headers, "X-Dataverse-key")).toBe("test-token"); + + const addCall = callArgs(1); + expect(addCall.url).toBe(`${SERVER_URL}/api/datasets/7/add`); + expect(addCall.options.method).toBe("POST"); + + expect(result).toEqual({ + success: true, + fileRef: { name: "data.json", id: "55" }, + storedFilename: "data.json", + }); + }); + + it("returns a failure WriteResult (does not proceed to re-add) when the DELETE fails", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 401, statusText: "Unauthorized", jsonBody: { status: "ERROR", message: "Bad API key" } }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const existingFileRef = { id: "42", name: "data.json" }; + + const result = await dataverseProvider.updateFile(auth, container, existingFileRef, "new-data", { + size: 8, + contentType: "application/json", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Bad API key", + retryAfter: null, + }); + }); +}); + +describe("5. listFiles pagination", () => { + it("paginates across two pages and returns the flattened list", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + totalCount: 3, + data: [ + { label: "a.csv", dataFile: { id: 1 } }, + { label: "b.csv", dataFile: { id: 2 } }, + ], + }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + totalCount: 3, + data: [{ label: "c.csv", dataFile: { id: 3 } }], + }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.listFiles(auth, container); + + expect(result).toEqual([ + { name: "a.csv", id: "1" }, + { name: "b.csv", id: "2" }, + { name: "c.csv", id: "3" }, + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const url1 = new URL(callArgs(0).url); + const url2 = new URL(callArgs(1).url); + expect(url1.pathname).toBe("/api/datasets/7/versions/:draft/files"); + expect(url1.searchParams.get("limit")).toBe("1000"); + expect(url1.searchParams.get("offset")).toBe("0"); + expect(url2.searchParams.get("offset")).toBe("2"); + }); + + it("re-joins directoryLabel with label so names round-trip with writeSessionFile's path-prefixed filenames", async () => { + // Regression guard. Returning the bare `label` here would break two + // things: updateFile re-adds under existingFileRef.name and would drop a + // subfolder file back to the dataset root, and the collision cache claims + // PREFIXED filenames -- so rehydration would fail to match an existing + // file and let a duplicate through, which Dataverse silently renames + // rather than rejecting. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + totalCount: 3, + data: [ + { label: "data.json", directoryLabel: "condition-A", dataFile: { id: 11 } }, + // Same label, different directory -- these must stay distinct. + { label: "data.json", directoryLabel: "condition-B", dataFile: { id: 12 } }, + // No directoryLabel at all -- stays bare, no leading slash. + { label: "dataset_description.json", dataFile: { id: 13 } }, + ], + }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.listFiles(auth, container); + + expect(result).toEqual([ + { name: "condition-A/data.json", id: "11" }, + { name: "condition-B/data.json", id: "12" }, + { name: "dataset_description.json", id: "13" }, + ]); + }); + + it("stops when a page comes back empty even if totalCount was never reached (infinite-loop guard)", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", totalCount: 100, data: [] }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.listFiles(auth, container); + + expect(result).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws (never returns a partial/empty list) when the listing request fails", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 401, statusText: "Unauthorized", jsonBody: { status: "ERROR", message: "Bad API key" } }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + + await expect(dataverseProvider.listFiles(auth, container)).rejects.toThrow(/listing failed/i); + }); +}); + +describe("6. downloadFile", () => { + it("GETs the access/datafile endpoint and returns the body text as content", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "file body text" })); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.downloadFile(auth, container, { id: "42", name: "data.json" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${SERVER_URL}/api/access/datafile/42`); + expect(options.method).toBe("GET"); + expect(header(options.headers, "X-Dataverse-key")).toBe("test-token"); + + expect(result).toEqual({ success: true, content: "file body text" }); + }); + + it("maps a 401 on download the same way as writes", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 401, statusText: "Unauthorized", jsonBody: { status: "ERROR", message: "Bad API key" } }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.downloadFile(auth, container, { id: "42", name: "data.json" }); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 401, + providerMessage: "Bad API key", + }); + }); +}); + +describe("7. createDataContainer", () => { + it("posts the citation metadata block and returns datasetId/persistentId/serverUrl", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { id: 123, persistentId: "doi:10.5072/FK2/ABC123" } }, + }) + ); + + const result = await dataverseProvider.createDataContainer(auth, { + collectionAlias: "my-collection", + title: "My Study", + authorName: "Ada Lovelace", + contactEmail: "ada@example.com", + description: "A study about things.", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${SERVER_URL}/api/dataverses/my-collection/datasets`); + expect(options.method).toBe("POST"); + expect(header(options.headers, "Content-Type")).toBe("application/json"); + expect(header(options.headers, "X-Dataverse-key")).toBe("test-token"); + + const body = JSON.parse(options.body); + const fields = body.datasetVersion.metadataBlocks.citation.fields; + + const title = fields.find((f) => f.typeName === "title"); + expect(title).toEqual({ typeName: "title", typeClass: "primitive", multiple: false, value: "My Study" }); + + const author = fields.find((f) => f.typeName === "author"); + expect(author.value[0].authorName.value).toBe("Ada Lovelace"); + + const contact = fields.find((f) => f.typeName === "datasetContact"); + expect(contact.value[0].datasetContactEmail.value).toBe("ada@example.com"); + + const desc = fields.find((f) => f.typeName === "dsDescription"); + expect(desc.value[0].dsDescriptionValue.value).toBe("A study about things."); + + const subject = fields.find((f) => f.typeName === "subject"); + expect(subject).toEqual({ + typeName: "subject", + typeClass: "controlledVocabulary", + multiple: true, + value: ["Social Sciences"], + }); + + expect(result).toEqual({ + provider: "dataverse", + datasetId: 123, + persistentId: "doi:10.5072/FK2/ABC123", + serverUrl: SERVER_URL, + }); + }); + + it("uses a caller-supplied subject instead of the default", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { id: 124, persistentId: "doi:10.5072/FK2/DEF456" } }, + }) + ); + + await dataverseProvider.createDataContainer(auth, { + collectionAlias: "my-collection", + title: "My Study", + authorName: "Ada Lovelace", + contactEmail: "ada@example.com", + description: "A study about things.", + subject: "Computer and Information Science", + }); + + const body = JSON.parse(callArgs(0).options.body); + const subject = body.datasetVersion.metadataBlocks.citation.fields.find((f) => f.typeName === "subject"); + expect(subject.value).toEqual(["Computer and Information Science"]); + }); + + it("throws an informative error when dataset creation fails", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 401, + statusText: "Unauthorized", + jsonBody: { status: "ERROR", message: "Bad API key" }, + }) + ); + + await expect( + dataverseProvider.createDataContainer(auth, { + collectionAlias: "my-collection", + title: "My Study", + authorName: "Ada Lovelace", + contactEmail: "ada@example.com", + description: "A study about things.", + }) + ).rejects.toThrow(/dataset creation failed/i); + }); +}); + +describe("8. federated serverUrl resolution", () => { + it("falls back to auth.serverUrl when the container doesn't carry one", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "content" })); + + // Container deliberately omits serverUrl. + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc" }; + const result = await dataverseProvider.downloadFile(auth, container, { id: "42", name: "data.json" }); + + expect(callArgs(0).url).toBe(`${SERVER_URL}/api/access/datafile/42`); + expect(result).toEqual({ success: true, content: "content" }); + }); + + it("prefers the container's serverUrl over auth's when both are present", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", textBody: "content" })); + + const container = { + provider: "dataverse", + datasetId: 7, + persistentId: "doi:10/abc", + serverUrl: "https://institution.example.edu", + }; + await dataverseProvider.downloadFile(auth, container, { id: "42", name: "data.json" }); + + expect(callArgs(0).url).toBe("https://institution.example.edu/api/access/datafile/42"); + }); + + it("throws a clear error when neither the container nor auth carries a serverUrl", async () => { + const authWithoutServer = { token: "test-token" }; + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc" }; + + await expect( + dataverseProvider.downloadFile(authWithoutServer, container, { id: "42", name: "data.json" }) + ).rejects.toThrow(/serverUrl/i); + }); +}); + +describe("9. validateStaticToken", () => { + it("returns true on a 200 response from /api/users/:me", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: {} })); + + const result = await dataverseProvider.validateStaticToken(auth); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${SERVER_URL}/api/users/:me`); + expect(options.method).toBe("GET"); + expect(header(options.headers, "X-Dataverse-key")).toBe("test-token"); + expect(result).toBe(true); + }); + + it("returns false (never throws) on a non-200 response", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 401, statusText: "Unauthorized", jsonBody: { status: "ERROR", message: "Bad API key" } }) + ); + + const result = await dataverseProvider.validateStaticToken(auth); + + expect(result).toBe(false); + }); +}); diff --git a/functions/src/api-messages.ts b/functions/src/api-messages.ts index a822bc0..c8d889a 100644 --- a/functions/src/api-messages.ts +++ b/functions/src/api-messages.ts @@ -43,6 +43,10 @@ const MESSAGES = { error: "PROVIDER_NOT_CONNECTED", message: "The experiment owner has not connected an account for this experiment's storage provider", }, + PROVIDER_TOKEN_EXPIRED: { + error: "PROVIDER_TOKEN_EXPIRED", + message: "The Dataverse API token for this experiment's owner has expired and must be reconnected", + }, INVALID_BASE64_DATA: { error: "INVALID_BASE64_DATA", message: "The data are not valid base64 data", diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts new file mode 100644 index 0000000..08c55ec --- /dev/null +++ b/functions/src/providers/dataverse.ts @@ -0,0 +1,485 @@ +import fetch from "node-fetch"; +import { decrypt } from "../crypto-utils.js"; +import { UserData } from "../interfaces.js"; +import { + StorageProvider, + ResolvedAuth, + ContainerRef, + FileRef, + FileMeta, + WriteResult, + DownloadResult, + ProviderErrorCode, + TokenResult, +} from "./types.js"; + +// The Dataverse container ref shape. Unlike every other provider, Dataverse +// is FEDERATED -- each researcher's dataset lives on whichever installation +// their institution runs (Harvard Dataverse, Borealis, DataverseNL, ...) -- +// so `serverUrl` travels on the container as well as on the connection +// (StaticTokenAccountConnection.serverUrl). Storing it on both makes an +// experiment self-describing even if the owner's connection is later +// reconnected against a different installation. +export interface DataverseContainerRef extends ContainerRef { + provider: "dataverse"; + datasetId: number; // numeric database id + persistentId: string; // DOI, for researcher-facing links + serverUrl: string; // which installation this dataset lives on +} + +// Paginate LIST DRAFT FILES in chunks of this size (see listFiles below). +const LIST_PAGE_LIMIT = 1000; + +// A fixed boundary is fine here — the request body is built and sent in one +// shot, never streamed/concatenated across requests, so there's no need for +// per-call uniqueness. Mirrors gdrive.ts's MULTIPART_BOUNDARY convention. +const MULTIPART_BOUNDARY = "datapipe-dataverse-multipart-boundary"; + +function authHeaders(auth: ResolvedAuth): Record<string, string> { + return { "X-Dataverse-key": auth.token }; +} + +// serverUrl is federated: it can come from the container (an already-created +// dataset knows exactly which installation it lives on) or from auth (the +// researcher's current connection). The container wins when both are present +// since that's the installation the dataset actually lives on; auth is the +// fallback for calls that don't yet have a container (e.g. createDataContainer). +function resolveServerUrl(auth: ResolvedAuth, container?: ContainerRef): string { + const fromContainer = (container as DataverseContainerRef | undefined)?.serverUrl; + const serverUrl = fromContainer ?? auth.serverUrl; + if (!serverUrl) { + throw new Error("Dataverse serverUrl is missing from both the container and the resolved auth"); + } + return serverUrl; +} + +function isSuccessStatus(status: number): boolean { + // The docs say ADD FILE returns 201; the Java source returns ok() (200) + // and the IQSS integration-test suite asserts 200. Accept 200 as the real + // contract and 201 defensively, in case a future version changes it. + return status === 200 || status === 201; +} + +interface MappedDataverseError { + error: ProviderErrorCode; + providerStatus: number; + providerMessage: string; + retryAfter: number | null; +} + +// Shared error-mapping helper — every write/update/list/download call routes +// its non-2xx response through this. Dataverse never yields a duplicate-name +// conflict (NAME_CONFLICT is never returned): duplicate filenames are +// silently renamed by the server (see writeSessionFile), not rejected. +function mapDataverseError( + status: number, + statusText: string, + body: { status?: string; message?: string } | undefined +): MappedDataverseError { + const message = body?.message ?? statusText; + + let error: ProviderErrorCode; + if (status === 401) { + // Both an invalid token AND permission-denied come back as 401 from + // Dataverse -- there is no separate 403-for-permissions case to handle + // here (see the size-limit/lock checks below, which ARE genuine 403/400). + error = "AUTH_EXPIRED"; + } else if (status === 403 && /dataset lock/i.test(message)) { + // Exact server text: "Dataset cannot be edited due to dataset lock." + // This is transient (another edit is in flight) -- UNAVAILABLE routes it + // into the retry queue instead of surfacing a hard failure. + error = "UNAVAILABLE"; + } else if ( + status === 400 && + (/exceeds the size limit/i.test(message) || /exceeds the remaining storage quota/i.test(message)) + ) { + error = "QUOTA_EXCEEDED"; + } else if (status === 429) { + error = "RATE_LIMITED"; + } else { + error = "UNAVAILABLE"; + } + + // There is no Retry-After header anywhere in Dataverse's source (including + // on 429s) -- never invent one. + return { error, providerStatus: status, providerMessage: message, retryAfter: null }; +} + +// Reads the body defensively (Dataverse error bodies are JSON +// {"status":"ERROR","message":"..."}, but tolerate non-JSON) and maps the +// response into the shared error shape. +async function mapErrorResponse(response: { + status: number; + statusText: string; + json: () => Promise<unknown>; +}): Promise<MappedDataverseError> { + let body: { status?: string; message?: string } | undefined; + try { + body = (await response.json()) as { status?: string; message?: string }; + } catch { + body = undefined; + } + return mapDataverseError(response.status, response.statusText, body); +} + +// Hand-built multipart/form-data body with exactly two parts, field names +// "file" and "jsonData" -- mirrors gdrive.ts's buildMultipartBody convention. +function buildMultipartBody( + jsonData: object, + filename: string, + data: string | Buffer, + contentType: string +): Buffer { + const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + const preamble = + `--${MULTIPART_BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n`; + const middle = + `\r\n--${MULTIPART_BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="jsonData"\r\n\r\n` + + `${JSON.stringify(jsonData)}\r\n`; + const epilogue = `--${MULTIPART_BOUNDARY}--`; + + return Buffer.concat([Buffer.from(preamble), dataBuffer, Buffer.from(middle), Buffer.from(epilogue)]); +} + +interface AddFileResponseBody { + status?: string; + data?: { + files?: { + label?: string; + directoryLabel?: string; + dataFile?: { id?: number; filename?: string; contentType?: string; filesize?: number }; + }[]; + }; +} + +export const dataverseProvider: StorageProvider = { + id: "dataverse", + authMethod: "static-token", + capabilities: { + nativeSubfolders: true, + supportsRegion: false, + // Per-installation and NOT readable through any public Dataverse + // endpoint -- each institution's server sets its own cap, and there is + // no API that surfaces it, so this stays null (descriptive only; never a + // correctness gate -- see types.ts). + maxFileSizeBytes: null, + quotaNote: "File size and storage limits are set by the researcher's hosting Dataverse installation", + }, + + async resolveToken(userData: UserData, _owner: string): Promise<TokenResult> { + // _owner is unused: Dataverse is a static-token provider with no refresh + // token to rotate, so there is no persist-back step the way gdrive's + // resolveToken has (it calls refreshGdriveToken(owner, ...)). + const dataverse = userData.connectedAccounts?.dataverse; + + if (!dataverse) { + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Dataverse account for this experiment's owner", + }; + } + + // Dataverse API tokens expire (commonly yearly, installation-configurable) + // and there is no refresh token to rotate -- unlike gdrive/OSF, an + // expired Dataverse token can only be fixed by the researcher generating + // a fresh one on their installation and reconnecting. + if (dataverse.tokenExpiresAt && dataverse.tokenExpiresAt < Date.now()) { + return { + success: false, + error: "PROVIDER_TOKEN_EXPIRED", + detail: "The Dataverse API token for this experiment's owner has expired", + }; + } + + return { success: true, token: decrypt(dataverse.encryptedToken), serverUrl: dataverse.serverUrl }; + }, + + async validateStaticToken(auth: ResolvedAuth): Promise<boolean> { + const serverUrl = resolveServerUrl(auth); + const response = await fetch(`${serverUrl}/api/users/:me`, { + method: "GET", + headers: authHeaders(auth), + }); + // Never throw on a non-200 -- a bad/expired token is simply "not valid", + // not an exceptional condition. + return response.status === 200; + }, + + async createDataContainer(auth: ResolvedAuth, researcherInput: Record<string, unknown>): Promise<ContainerRef> { + const serverUrl = resolveServerUrl(auth); + const collectionAlias = researcherInput.collectionAlias as string; + const title = researcherInput.title as string; + const authorName = researcherInput.authorName as string; + const contactEmail = researcherInput.contactEmail as string; + const description = researcherInput.description as string; + const subject = (researcherInput.subject as string) ?? "Social Sciences"; + + const body = { + datasetVersion: { + metadataBlocks: { + citation: { + displayName: "Citation Metadata", + fields: [ + { typeName: "title", typeClass: "primitive", multiple: false, value: title }, + { + typeName: "author", + typeClass: "compound", + multiple: true, + value: [ + { + authorName: { + typeName: "authorName", + typeClass: "primitive", + multiple: false, + value: authorName, + }, + }, + ], + }, + { + typeName: "datasetContact", + typeClass: "compound", + multiple: true, + value: [ + { + datasetContactEmail: { + typeName: "datasetContactEmail", + typeClass: "primitive", + multiple: false, + value: contactEmail, + }, + }, + ], + }, + { + typeName: "dsDescription", + typeClass: "compound", + multiple: true, + value: [ + { + dsDescriptionValue: { + typeName: "dsDescriptionValue", + typeClass: "primitive", + multiple: false, + value: description, + }, + }, + ], + }, + { typeName: "subject", typeClass: "controlledVocabulary", multiple: true, value: [subject] }, + ], + }, + }, + }, + }; + + const response = await fetch(`${serverUrl}/api/dataverses/${collectionAlias}/datasets`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + // createDataContainer has no error union in the StorageProvider + // interface (matches osf/gdrive) -- signal failure by throwing, same as + // gdrive's folder-creation failures. + if (response.status !== 200) { + const mapped = await mapErrorResponse(response); + throw new Error(`Dataverse dataset creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const responseBody = (await response.json()) as { data?: { id?: number; persistentId?: string } }; + const datasetId = responseBody.data?.id as number; + const persistentId = responseBody.data?.persistentId as string; + + return { provider: "dataverse", datasetId, persistentId, serverUrl }; + }, + + async writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + meta: FileMeta + ): Promise<WriteResult> { + const dataverseContainer = container as DataverseContainerRef; + const serverUrl = resolveServerUrl(auth, dataverseContainer); + + // A filename may carry a multi-level path prefix (e.g. + // "data/raw/abc123.json"). Dataverse's native subfolder mechanism is a + // single flat `directoryLabel` string, not pre-created nested folders + // like Drive -- so unlike gdrive.ts, there is no findOrCreateFolder walk + // here. The leading segments just join back together with "/". + const segments = filename.split("/"); + const uploadFilename = segments.pop() as string; + const directoryLabel = segments.length > 0 ? segments.join("/") : undefined; + + const jsonData: Record<string, unknown> = { + // Always the STRING "false" -- every official example uses the string, + // and if this is omitted Dataverse defaults to TRUE and silently + // converts CSVs into archival .tab files, mangling researchers' data. + tabIngest: "false", + }; + if (directoryLabel) { + jsonData.directoryLabel = directoryLabel; + } + + const body = buildMultipartBody(jsonData, uploadFilename, data, meta.contentType); + + const response = await fetch(`${serverUrl}/api/datasets/${dataverseContainer.datasetId}/add`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": `multipart/form-data; boundary=${MULTIPART_BOUNDARY}`, + }, + body, + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + const responseBody = (await response.json()) as AddFileResponseBody; + const uploaded = responseBody.data?.files?.[0]; + + // Dataverse SILENTLY RENAMES duplicate filenames (uploading README.md + // twice yields label "README-1.md") -- storedFilename MUST come from the + // response's `label`, never assumed to equal the requested name. This is + // exactly the case WriteResult.storedFilename exists to detect. Dataverse + // can never produce a NAME_CONFLICT, so there's no error path for it. + const storedFilename = uploaded?.label as string; + + return { + success: true, + fileRef: { name: storedFilename, id: String(uploaded?.dataFile?.id) }, + storedFilename, + }; + }, + + // Implemented as DELETE then re-add, because Dataverse's + // `/api/files/{id}/replace` endpoint is NOT available on a never-published + // draft dataset, and DataPipe deliberately keeps datasets in draft + // indefinitely (docs/provider-migration-design.md). This is therefore + // NON-ATOMIC -- there is a brief window where the file does not exist -- + // the same caveat the design doc already documents for Figshare's + // updateFile (delete + re-upload there too). If the delete fails, this + // returns a failure WriteResult rather than proceeding to re-add, so a + // failed delete can never silently duplicate the file under a new id. + async updateFile( + auth: ResolvedAuth, + container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + meta: FileMeta + ): Promise<WriteResult> { + const serverUrl = resolveServerUrl(auth, container as DataverseContainerRef); + + const deleteResponse = await fetch(`${serverUrl}/api/files/${existingFileRef.id}`, { + method: "DELETE", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(deleteResponse.status)) { + const mapped = await mapErrorResponse(deleteResponse); + return { success: false, ...mapped }; + } + + return dataverseProvider.writeSessionFile(auth, container, existingFileRef.name, data, meta); + }, + + async listFiles(auth: ResolvedAuth, container: ContainerRef): Promise<FileRef[]> { + const dataverseContainer = container as DataverseContainerRef; + const serverUrl = resolveServerUrl(auth, dataverseContainer); + + // Paginate internally (the StorageProvider interface requires a full + // listing): page with a limit of 1000, looping until every result has + // been collected. Guards against an infinite loop if totalCount is + // missing or a page comes back empty before totalCount is reached. + const results: FileRef[] = []; + let offset = 0; + let totalCount: number | undefined; + + while (totalCount === undefined || results.length < totalCount) { + const url = new URL(`${serverUrl}/api/datasets/${dataverseContainer.datasetId}/versions/:draft/files`); + url.searchParams.set("limit", String(LIST_PAGE_LIMIT)); + url.searchParams.set("offset", String(offset)); + + const response = await fetch(url.toString(), { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Dataverse listing failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const body = (await response.json()) as { + totalCount?: number; + data?: { label?: string; directoryLabel?: string; dataFile?: { id?: number } }[]; + }; + totalCount = body.totalCount; + + const page = body.data || []; + if (page.length === 0) { + // Guard against an infinite loop when totalCount is missing/wrong + // and the server has nothing left to give us. + break; + } + + for (const file of page) { + // Re-join directoryLabel with label so `name` round-trips with the + // path-prefixed filename writeSessionFile takes ("data/raw/x.json" + // -> directoryLabel "data/raw" + label "x.json"). Returning the bare + // label would break two things: updateFile re-adds under + // existingFileRef.name and would drop the file back to the dataset + // root, and the collision cache claims prefixed filenames, so + // rehydration would fail to match an existing file and let a + // duplicate through -- which Dataverse then silently renames rather + // than rejecting. Two files sharing a label in different directories + // would also collapse together here. + const name = file.directoryLabel + ? `${file.directoryLabel}/${file.label}` + : (file.label as string); + results.push({ name, id: String(file.dataFile?.id) }); + } + + offset += page.length; + } + + return results; + }, + + async downloadFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise<DownloadResult> { + const serverUrl = resolveServerUrl(auth, container as DataverseContainerRef); + + const response = await fetch(`${serverUrl}/api/access/datafile/${fileRef.id}`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { + success: false, + error: mapped.error, + providerStatus: mapped.providerStatus, + providerMessage: mapped.providerMessage, + }; + } + + const content = await response.text(); + return { success: true, content }; + }, +}; diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index fd885c2..5fcd277 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -1,11 +1,13 @@ import { registerProvider, getProvider } from "./registry.js"; import { osfProvider } from "./osf.js"; import { gdriveProvider } from "./gdrive.js"; +import { dataverseProvider } from "./dataverse.js"; import { StorageProvider, StorageProviderId, ContainerRef, OAuthConfig } from "./types.js"; import { ExperimentData } from "../interfaces.js"; registerProvider(osfProvider); registerProvider(gdriveProvider); +registerProvider(dataverseProvider); // OAuth config for the generic storage-GRANT flow // (docs/provider-migration-design.md, scratchpad/step4b-oauth-connect-spec.md). @@ -51,4 +53,5 @@ export function getProviderForExperiment(exp_data: ExperimentData): { export { registerProvider, getProvider, listProviders } from "./registry.js"; export { osfProvider } from "./osf.js"; export { gdriveProvider } from "./gdrive.js"; +export { dataverseProvider } from "./dataverse.js"; export * from "./types.js"; From 25f1e9dd7311cc611e4c8f4f027b6f5f806d6ed2 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 18:37:51 -0400 Subject: [PATCH 050/181] feat: static-token connect path, with an SSRF gate on serverUrl Dataverse could not be connected or disconnected: connectProvider is OAuth-only (it requires code + state and exchanges an authorization code) and disconnectProvider gated on getOAuthConfig, which throws for any static-token provider. Adds connectStaticTokenProvider as a separate endpoint rather than a branch inside connectProvider -- the two flows share almost nothing, since a pasted token has no third-party redirect and therefore no CSRF state to validate. It requires a registered provider whose authMethod is 'static-token', verifies ownership, validates the credential through the adapter's validateStaticToken, and persists with the same dot-path set()+mergeFields convention so sibling connections are untouched. disconnectProvider now accepts any registered non-osf provider. SECURITY: serverUrl is fully user-supplied and the backend makes authenticated requests to it, with downloadFile returning the body to the caller -- an unconstrained SSRF primitive against the cloud metadata service and any internal address. isAllowedServerUrl requires https, no embedded credentials, no port other than 443, a dotted hostname, and rejects localhost/.localhost/.internal/metadata.google.internal, IPv6 literals, and every IPv4-shaped literal (the shape check also covers octal/decimal shorthand like 0177.0.0.1 and 2130706433). The stored value is normalized to url.origin so no path or trailing slash survives. The hostname is normalized by stripping one trailing dot before those checks. Without it, https://metadata.google.internal./ matched neither the equality check nor endsWith(".internal") yet still satisfied the dotted-hostname rule, so the explicit-root FQDN form walked through every rule while resolving identically in DNS. Three trailing-dot cases are pinned in the tests. This is defense in depth, not complete SSRF protection: a DNS name can still resolve to an internal address, and the comment says so. The new suite drives the handlers in-process rather than through the Functions emulator's HTTP listener, because the SSRF gate rejects every address a same-machine mock could bind to (localhost, any IP literal, any non-443 port) -- there is no URL that both passes the gate and reaches a mock we control. Real production code paths and real Firestore/Auth emulators are still exercised; only the outbound transport is mocked. Full suite green: 42 suites / 310 tests, 2 of 3 runs. The third hit the known shared-bucket race between the pending-recovery suites (9008f67), which passes in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../connect-static-token-emulator.test.js | 324 ++++++++++++++++++ functions/src/connect-provider.ts | 153 ++++++++- functions/src/index.ts | 3 +- 3 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 functions/src/__tests__/connect-static-token-emulator.test.js diff --git a/functions/src/__tests__/connect-static-token-emulator.test.js b/functions/src/__tests__/connect-static-token-emulator.test.js new file mode 100644 index 0000000..1e57154 --- /dev/null +++ b/functions/src/__tests__/connect-static-token-emulator.test.js @@ -0,0 +1,324 @@ +/** + * @jest-environment node + */ + +// Tests for connectStaticTokenProvider (functions/src/connect-provider.ts) -- +// the new static-token connect path that lets a Dataverse account actually be +// connected -- and for the disconnectProvider fix that lets a static-token +// connection be removed again (its old getOAuthConfig gate threw for +// static-token providers, so Dataverse could be neither connected nor +// disconnected before this change). +// +// UNLIKE oauth-connect-emulator.test.js, this suite does NOT drive the +// endpoints through the separate spawned Functions-emulator process's own +// HTTP listener. That pattern relies on an env var the Functions-emulator +// process reads at ITS OWN startup (GDRIVE_API_BASE/GDRIVE_TOKEN_URL) to +// redirect gdrive's OAuth calls to a local mock server. Dataverse has no such +// override, by design: serverUrl is genuinely PER-CONNECTION, researcher- +// supplied request-body input (that's the whole point of a federated +// provider), not a fixed provider constant -- and isAllowedServerUrl (the +// SSRF gate this endpoint enforces) rejects every address a same-machine +// mock server could actually bind to (localhost, 127.0.0.1, any port but +// 443, any bare IP literal). There is no URL we could hand the spawned +// emulator process that both (a) passes the SSRF gate and (b) resolves to a +// mock server we control, short of editing /etc/hosts or binding the +// privileged port 443 -- both out of scope for a test. +// +// So instead: this suite loads the compiled connectStaticTokenProvider / +// disconnectProvider / isAllowedServerUrl directly, in-process (the +// build-step instructions explicitly sanction jest.mock("node-fetch") in the +// test file for exactly this kind of problem), and drives the handlers +// through a throwaway local Express server bound to an OS-assigned port. +// Express is needed -- rather than a bare http.Server -- because the +// Firebase Functions Framework normally supplies req.body parsing and the +// res.status()/.json() helpers the handler calls; neither exists on a plain +// http.ServerResponse. This still exercises the REAL production code path, +// including the real dataverseProvider.validateStaticToken, against the REAL +// Firestore/Auth emulators (FIRESTORE_EMULATOR_HOST, idTokens signed by the +// Auth emulator -- same as oauth-connect-emulator.test.js). The only thing +// swapped out is dataverseProvider's own network transport ("node-fetch"); +// its behavior against real Dataverse response shapes is already covered by +// providers-dataverse.test.js. + +const mockFetch = jest.fn(); +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import express from "express"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099"; +jest.setTimeout(30000); + +// Scoped to this process only -- this suite never talks to the separate +// Functions-emulator process, so (unlike oauth-connect-emulator.test.js) it +// doesn't need to match functions/.env.datapipe-test's key. +const TOKEN_ENCRYPTION_KEY = "34".repeat(32); + +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +// A real-shaped, https, dotted hostname reserved for exactly this purpose +// (the IANA ".test" TLD is guaranteed to never resolve in real DNS) -- mirrors +// providers-dataverse.test.js's own SERVER_URL convention. +const SERVER_URL = "https://dataverse.mock.test"; + +let db; +let connectStaticTokenProvider; +let disconnectProvider; +let isAllowedServerUrl; +let decrypt; + +beforeAll(async () => { + process.env.TOKEN_ENCRYPTION_KEY = TOKEN_ENCRYPTION_KEY; + + ({ connectStaticTokenProvider, disconnectProvider, isAllowedServerUrl } = await import( + "../../lib/connect-provider.js" + )); + ({ db } = await import("../../lib/app.js")); + ({ decrypt } = await import("../../lib/crypto-utils.js")); +}); + +afterEach(() => { + mockFetch.mockReset(); +}); + +// ---- helpers ---- + +async function signUpEmulatorUser() { + const email = `connect-static-token-${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 }; +} + +// Drives an onRequest-wrapped handler through a real (throwaway, +// OS-assigned-port) HTTP round trip -- see the header comment for why. +async function callHandler(handler, payload) { + const app = express(); + app.use(express.json()); + app.use((req, res) => handler(req, res)); + + const server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + + try { + const { port } = server.address(); + const res = await fetch(`http://127.0.0.1:${port}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; + } finally { + server.close(); + } +} + +function callConnectStaticTokenProvider(payload) { + return callHandler(connectStaticTokenProvider, payload); +} + +function callDisconnectProvider(payload) { + return callHandler(disconnectProvider, payload); +} + +async function getUserData(uid) { + const snap = await db.collection("users").doc(uid).get(); + return snap.data(); +} + +// ---- connectStaticTokenProvider ---- + +describe("connectStaticTokenProvider", () => { + it("persists an encrypted dataverse connection with the normalized serverUrl origin", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + mockFetch.mockResolvedValueOnce({ status: 200 }); + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "dataverse", + uid, + idToken, + token: "plaintext-dataverse-api-token", + // A trailing slash on the input proves normalization strips it down to + // the bare origin before persisting/using it. + serverUrl: `${SERVER_URL}/`, + }); + + expect(status).toBe(200); + expect(body).toEqual({ success: true, provider: "dataverse" }); + + const userData = await getUserData(uid); + const dataverse = userData.connectedAccounts.dataverse; + expect(dataverse.authMethod).toBe("static-token"); + expect(dataverse.serverUrl).toBe(SERVER_URL); + expect(dataverse.encryptedToken).not.toBe("plaintext-dataverse-api-token"); + expect(dataverse.encryptedToken.startsWith("v1:")).toBe(true); + expect(decrypt(dataverse.encryptedToken)).toBe("plaintext-dataverse-api-token"); + // Dataverse doesn't tell us the expiry at connect time -- must stay unset. + expect(dataverse.tokenExpiresAt).toBeUndefined(); + + // validateStaticToken hit the normalized origin, not the raw + // trailing-slash input. + expect(mockFetch).toHaveBeenCalledWith(`${SERVER_URL}/api/users/:me`, expect.anything()); + }); + + it("rejects an invalid token with 400 and persists nothing", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + mockFetch.mockResolvedValueOnce({ status: 401 }); + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "dataverse", + uid, + idToken, + token: "bad-token", + serverUrl: SERVER_URL, + }); + + expect(status).toBe(400); + expect(body.error).toBe("Invalid API token"); + + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.dataverse).toBeUndefined(); + }); + + it("rejects an oauth2 provider (gdrive) with 400 Unknown provider, without calling validateStaticToken", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "gdrive", + uid, + idToken, + token: "some-token", + serverUrl: SERVER_URL, + }); + + expect(status).toBe(400); + expect(body.error).toBe("Unknown provider"); + expect(mockFetch).not.toHaveBeenCalled(); + + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.gdrive).toBeUndefined(); + }); + + it("rejects 'osf' with 400 Unknown provider", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "osf", + uid, + idToken, + token: "some-token", + serverUrl: SERVER_URL, + }); + + expect(status).toBe(400); + expect(body.error).toBe("Unknown provider"); + }); + + it("rejects a missing serverUrl with 400 Invalid server URL, without calling validateStaticToken", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "dataverse", + uid, + idToken, + token: "some-token", + }); + + expect(status).toBe(400); + expect(body.error).toBe("Invalid server URL"); + expect(mockFetch).not.toHaveBeenCalled(); + + const userData = await getUserData(uid); + expect(userData?.connectedAccounts?.dataverse).toBeUndefined(); + }); +}); + +// ---- isAllowedServerUrl (pure-function SSRF gate, no network needed) ---- + +describe("isAllowedServerUrl", () => { + const rejected = [ + ["non-https scheme", "http://dataverse.harvard.edu"], + ["localhost", "https://localhost/"], + ["IPv4 loopback literal", "https://127.0.0.1/"], + ["private IPv4 literal", "https://10.0.0.1/"], + ["cloud metadata IP literal", "https://169.254.169.254/"], + ["GCP metadata hostname", "https://metadata.google.internal/"], + // Trailing-dot FQDNs are the explicit-root form and resolve identically + // in DNS, but match neither an equality check nor endsWith(".internal"), + // so they bypassed every hostname rule until the hostname was normalized. + ["GCP metadata hostname, trailing-dot FQDN", "https://metadata.google.internal./"], + ["localhost, trailing-dot FQDN", "https://localhost./"], + ["internal suffix, trailing-dot FQDN", "https://build.corp.internal./"], + ["IPv6 literal", "https://[::1]/"], + ["embedded credentials", "https://user:pass@dataverse.harvard.edu/"], + ["non-443 port", "https://dataverse.harvard.edu:8443/"], + ["bare single-label host", "https://dataverse/"], + ]; + + it.each(rejected)("rejects %s (%s)", (_label, url) => { + expect(isAllowedServerUrl(url)).toBe(false); + }); + + const allowed = ["https://dataverse.harvard.edu", "https://demo.dataverse.org"]; + + it.each(allowed)("allows a real institutional installation: %s", (url) => { + expect(isAllowedServerUrl(url)).toBe(true); + }); +}); + +// ---- disconnectProvider ---- + +describe("disconnectProvider", () => { + it("removes a dataverse connection (previously impossible -- getOAuthConfig threw for static-token providers)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await db + .collection("users") + .doc(uid) + .set({ + connectedAccounts: { + dataverse: { + authMethod: "static-token", + encryptedToken: "pre-existing-dataverse-token", + serverUrl: SERVER_URL, + }, + }, + }); + + const { status, body } = await callDisconnectProvider({ provider: "dataverse", uid, idToken }); + + expect(status).toBe(200); + expect(body).toEqual({ success: true }); + + const userData = await getUserData(uid); + expect(userData.connectedAccounts.dataverse).toBeUndefined(); + }); + + it("still rejects 'osf' with 400 Unknown provider (its legacy identity flow stays unmanaged here)", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + + const { status, body } = await callDisconnectProvider({ provider: "osf", uid, idToken }); + + expect(status).toBe(400); + expect(body.error).toBe("Unknown provider"); + }); +}); diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts index e55c7d5..7097700 100644 --- a/functions/src/connect-provider.ts +++ b/functions/src/connect-provider.ts @@ -11,12 +11,63 @@ import { onRequest } from "firebase-functions/v2/https"; import { FieldValue } from "firebase-admin/firestore"; import { db, auth } from "./app.js"; import { encrypt } from "./crypto-utils.js"; -import { getOAuthConfig } from "./providers/index.js"; +import { getOAuthConfig, getProvider } from "./providers/index.js"; +import { StorageProviderId } from "./providers/types.js"; export type AuthCheckResult = | { ok: true } | { ok: false; status: number; error: string }; +// Rejects request bodies that could turn our authenticated Dataverse client +// into a server-side request forgery (SSRF) primitive: serverUrl is fully +// researcher-supplied, and downloadFile echoes the response body straight +// back to the caller. Left unconstrained, that is a read-with-credentials +// proxy against Google Cloud's metadata service +// (169.254.169.254 / metadata.google.internal) and anything else reachable +// on the private network. This is DEFENSE IN DEPTH, NOT a complete SSRF +// defense -- a public DNS name can still resolve to an internal address +// after this check passes (DNS rebinding) -- so it only closes the cheap, +// obvious cases: non-https schemes, embedded credentials, non-default ports, +// loopback/internal-looking hostnames, and literal IP addresses. Researchers +// only ever connect to named institutional installations +// (dataverse.harvard.edu, demo.dataverse.org), never to a bare IP, so +// rejecting every IPv4/IPv6 literal outright costs nothing real and closes +// the whole class rather than trying to enumerate private ranges. +export function isAllowedServerUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + + if (url.protocol !== "https:") return false; + if (url.username || url.password) return false; + // Default-port URLs are normalized to "" by the URL parser, so this also + // accepts an explicit ":443". + if (url.port !== "" && url.port !== "443") return false; + + // Strip a single trailing dot before any comparison. "…internal." is the + // explicit-root FQDN form and DNS resolves it identically to "…internal", + // but it matches neither an equality check nor an endsWith(".internal") + // one -- so without this, https://metadata.google.internal./ walks straight + // through every rule below. + const hostname = url.hostname.endsWith(".") ? url.hostname.slice(0, -1) : url.hostname; + if (hostname === "localhost") return false; + if (hostname.endsWith(".localhost")) return false; + if (hostname.endsWith(".internal")) return false; + if (hostname === "metadata.google.internal") return false; + if (hostname.startsWith("[")) return false; // IPv6 literal + // Reject every IPv4-shaped literal outright, including the + // octal/decimal-shorthand forms (0177.0.0.1, 2130706433) that a naive + // dotted-quad check would miss -- rather than trying to enumerate private + // ranges. + if (/^\d+(\.\d+)*$/.test(hostname)) return false; + if (!hostname.includes(".")) return false; // rejects bare single-label internal names + + return true; +} + export async function verifyOwnership(uid: string, idToken: string | undefined): Promise<AuthCheckResult> { if (!idToken) { return { ok: false, status: 401, error: 'Authentication required' }; @@ -154,6 +205,95 @@ export const connectProvider = onRequest({ cors: true }, async (req, res) => { } }); +// Separate endpoint from connectProvider rather than a branch inside it: the +// two flows share almost nothing. OAuth needs code+state+CSRF-state +// validation against a third-party redirect; static-token needs a pasted +// token+serverUrl with no redirect at all, so no CSRF state applies here. +// Mixing them would tangle the validation of both. +export const connectStaticTokenProvider = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + const { provider, uid, idToken, token, serverUrl } = req.body || {}; + + if (!provider || !uid || !token) { + res.status(400).json({ error: 'Missing required parameters' }); + return; + } + + let storageProvider; + try { + storageProvider = getProvider(provider as StorageProviderId); + } catch { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + // Opaque "Unknown provider" for every rejection here -- registered but + // wrong auth method looks identical to the caller as genuinely unknown, + // same convention as getOAuthConfig's callers. + if (storageProvider.authMethod !== 'static-token' || !storageProvider.validateStaticToken) { + res.status(400).json({ error: 'Unknown provider' }); + return; + } + + if (typeof serverUrl !== 'string' || !isAllowedServerUrl(serverUrl)) { + res.status(400).json({ error: 'Invalid server URL' }); + return; + } + // Normalize to scheme+host only (no path/query/fragment/trailing slash) + // so the adapter's `${serverUrl}/api/...` string concatenation can never + // produce a double slash or inherit a stray path. + const normalizedServerUrl = new URL(serverUrl).origin; + + // Verify that the caller owns the uid they claim. No signup path here. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + let isValid: boolean; + try { + isValid = await storageProvider.validateStaticToken({ token, serverUrl: normalizedServerUrl }); + } catch (e) { + // A network error against an unreachable/misconfigured installation is + // "not valid", not a server-side failure. + console.error('Static token validation error:', e instanceof Error ? e.message : 'Unknown error'); + isValid = false; + } + if (!isValid) { + res.status(400).json({ error: 'Invalid API token' }); + return; + } + + // Same dot-path persist convention as connectProvider: set()+mergeFields + // creates users/{uid} if absent and leaves sibling provider connections + // untouched. tokenExpiresAt is deliberately omitted -- Dataverse does not + // tell us the expiry at connect time, and the field is optional. + const fieldPath = `connectedAccounts.${provider}`; + await db.doc(`users/${uid}`).set( + { + connectedAccounts: { + [provider]: { + authMethod: 'static-token', + encryptedToken: encrypt(token), + serverUrl: normalizedServerUrl, + }, + }, + }, + { mergeFields: [fieldPath] } + ); + + res.status(200).json({ success: true, provider }); + } catch (error) { + console.error('Error connecting static-token provider:', error instanceof Error ? error.message : 'Unknown error'); + res.status(500).json({ error: 'Failed to connect provider' }); + } +}); + export const disconnectProvider = onRequest({ cors: true }, async (req, res) => { try { if (req.method !== 'POST') { @@ -168,9 +308,18 @@ export const disconnectProvider = onRequest({ cors: true }, async (req, res) => return; } + // Accept any REGISTERED provider except osf: osf's identity flow + // (oauth2-callback.ts) is a separate legacy path not managed here. + // Everything else -- oauth2 (gdrive) or static-token (dataverse) -- can + // be disconnected the same way, since disconnect is just deleting the + // stored connection, regardless of how it was established. + let storageProvider; try { - getOAuthConfig(provider); + storageProvider = getProvider(provider as StorageProviderId); } catch { + storageProvider = undefined; + } + if (!storageProvider || storageProvider.id === 'osf') { res.status(400).json({ error: 'Unknown provider' }); return; } diff --git a/functions/src/index.ts b/functions/src/index.ts index c4d9f76..f2c0f30 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -11,7 +11,7 @@ import { scheduledUploadRetry } from "./scheduled-upload-retry.js"; import { scheduledPendingRecovery } from "./scheduled-pending-recovery.js"; import { apiQueueStatus } from "./api-queue-status.js"; import { generateOAuthState } from "./generate-oauth-state.js"; -import { connectProvider, disconnectProvider } from "./connect-provider.js"; +import { connectProvider, connectStaticTokenProvider, disconnectProvider } from "./connect-provider.js"; import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; @@ -35,6 +35,7 @@ export { apiQueueStatus as apiqueuestatus, generateOAuthState as generateoauthstate, connectProvider as connectprovider, + connectStaticTokenProvider as connectstatictokenprovider, disconnectProvider as disconnectprovider, saveOsfToken as saveosftoken, getOsfToken as getosftoken, From 55b95a3edd042424a5290192f41be36ebfa021fc Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 25 Jul 2026 18:38:33 -0400 Subject: [PATCH 051/181] docs: record source-verified Dataverse spike findings The design doc says spike findings get recorded back into it. These are source-verified (Dataverse's Java source + IQSS integration tests), NOT the empirical demo.dataverse.org run the doc calls for, and the section labels that distinction explicitly. Headline: concurrent-write locking -- named as the single most likely disqualifier in the plan -- looks substantially less dangerous than assumed. Ingest locks are explicitly exempted from the edit-lock check, so adds during another file's ingest are generally allowed, and we suppress ingest anyway. Only non-Ingest locks reject, with 403 rather than the assumed 409. Still needs confirming under real burst load. Silent rename is confirmed and handled; tabular ingest is suppressible and suppressed. Also records two contract details that contradict the published guides (/add returns 200 not 201; /replace is unavailable on an unpublished draft), and flags the serverUrl SSRF surface against the federation open question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/provider-migration-design.md | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 9fe3008..ec8182c 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -355,6 +355,51 @@ provider, it does not trigger a redesign. flow has more failure modes than a single PUT; verify behavior under concurrent submissions, including media-sized files. +### Dataverse spike — partial findings (source-verified, NOT yet empirical) + +Recorded 2026-07-25 alongside the Dataverse adapter (backend only, not +exposed). These come from reading Dataverse's Java source and its IQSS +integration tests, **not** from running the spike against a live +installation. They narrow the gates but do not close them — the live +`demo.dataverse.org` run described above is still required, especially for +locking under real burst load. + +- **Concurrent-write locking — looks substantially less dangerous than + assumed.** `UpdateDatasetVersionCommand` → `checkUpdateDatasetVersionLock` + explicitly *exempts* `Ingest` locks: `hasAtLeastOneLockThatIsNotAnIngestLock` + gates the block, so a file landing while another file is mid-tabular-ingest + is generally allowed. Only non-Ingest locks (`EditInProgress`, `Workflow`, + `DcmUpload`, `GlobusUpload`, `finalizePublication`) reject, and they return + **HTTP 403** with `Dataset cannot be edited due to dataset lock.` — not the + 409 the design assumed. Since we send `tabIngest=false`, ingest locks should + rarely arise at all. **This was named the most likely disqualifier; the + source suggests it probably is not.** Still needs empirical confirmation at + 30–100 writes/minute. +- **Tabular ingest — suppressible, and we suppress it.** `tabIngest` defaults + to `true` (`OptionalFileParams.java`), so the adapter sends `"false"` on + every upload. Version-dependence across installations remains unverified. +- **Silent rename — confirmed, and handled.** IQSS's `DuplicateFilesIT` + asserts a second `README.md` comes back as `README-1.md`. The adapter reads + `storedFilename` from `data.files[0].label` and never assumes it matches the + request. Dataverse cannot return a NAME_CONFLICT, so the Firestore collision + cache is the only duplicate gate for Dataverse experiments. + +Two contract details worth carrying forward, both of which contradict the +published guides: + +- `/add` returns **200**, not the documented 201. +- `/api/files/{id}/replace` is **unavailable on a never-published draft**, and + this design keeps datasets in draft indefinitely — so `updateFile` is + DELETE + re-add, non-atomic, exactly the caveat already recorded for + Figshare. `DELETE /api/files/{id}` physically deletes while unpublished. + +Also note for the federated `serverUrl` open question below: because the +researcher supplies that URL and the backend then makes authenticated +requests to it, it is an SSRF surface. `connect-provider.ts`'s +`isAllowedServerUrl` constrains it (https, no credentials, no odd ports, no IP +literals, no internal/metadata hostnames) — defense in depth only, since a DNS +name can still resolve internally. + ## Deployment checklist (gdrive launch) Accumulated from build steps 1–7; everything below is required before the From ddef109205027e1127c20f1437553444900a68b4 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 01:18:37 -0400 Subject: [PATCH 052/181] test: fix the cross-suite race in the pending-recovery and queue suites The intermittent failure that has been dismissed as emulator contention was a real race with three distinct causes, two of which were outside the pair of suites it was attributed to. 1. pending-recovery-provider-regression ran the recovery sweep UNSCOPED. The sweep lists everything under pending-data/, promotes it, and deletes the pending file -- and the test mocks Date.now 20 minutes forward, so every pending file in the shared emulator bucket looks stale, not just its own. It therefore consumed whatever fixtures a parallel suite had written moments earlier, surfacing as "No such object: .../pending-data/..." in an innocent suite. recoverPendingUploads now takes an optional prefix, defaulting to the production-wide sweep, and the test scopes it to its own experiment. The scheduled function's behavior is unchanged. 2. scheduled-pending-recovery-emulator's afterEach deleted EVERY doc in uploadQueue, not just its own. 3. upload-queue.test.js did exactly the same thing. (2) and (3) are why the observed victims included suites outside the pair -- metadata-derived-upload scopes its own cleanup correctly with a where clause and was pure collateral. Both suites now delete only the doc ids they created; upload-queue never calls the production queueUpload, so registering ids at the write sites is complete. The recovery suite's two fixed experiment ids are also randomized now, so its cleanup can be scoped precisely. Verified over 12 full-suite runs: the pending-data/uploadQueue signature did not recur once, against a prior rate of roughly 1 in 3. One unrelated flake remains, seen once in those 12 runs: collision-integration-emulator case 17 ("warm collision cache"). It uses randomUUID ids and talks to the Functions emulator over HTTP, so it is load/timing, not shared-state contamination -- the same "pre-existing data-emulator timing flake" node.js.yml already caps maxWorkers for. Left alone deliberately; it is a different mechanism needing a different fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- ...nding-recovery-provider-regression.test.js | 52 +++++++++++-------- ...cheduled-pending-recovery-emulator.test.js | 22 ++++++-- functions/src/__tests__/upload-queue.test.js | 30 ++++++++--- functions/src/scheduled-pending-recovery.ts | 17 ++++-- 4 files changed, 84 insertions(+), 37 deletions(-) diff --git a/functions/src/__tests__/pending-recovery-provider-regression.test.js b/functions/src/__tests__/pending-recovery-provider-regression.test.js index ad90c2b..97ab1df 100644 --- a/functions/src/__tests__/pending-recovery-provider-regression.test.js +++ b/functions/src/__tests__/pending-recovery-provider-regression.test.js @@ -17,29 +17,30 @@ // This test seeds exactly that scenario and asserts the queue doc carries the // provider fields -- expected RED today. // -// Seam: scheduled-pending-recovery.ts exports only `scheduledPendingRecovery` -// (an onSchedule-wrapped function); `recoverPendingUploads`/`promoteToQueue` -// are private. Rather than going through the Functions-emulator's HTTP -// manual-trigger URL (the "scheduledpendingrecovery-0" pattern established by -// oauth-connect-scheduled-regression.test.js's case 11), this test uses a -// more direct seam: firebase-functions v2's onSchedule() implementation -// (node_modules/firebase-functions/lib/v2/providers/scheduler.js) stashes the -// raw, unwrapped handler on the returned function as `.run` (`func.run = -// handler`). Dynamically importing the COMPILED module (functions/lib/, -// requires `npm run build` first -- same convention as +// Seam: this test imports `recoverPendingUploads` from the COMPILED module +// (functions/lib/, so `npm run build` must run first -- same convention as // oauth-connect-emulator.test.js's `await import("../../lib/crypto-utils.js")`) -// and calling `scheduledPendingRecovery.run()` invokes recoverPendingUploads() -// directly in THIS process. +// and calls it directly in THIS process, rather than going through the +// Functions-emulator's HTTP manual-trigger URL (the +// "scheduledpendingrecovery-0" pattern in +// oauth-connect-scheduled-regression.test.js's case 11). // -// That matters because recoverPendingUploads() only recovers files older -// than STALE_THRESHOLD_MS (15 minutes) -- a real pending-data file would need -// to sit in the emulator for 15 real minutes before this suite could observe -// it being recovered, which is impractical for CI. Because `.run()` executes -// the real handler in-process (not over HTTP to the separate -// Functions-emulator child process), this test can mock the global `Date.now` -// that recoverPendingUploads() reads to compute its cutoff, shifting the -// cutoff forward past the (really, just-created) file's real timeCreated -- +// Running in-process matters because recoverPendingUploads() only recovers +// files older than STALE_THRESHOLD_MS (15 minutes) -- a real pending-data file +// would otherwise need to sit in the emulator for 15 real minutes before this +// suite could observe it being recovered. In-process, the test can mock the +// global `Date.now` that recoverPendingUploads() reads to compute its cutoff, +// shifting the cutoff past the (really, just-created) file's timeCreated // without touching STALE_THRESHOLD_MS or any other production code. +// +// It also passes a PREFIX scoping the sweep to this test's own experiment. +// The sweep is global and deletes every pending file it promotes, and the +// Date.now shift makes every file in the shared emulator bucket look stale, so +// an unscoped run here consumed fixtures belonging to whatever suite was +// running in parallel -- surfacing as "No such object: .../pending-data/..." +// in some other, innocent suite about one run in three. Earlier this test used +// `scheduledPendingRecovery.run({})` (firebase-functions stashes the unwrapped +// handler on `.run`), which took no prefix and is what made it destructive. import { initializeApp, getApp } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; @@ -59,7 +60,7 @@ const STALE_THRESHOLD_MS = 15 * 60 * 1000; let db; let bucket; -let scheduledPendingRecovery; +let recoverPendingUploads; beforeAll(async () => { // A NAMED app for this test's own seeding/assertions -- deliberately not @@ -76,7 +77,7 @@ beforeAll(async () => { db = getFirestore(app); bucket = getStorage(app).bucket(); - ({ scheduledPendingRecovery } = await import("../../lib/scheduled-pending-recovery.js")); + ({ recoverPendingUploads } = await import("../../lib/scheduled-pending-recovery.js")); }); async function seedPendingFile(experimentID, filename, data) { @@ -110,7 +111,12 @@ describe("10. scheduled-pending-recovery carries provider fields through for a g const realNow = Date.now(); const nowSpy = jest.spyOn(Date, "now").mockReturnValue(realNow + STALE_THRESHOLD_MS + 5 * 60 * 1000); try { - await scheduledPendingRecovery.run({}); + // Scoped to THIS experiment's prefix. The sweep is global and deletes + // every pending file it promotes, and the Date.now shift above makes + // *every* file in the shared emulator bucket look stale -- so running + // it unscoped consumed whatever fixtures a parallel suite had just + // written. See recoverPendingUploads' comment on the prefix seam. + await recoverPendingUploads(`pending-data/${experimentID}/`); } finally { nowSpy.mockRestore(); } diff --git a/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js b/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js index 5c13a0f..19be940 100644 --- a/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js +++ b/functions/src/__tests__/scheduled-pending-recovery-emulator.test.js @@ -14,6 +14,7 @@ process.env.FIREBASE_CONFIG = JSON.stringify({ storageBucket: "datapipe-test.appspot.com", }); +const { randomUUID } = require("crypto"); const { getFirestore } = require("firebase-admin/firestore"); const { getStorage } = require("firebase-admin/storage"); const { promoteToQueue } = require("../../lib/scheduled-pending-recovery.js"); @@ -33,16 +34,27 @@ async function seedExperiment(experimentID, metadataActive) { }); } +// Only the docs THIS suite created. A collection-wide wipe here used to +// delete uploadQueue docs belonging to whatever suite was running in +// parallel (upload-queue.test.js, metadata-derived-upload-emulator, +// pending-recovery-provider-regression), which is half of the long-standing +// cross-suite flake -- the other half was the global pending-data sweep in +// pending-recovery-provider-regression. +const createdQueueDocIds = []; + afterEach(async () => { - const docs = await db.collection("uploadQueue").get(); + if (createdQueueDocIds.length === 0) return; const batch = db.batch(); - docs.forEach((doc) => batch.delete(doc.ref)); + for (const docId of createdQueueDocIds) { + batch.delete(db.collection("uploadQueue").doc(docId)); + } await batch.commit(); + createdQueueDocIds.length = 0; }); describe("scheduled-pending-recovery layout awareness", () => { it("queues the raw-data path and matching dedup key when metadata is active", async () => { - const experimentID = "recovery-test-metadata-on"; + const experimentID = `recovery-test-metadata-on-${randomUUID()}`; await seedExperiment(experimentID, true); const storagePath = await persistPending( @@ -56,6 +68,7 @@ describe("scheduled-pending-recovery layout awareness", () => { const expectedDedupKey = `${experimentID}:data/raw/condition-A-data.json`; const docId = expectedDedupKey.replace(/[/\\]/g, "_"); + createdQueueDocIds.push(docId); const doc = await db.collection("uploadQueue").doc(docId).get(); expect(doc.exists).toBe(true); @@ -64,7 +77,7 @@ describe("scheduled-pending-recovery layout awareness", () => { }); it("queues the original filename and matching dedup key when metadata is off", async () => { - const experimentID = "recovery-test-metadata-off"; + const experimentID = `recovery-test-metadata-off-${randomUUID()}`; await seedExperiment(experimentID, false); const storagePath = await persistPending(experimentID, "data.json", "[]"); @@ -74,6 +87,7 @@ describe("scheduled-pending-recovery layout awareness", () => { const expectedDedupKey = `${experimentID}:data.json`; const docId = expectedDedupKey.replace(/[/\\]/g, "_"); + createdQueueDocIds.push(docId); const doc = await db.collection("uploadQueue").doc(docId).get(); expect(doc.exists).toBe(true); diff --git a/functions/src/__tests__/upload-queue.test.js b/functions/src/__tests__/upload-queue.test.js index 19362a2..2c80aa5 100644 --- a/functions/src/__tests__/upload-queue.test.js +++ b/functions/src/__tests__/upload-queue.test.js @@ -23,18 +23,34 @@ beforeAll(async () => { db = getFirestore(app); }); +// Only the docs THIS suite created. A collection-wide wipe here used to +// delete uploadQueue docs belonging to whatever suite was running in parallel +// (scheduled-pending-recovery-emulator, metadata-derived-upload-emulator, +// pending-recovery-provider-regression), which was one half of the +// long-standing cross-suite flake. Every doc this suite touches is written +// directly under a known id -- it never calls the production queueUpload -- +// so registering them here is complete. +const createdQueueDocIds = []; + +function queueDoc(docId) { + createdQueueDocIds.push(docId); + return db.collection("uploadQueue").doc(docId); +} + afterEach(async () => { - // Clean up uploadQueue collection - const docs = await db.collection("uploadQueue").get(); + if (createdQueueDocIds.length === 0) return; const batch = db.batch(); - docs.forEach((doc) => batch.delete(doc.ref)); + for (const docId of createdQueueDocIds) { + batch.delete(db.collection("uploadQueue").doc(docId)); + } await batch.commit(); + createdQueueDocIds.length = 0; }); describe("queueUpload deduplication", () => { test("uses deterministic document ID from experimentID and filename", async () => { const docId = "exp123:data.csv".replace(/[/\\]/g, "_"); - const docRef = db.collection("uploadQueue").doc(docId); + const docRef = queueDoc(docId); await docRef.set({ experimentID: "exp123", @@ -137,7 +153,7 @@ describe("handleRetryFailure backoff", () => { describe("queue entry lifecycle in Firestore", () => { test("pending entry can transition to processing", async () => { - const docRef = db.collection("uploadQueue").doc("lifecycle-test"); + const docRef = queueDoc("lifecycle-test"); await docRef.set({ status: "pending", retryCount: 0, @@ -160,7 +176,7 @@ describe("queue entry lifecycle in Firestore", () => { }); test("processing entry cannot be claimed again", async () => { - const docRef = db.collection("uploadQueue").doc("double-claim-test"); + const docRef = queueDoc("double-claim-test"); await docRef.set({ status: "processing", retryCount: 0, @@ -181,7 +197,7 @@ describe("queue entry lifecycle in Firestore", () => { }); test("failed entry with max retries reached stays failed", async () => { - const docRef = db.collection("uploadQueue").doc("max-retry-test"); + const docRef = queueDoc("max-retry-test"); await docRef.set({ status: "pending", retryCount: 4, diff --git a/functions/src/scheduled-pending-recovery.ts b/functions/src/scheduled-pending-recovery.ts index 51159d2..294af5a 100644 --- a/functions/src/scheduled-pending-recovery.ts +++ b/functions/src/scheduled-pending-recovery.ts @@ -35,13 +35,24 @@ export const scheduledPendingRecovery = onSchedule( } ); -async function recoverPendingUploads() { +/** + * `prefix` exists as a TEST SEAM and defaults to the production behavior of + * sweeping every pending file. This sweep is deliberately global and + * destructive — it promotes whatever it finds and then DELETES the pending + * file — so a test that runs it unscoped against the shared emulator bucket + * consumes fixtures belonging to whatever other suite happens to be running + * in parallel. That caused a long-lived, misleading flake: a different + * unrelated suite failed with "No such object: .../pending-data/..." roughly + * one run in three, always passing in isolation. Tests must therefore pass a + * prefix that scopes the sweep to their own experiment namespace. + */ +export async function recoverPendingUploads(prefix: string = PENDING_PREFIX) { const bucket = storage.bucket(); const cutoffTime = new Date(Date.now() - STALE_THRESHOLD_MS); - // List files under pending-data/ prefix + // List files under the pending-data/ prefix (or a narrower, test-scoped one) const [files] = await bucket.getFiles({ - prefix: PENDING_PREFIX, + prefix, maxResults: MAX_FILES_PER_RUN * 2, // fetch extra in case some are too recent }); From 37f244ec2e580505e84b786a6085009af1c10eb9 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 08:53:46 -0400 Subject: [PATCH 053/181] feat: connect UI for static-token providers, and the Dataverse rewrite Three things, none of which touch experiment creation (still blocked on how create-experiment.ts passes provider-shaped researcherInput -- it currently hardcodes gdrive's { name, parentId } shape). 1. firebase.json gains the /api/connectstatictokenprovider rewrite. The endpoint landed in 25f1e9d but firebase.json declares a rewrite per function, so without this it was unreachable from the browser. The emulator tests missed it because they drive the handler in-process. 2. lib/provider-config.js gains a dataverse entry, plus an authMethod field on both providers so the connect UI knows which flow to run. Its containerLink reads serverUrl off the container rather than a constant -- Dataverse is federated, so the dataset lives on whichever installation the researcher connected -- and encodes the DOI, which contains slashes. Adding the entry does NOT expose Dataverse in experiment creation: pages/admin/new.js references STORAGE_PROVIDERS.gdrive directly instead of iterating, and ExperimentInfo.js keys off data.storageProvider. So this surfaces Dataverse in account connections and on the dashboard for experiments that already have a container, and nowhere else. 3. ProviderConnections.js branches on authMethod: oauth2 keeps the redirect, static-token opens an inline form for server URL + API token and posts to the new endpoint. Save is disabled until both are filled, since a federated provider cannot be addressed without its host. The backend's SSRF gate returns an opaque "Invalid server URL" for http, IP literals, odd ports and internal hostnames. Surfacing that verbatim would strand a researcher, so it is translated into actionable guidance and the form stays open to be corrected. Buttons now carry provider-specific accessible names ("Connect Google Drive"). Two identically-named Connect buttons is an a11y problem as soon as there is more than one provider, and it made the existing tests' queries ambiguous; those queries are re-pointed, with no assertion changes. Full suite green across two runs: 42 suites / 319 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/provider-config.test.js | 46 +++++++ __tests__/provider-connections.test.jsx | 104 ++++++++++++++- components/account/ProviderConnections.js | 155 +++++++++++++++++++++- firebase.json | 4 + lib/provider-config.js | 31 +++++ 5 files changed, 332 insertions(+), 8 deletions(-) diff --git a/__tests__/provider-config.test.js b/__tests__/provider-config.test.js index 9ece971..99d0098 100644 --- a/__tests__/provider-config.test.js +++ b/__tests__/provider-config.test.js @@ -36,7 +36,53 @@ describe("STORAGE_PROVIDERS.gdrive", () => { expect(STORAGE_PROVIDERS.gdrive.id).toBe("gdrive"); }); + it("declares authMethod oauth2 so the connect UI runs the redirect flow", () => { + expect(STORAGE_PROVIDERS.gdrive.authMethod).toBe("oauth2"); + }); + it("does NOT include osf in the provider map (osf keeps its bespoke legacy UI)", () => { expect(STORAGE_PROVIDERS.osf).toBeUndefined(); }); }); + +describe("STORAGE_PROVIDERS.dataverse", () => { + it("declares authMethod static-token and needs a server URL (it is federated)", () => { + expect(STORAGE_PROVIDERS.dataverse.authMethod).toBe("static-token"); + expect(STORAGE_PROVIDERS.dataverse.needsServerUrl).toBe(true); + }); + + it("isConnected tracks connectedAccounts.dataverse", () => { + expect( + STORAGE_PROVIDERS.dataverse.isConnected({ + connectedAccounts: { dataverse: true }, + }) + ).toBe(true); + expect( + STORAGE_PROVIDERS.dataverse.isConnected({ connectedAccounts: {} }) + ).toBe(false); + expect(STORAGE_PROVIDERS.dataverse.isConnected(undefined)).toBe(false); + }); + + it("containerLink takes the host from the container, not a constant, and encodes the DOI", () => { + // Dataverse is federated, so the dataset lives on whichever installation + // the researcher connected -- the URL cannot be built from a fixed host. + // Persistent ids contain slashes, so the DOI must be encoded to survive + // as a query parameter. + const url = STORAGE_PROVIDERS.dataverse.containerLink({ + providerContainer: { + serverUrl: "https://dataverse.harvard.edu", + persistentId: "doi:10.5072/FK2/J8SJZB", + }, + }); + expect(url).toBe( + "https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi%3A10.5072%2FFK2%2FJ8SJZB" + ); + }); + + it("exposes a human-readable name and container label", () => { + expect(STORAGE_PROVIDERS.dataverse.name).toBe("Dataverse"); + expect(STORAGE_PROVIDERS.dataverse.containerLabel).toBe("Dataverse Dataset"); + expect(STORAGE_PROVIDERS.dataverse.containerLinkText).toBe("Open dataset"); + expect(STORAGE_PROVIDERS.dataverse.id).toBe("dataverse"); + }); +}); diff --git a/__tests__/provider-connections.test.jsx b/__tests__/provider-connections.test.jsx index 3637e96..9011937 100644 --- a/__tests__/provider-connections.test.jsx +++ b/__tests__/provider-connections.test.jsx @@ -68,9 +68,9 @@ describe("ProviderConnections", () => { renderComponent(); expect( - screen.getByRole("button", { name: /Connect/i }) + screen.getByRole("button", { name: /^Connect Google Drive$/i }) ).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /Connect/i })); + fireEvent.click(screen.getByRole("button", { name: /^Connect Google Drive$/i })); await waitFor(() => expect(global.fetch).toHaveBeenCalled()); const [url, options] = global.fetch.mock.calls[0]; @@ -86,6 +86,104 @@ describe("ProviderConnections", () => { expect(localStorage.getItem("providerConnectFlow")).toBe("gdrive"); }); + it("static-token provider: Connect opens an inline form instead of redirecting", async () => { + useDocumentData.mockReturnValue([{ connectedAccounts: {} }, false, undefined]); + + renderComponent(); + + fireEvent.click(screen.getByRole("button", { name: /^Connect Dataverse$/i })); + + // Federated: the researcher must say WHICH installation. + expect(screen.getByLabelText(/Dataverse server URL/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/API token/i)).toBeInTheDocument(); + // No OAuth redirect for a static-token provider. + expect(global.fetch).not.toHaveBeenCalled(); + expect(window.location.assign).not.toHaveBeenCalled(); + }); + + it("static-token provider: Save posts token + serverUrl to connectstatictokenprovider", async () => { + useDocumentData.mockReturnValue([{ connectedAccounts: {} }, false, undefined]); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, provider: "dataverse" }), + }); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /^Connect Dataverse$/i })); + + fireEvent.change(screen.getByLabelText(/Dataverse server URL/i), { + target: { value: "https://dataverse.harvard.edu" }, + }); + fireEvent.change(screen.getByLabelText(/API token/i), { + target: { value: " tok-abc " }, + }); + fireEvent.click( + screen.getByRole("button", { name: /^Save Dataverse connection$/i }) + ); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/connectstatictokenprovider"); + expect(JSON.parse(options.body)).toEqual({ + provider: "dataverse", + uid: "user-1", + idToken: "id-token-123", + token: "tok-abc", + serverUrl: "https://dataverse.harvard.edu", + }); + }); + + it("static-token provider: Save stays disabled until both fields are filled", async () => { + useDocumentData.mockReturnValue([{ connectedAccounts: {} }, false, undefined]); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /^Connect Dataverse$/i })); + + const save = screen.getByRole("button", { + name: /^Save Dataverse connection$/i, + }); + expect(save).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/API token/i), { + target: { value: "tok-abc" }, + }); + // serverUrl still empty -- a federated provider cannot be addressed without it. + expect(save).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/Dataverse server URL/i), { + target: { value: "https://dataverse.harvard.edu" }, + }); + expect(save).toBeEnabled(); + }); + + it("static-token provider: a rejected server URL is explained, not surfaced verbatim", async () => { + useDocumentData.mockReturnValue([{ connectedAccounts: {} }, false, undefined]); + global.fetch.mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: "Invalid server URL" }), + }); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /^Connect Dataverse$/i })); + fireEvent.change(screen.getByLabelText(/Dataverse server URL/i), { + target: { value: "http://10.0.0.1" }, + }); + fireEvent.change(screen.getByLabelText(/API token/i), { + target: { value: "tok-abc" }, + }); + fireEvent.click( + screen.getByRole("button", { name: /^Save Dataverse connection$/i }) + ); + + // The backend's opaque "Invalid server URL" becomes actionable guidance, + // and the form stays open so the value can be corrected. + await waitFor(() => + expect(screen.getByText(/full https:\/\/ address/i)).toBeInTheDocument() + ); + expect(screen.getByLabelText(/Dataverse server URL/i)).toBeInTheDocument(); + }); + it("8. connected: shows Connected status + Disconnect; click posts to disconnectprovider", async () => { useDocumentData.mockReturnValue([ { connectedAccounts: { gdrive: true } }, @@ -100,7 +198,7 @@ describe("ProviderConnections", () => { renderComponent(); expect(screen.getByText(/Connected/i)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /Disconnect/i })); + fireEvent.click(screen.getByRole("button", { name: /^Disconnect Google Drive$/i })); await waitFor(() => expect(global.fetch).toHaveBeenCalled()); const [url, options] = global.fetch.mock.calls[0]; diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 3acc468..7d995a4 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -1,5 +1,5 @@ import { useContext, useState } from "react"; -import { VStack, HStack, Text, Button } from "@chakra-ui/react"; +import { VStack, HStack, Text, Button, Input, Field } from "@chakra-ui/react"; import { doc } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; import { useDocumentData } from "react-firebase-hooks/firestore"; @@ -16,6 +16,82 @@ export default function ProviderConnections() { const [connectingId, setConnectingId] = useState(null); const [disconnectingId, setDisconnectingId] = useState(null); + // Static-token providers have no redirect flow: clicking Connect opens an + // inline form instead of navigating away. Only one can be open at a time. + const [tokenFormId, setTokenFormId] = useState(null); + const [serverUrl, setServerUrl] = useState(""); + const [apiToken, setApiToken] = useState(""); + const [formError, setFormError] = useState(null); + + const openTokenForm = (providerId) => { + setTokenFormId(providerId); + setServerUrl(""); + setApiToken(""); + setFormError(null); + }; + + const closeTokenForm = () => { + setTokenFormId(null); + setServerUrl(""); + setApiToken(""); + setFormError(null); + }; + + // The backend rejects any server URL that is not a plain https host: no + // http, no embedded credentials, no odd ports, no IP literals, no internal + // hostnames. That gate exists because the server itself makes authenticated + // requests to whatever is submitted. Translate its opaque replies into + // something a researcher can act on rather than surfacing them verbatim. + const messageForError = (status, error) => { + if (error === "Invalid server URL") { + return "That does not look like a Dataverse server address. Use the full https:// address of your institution's installation, for example https://dataverse.harvard.edu."; + } + if (error === "Invalid API token") { + return "That server did not accept the token. Check that you copied it fully, that it has not expired, and that it belongs to the server above."; + } + if (status === 401 || status === 403) { + return "You are not signed in to the right account. Try reloading the page and signing in again."; + } + return "Could not connect. Please try again."; + }; + + const handleTokenConnect = async (providerId) => { + setConnectingId(providerId); + setFormError(null); + try { + const idToken = await auth.currentUser.getIdToken(); + const response = await fetch("/api/connectstatictokenprovider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + uid: user.uid, + idToken, + token: apiToken.trim(), + serverUrl: serverUrl.trim(), + }), + }); + + if (!response.ok) { + let error; + try { + ({ error } = await response.json()); + } catch { + error = null; + } + setFormError(messageForError(response.status, error)); + return; + } + + closeTokenForm(); + } catch (err) { + console.error("Failed to connect provider:", err); + setFormError("Could not reach DataPipe. Check your connection and try again."); + } finally { + setConnectingId(null); + } + }; + const handleConnect = async (providerId) => { setConnectingId(providerId); try { @@ -65,10 +141,12 @@ export default function ProviderConnections() { <VStack gap={3} w="100%" align="stretch"> {Object.values(STORAGE_PROVIDERS).map((provider) => { const connected = provider.isConnected(data); + const isStaticToken = provider.authMethod === "static-token"; + const formOpen = tokenFormId === provider.id; return ( + <VStack key={provider.id} w="100%" align="stretch" gap={2}> <HStack - key={provider.id} justifyContent="space-between" w="100%" flexWrap="wrap" @@ -90,6 +168,7 @@ export default function ProviderConnections() { colorPalette="red" variant="outline" size="md" + aria-label={`Disconnect ${provider.name}`} loading={disconnectingId === provider.id} onClick={() => handleDisconnect(provider.id)} > @@ -99,13 +178,79 @@ export default function ProviderConnections() { <Button colorPalette="blue" size="md" - loading={connectingId === provider.id} - onClick={() => handleConnect(provider.id)} + variant={formOpen ? "outline" : "solid"} + aria-label={`${formOpen ? "Cancel" : "Connect"} ${provider.name}`} + loading={connectingId === provider.id && !isStaticToken} + onClick={() => + isStaticToken + ? formOpen + ? closeTokenForm() + : openTokenForm(provider.id) + : handleConnect(provider.id) + } > - Connect + {formOpen ? "Cancel" : "Connect"} </Button> )} </HStack> + + {formOpen && !connected && ( + <VStack + align="stretch" + gap={3} + w="100%" + borderWidth="1px" + borderRadius="md" + p={4} + > + {provider.needsServerUrl && ( + <Field.Root> + <Field.Label>{provider.serverUrlLabel}</Field.Label> + <Input + type="url" + value={serverUrl} + placeholder={provider.serverUrlPlaceholder} + onChange={(e) => setServerUrl(e.target.value)} + /> + </Field.Root> + )} + <Field.Root> + <Field.Label>{provider.tokenLabel}</Field.Label> + <Input + type="password" + value={apiToken} + autoComplete="off" + onChange={(e) => setApiToken(e.target.value)} + /> + {provider.tokenHelp && ( + <Field.HelperText>{provider.tokenHelp}</Field.HelperText> + )} + </Field.Root> + + {formError && ( + <Text fontSize="sm" color="red.400"> + {formError} + </Text> + )} + + <HStack justifyContent="flex-end"> + <Button + colorPalette="blue" + size="md" + aria-label={`Save ${provider.name} connection`} + loading={connectingId === provider.id} + disabled={ + apiToken.trim().length === 0 || + (provider.needsServerUrl && serverUrl.trim().length === 0) + } + onClick={() => handleTokenConnect(provider.id)} + > + Save connection + </Button> + </HStack> + </VStack> + )} + </VStack> ); })} </VStack> diff --git a/firebase.json b/firebase.json index c8614c9..7a48211 100644 --- a/firebase.json +++ b/firebase.json @@ -62,6 +62,10 @@ "source": "/api/connectprovider", "function": "connectprovider" }, + { + "source": "/api/connectstatictokenprovider", + "function": "connectstatictokenprovider" + }, { "source": "/api/disconnectprovider", "function": "disconnectprovider" diff --git a/lib/provider-config.js b/lib/provider-config.js index 8a3da6f..f833c8a 100644 --- a/lib/provider-config.js +++ b/lib/provider-config.js @@ -2,14 +2,45 @@ // included here -- it keeps its bespoke legacy UI (identity flow, PAT flow, // existing new-experiment form, existing dashboard links). Adding a new // provider (e.g. figshare) should only require a new entry in this map. +// +// `authMethod` mirrors the backend StorageProvider field of the same name and +// tells the connect UI which flow to run: "oauth2" redirects to the provider's +// consent screen, "static-token" collects a pasted token (and, for federated +// providers, a server URL) in a form. See components/account/ProviderConnections.js. export const STORAGE_PROVIDERS = { gdrive: { id: "gdrive", name: "Google Drive", + authMethod: "oauth2", isConnected: (userDoc) => !!userDoc?.connectedAccounts?.gdrive, containerLink: (exp) => `https://drive.google.com/drive/folders/${exp.providerContainer?.folderId}`, containerLabel: "Google Drive Folder", containerLinkText: "Open folder", }, + dataverse: { + id: "dataverse", + name: "Dataverse", + authMethod: "static-token", + // Dataverse is FEDERATED -- Harvard, Borealis, DataverseNL and the rest are + // separate installations -- so the researcher supplies their server URL at + // connect time and it is stored per-connection. + needsServerUrl: true, + serverUrlLabel: "Dataverse server URL", + serverUrlPlaceholder: "https://dataverse.harvard.edu", + tokenLabel: "API token", + tokenHelp: + "Create one under your Dataverse account's API Token tab. Tokens expire (often yearly) and cannot be renewed automatically, so you will need to reconnect when yours lapses.", + isConnected: (userDoc) => !!userDoc?.connectedAccounts?.dataverse, + // The dataset landing page lives on whichever installation holds it, so + // the host comes off the container rather than a constant. The DOI is + // encoded because persistent ids contain slashes + // (doi:10.5072/FK2/J8SJZB). + containerLink: (exp) => + `${exp.providerContainer?.serverUrl}/dataset.xhtml?persistentId=${encodeURIComponent( + exp.providerContainer?.persistentId ?? "" + )}`, + containerLabel: "Dataverse Dataset", + containerLinkText: "Open dataset", + }, }; From e72a8b8e225b658a6c6c211e6f1ac07c2e8db15e Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 09:05:18 -0400 Subject: [PATCH 054/181] feat: providers declare their container input; create-experiment validates it create-experiment.ts built createDataContainer's researcherInput as a hardcoded gdrive shape -- { name: title, parentId } -- so a Dataverse create would have POSTed to /api/dataverses/undefined/datasets. That was the last place the endpoint knew a specific provider's input shape. Each adapter now DECLARES the researcher-supplied fields it needs via containerInput: ContainerInputField[], and create-experiment validates against that declaration without naming any provider. osf declares none (its creation stays browser-driven), gdrive declares a single optional hidden parentId (supplied by the Picker, not typed), and dataverse declares collectionAlias / authorName / contactEmail / description as required plus subject as optional. Validation runs before token resolution -- no point decrypting or refreshing a credential for a request that cannot succeed -- and returns a 400 naming exactly the missing fields. The top-level parentFolderId wire param is kept and folded in as parentId unconditionally, with no provider branch. It predates this mechanism, and the shipped Drive-picker client sends it with six tests pinning the wire name, so churning it would risk a working feature for no gain. New providers use researcherInput instead. gdrive behavior is unchanged and case 10 of create-experiment-emulator passes untouched. Adds a conformance test asserting every REGISTERED provider declares a well-shaped containerInput, so provider #4 cannot forget it. It lives in its own file rather than extending providers-registry.test.js, whose beforeEach clears the registry and repopulates it with bare fakes. Full suite green: 43 suites / 324 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../create-experiment-emulator.test.js | 84 +++++++++++++++++++ .../providers-container-input.test.js | 51 +++++++++++ functions/src/create-experiment.ts | 45 ++++++++-- functions/src/providers/dataverse.ts | 8 ++ functions/src/providers/gdrive.ts | 6 ++ functions/src/providers/osf.ts | 5 ++ functions/src/providers/types.ts | 22 +++++ 7 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 functions/src/__tests__/providers-container-input.test.js diff --git a/functions/src/__tests__/create-experiment-emulator.test.js b/functions/src/__tests__/create-experiment-emulator.test.js index 5c24c8c..5d54b8d 100644 --- a/functions/src/__tests__/create-experiment-emulator.test.js +++ b/functions/src/__tests__/create-experiment-emulator.test.js @@ -417,3 +417,87 @@ describe("10. createExperiment with parentFolderId", () => { expect(mockDrive.getCreateCount("DataPipe")).toBe(1); }); }); + +describe("11. createExperiment generic containerInput validation (dataverse)", () => { + it("returns 400 naming every missing required field for a dataverse experiment, and creates no experiment doc", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // Deliberately no connectedAccounts.dataverse seeded, and no + // researcherInput sent -- dataverse's containerInput declares + // collectionAlias/authorName/contactEmail/description as required (see + // providers/dataverse.ts), none of which are present here. + const title = `Case11 missing-fields ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "dataverse", title, idToken, uid }); + + expect(status).toBe(400); + expect(body.error).toContain("collectionAlias"); + expect(body.error).toContain("authorName"); + expect(body.error).toContain("contactEmail"); + expect(body.error).toContain("description"); + // subject is optional -- must NOT be named as missing. + expect(body.error).not.toContain("subject"); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); + + it("returns 400 before token resolution: the user has no dataverse connection at all, yet the error names missing fields rather than PROVIDER_NOT_CONNECTED", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // No connectedAccounts.dataverse seeded at all -- if the endpoint + // resolved a token before validating containerInput, this would surface + // MESSAGES.PROVIDER_NOT_CONNECTED instead. Getting the missing-fields + // message proves validation runs first and never reaches resolveToken + // (and therefore never reaches any provider HTTP call either). + const title = `Case11 no-connection ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "dataverse", title, idToken, uid }); + + expect(status).toBe(400); + expect(body).not.toEqual(expect.objectContaining(MESSAGES.PROVIDER_NOT_CONNECTED)); + expect(body.error).toContain("Missing required fields for dataverse"); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); + + it("succeeds when researcherInput supplies every required dataverse field (regression guard on the happy path, no mock Dataverse server needed since this asserts only the validation gate is passable)", async () => { + // This suite has no mock Dataverse HTTP server (createDataContainer would + // need to reach a real/mocked installation), so this case only proves + // the 400 goes away once every required field is supplied -- pairing + // with the two tests above it confirms the gate is neither too strict + // nor a no-op. It's expected to fail past validation (no dataverse + // account connected, so resolveToken -> PROVIDER_NOT_CONNECTED), which + // itself confirms containerInput validation let the request through. + const { uid, idToken } = await signUpEmulatorUser(); + const title = `Case11 complete-fields ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ + provider: "dataverse", + title, + idToken, + uid, + researcherInput: { + collectionAlias: "my-lab", + authorName: "Lastname, Firstname", + contactEmail: "you@example.edu", + description: "A test dataset", + }, + }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining(MESSAGES.PROVIDER_NOT_CONNECTED)); + expect((await experimentsForOwner(uid)).length).toBe(0); + }); +}); + +describe("12. createExperiment gdrive with only a title (regression: gdrive's single containerInput field is optional)", () => { + it("succeeds with no parentFolderId and no researcherInput at all", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + await seedGdriveUser(uid); + const title = `Case12 ${randomUUID()}`; + + const { status, body } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + const folderId = mockDrive.getFolderId(title); + expect(typeof folderId).toBe("string"); + expect(body.providerContainer).toEqual({ provider: "gdrive", folderId }); + }); +}); diff --git a/functions/src/__tests__/providers-container-input.test.js b/functions/src/__tests__/providers-container-input.test.js new file mode 100644 index 0000000..7a50b29 --- /dev/null +++ b/functions/src/__tests__/providers-container-input.test.js @@ -0,0 +1,51 @@ +/** + * @jest-environment node + */ + +// Conformance check for the containerInput mechanism (providers/types.ts's +// ContainerInputField / StorageProvider.containerInput). create-experiment.ts +// validates a createDataContainer request generically against whatever a +// provider's containerInput array declares, and never names a specific +// provider's researcherInput shape -- so a provider that forgets to declare +// containerInput would silently accept requests it can't actually satisfy. +// This test guards against exactly that, for every REGISTERED provider (not +// a fake test double), which is why it imports the real registry entry point +// (providers/index.js) rather than constructing fixtures like +// providers-registry.test.js does. +// +// Runs in the node environment and imports the compiled lib/ output, same +// convention as every other providers-*.test.js file in this directory (see +// e.g. providers-gdrive.test.js's docblock for why: osf.ts/gdrive.ts pull in +// app.js -> firebase-admin, and jsdom's ESM-only jose build breaks under +// Jest's CJS transform). + +// providers/index.js pulls in every adapter (osf.ts, gdrive.ts, dataverse.ts), +// each of which imports "node-fetch" at module scope for real network calls. +// node-fetch ships ESM and Jest's CJS transform can't parse it, so it must be +// mocked at the module level even though this test never calls fetch -- +// mirrors every other providers-*.test.js file's convention. +jest.mock("node-fetch", () => ({ + __esModule: true, + default: jest.fn(), +})); + +import { listProviders, getProvider } from "../../lib/providers/index.js"; + +describe("provider containerInput conformance", () => { + it("every registered provider declares a containerInput array", () => { + const ids = listProviders(); + // Sanity check that we're looking at the real registry (osf, gdrive, + // dataverse), not an empty/cleared one. + expect(ids.length).toBeGreaterThan(0); + + for (const id of ids) { + const provider = getProvider(id); + expect(Array.isArray(provider.containerInput)).toBe(true); + for (const field of provider.containerInput) { + expect(typeof field.name).toBe("string"); + expect(typeof field.label).toBe("string"); + expect(typeof field.required).toBe("boolean"); + } + } + }); +}); diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts index 4db6205..efbb3f9 100644 --- a/functions/src/create-experiment.ts +++ b/functions/src/create-experiment.ts @@ -60,6 +60,7 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { uid, experimentSettings, parentFolderId, + researcherInput, }: { provider?: string; title?: string; @@ -69,8 +70,17 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { // Researcher-chosen Drive folder (via the Picker) to create the // experiment's data folder under, instead of the default DataPipe // root. Optional and provider-shaped -- createDataContainer ignores it - // for providers that don't understand a parentId. + // for providers that don't understand a parentId. This is a LEGACY + // wire param that predates the generic containerInput mechanism below; + // new providers use researcherInput instead, but this must keep + // working because the shipped Drive-picker client sends it and + // several tests pin the wire name. parentFolderId?: string; + // Generic, provider-shaped researcher input for createDataContainer + // (e.g. collectionAlias/authorName/... for Dataverse). Validated below + // against the target provider's declared containerInput fields -- + // create-experiment never names a specific provider's shape. + researcherInput?: Record<string, unknown>; } = req.body || {}; if (!provider || !title || !uid) { @@ -96,6 +106,34 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { const storageProvider = getProvider(provider as StorageProviderId); + // Build the container input generically -- no provider is ever named + // here. `title` is always injected: every provider needs a human name + // for its container, and gdrive's adapter reads it as `name`. + const containerInput: Record<string, unknown> = { + name: title, + title, + ...(researcherInput || {}), + }; + // Legacy wire param: this predates the containerInput mechanism above. + // The shipped Drive-picker client sends parentFolderId at the top level + // (not inside researcherInput), so fold it in unconditionally -- no + // provider branch, just "if this legacy field was sent, map it in". + // New providers use researcherInput instead. + if (parentFolderId) { + containerInput.parentId = parentFolderId; + } + + // Validate against the provider's declared containerInput spec BEFORE + // resolving a token -- no point refreshing/decrypting a credential for a + // request that cannot possibly succeed. + const missing = storageProvider.containerInput + .filter((f) => f.required && !containerInput[f.name]) + .map((f) => f.name); + if (missing.length > 0) { + res.status(400).json({ error: `Missing required fields for ${provider}: ${missing.join(", ")}` }); + return; + } + const userDocRef = db.doc(`users/${uid}`); const userDoc = await userDocRef.get(); // A freshly-signed-up user may have no Firestore doc yet -- treat that @@ -119,10 +157,7 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { let providerContainer: ContainerRef; try { - providerContainer = await storageProvider.createDataContainer( - auth, - { name: title, ...(parentFolderId ? { parentId: parentFolderId } : {}) } - ); + providerContainer = await storageProvider.createDataContainer(auth, containerInput); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; res.status(502).json({ error: "Failed to create storage container", detail }); diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index 08c55ec..9a29c95 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -169,6 +169,14 @@ export const dataverseProvider: StorageProvider = { quotaNote: "File size and storage limits are set by the researcher's hosting Dataverse installation", }, + containerInput: [ + { name: "collectionAlias", label: "Collection alias", required: true, placeholder: "my-lab" }, + { name: "authorName", label: "Author name", required: true, placeholder: "Lastname, Firstname" }, + { name: "contactEmail", label: "Contact email", required: true, placeholder: "you@example.edu" }, + { name: "description", label: "Description", required: true, inputType: "textarea" }, + { name: "subject", label: "Subject", required: false, placeholder: "Social Sciences" }, + ], + async resolveToken(userData: UserData, _owner: string): Promise<TokenResult> { // _owner is unused: Dataverse is a static-token provider with no refresh // token to rotate, so there is no persist-back step the way gdrive's diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index f429d67..15b4ebb 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -200,6 +200,12 @@ export const gdriveProvider: StorageProvider = { quotaNote: "Free Google accounts share 15 GB across Drive, Gmail, and Photos", }, + // Supplied by the Google Picker (a bespoke UI), not a rendered text field -- + // hence inputType "hidden" rather than "text". + containerInput: [ + { name: "parentId", label: "Parent folder", required: false, inputType: "hidden" }, + ], + // A method rather than a static object so env vars are read at CALL time, // not module load -- same reason as getApiBase() above. oauthConfig(): OAuthConfig { diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index 9daabd0..fb9e19c 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -55,6 +55,11 @@ export const osfProvider: StorageProvider = { quotaNote: null, }, + // OSF creation stays entirely browser-driven (see lib/experiment-creation.js) + // and its createDataContainer below throws "not implemented" -- there is no + // researcher input for create-experiment to collect or validate. + containerInput: [], + async resolveToken(user_data: UserData, owner: string): Promise<TokenResult> { if (user_data.usingPersonalToken) { if (!user_data.osfTokenValid) { diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 1b98f83..3d89444 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -104,11 +104,33 @@ export interface OAuthConfig { extraAuthParams: Record<string, string>; } +// Describes one researcher-supplied value createDataContainer needs beyond +// the experiment title (which create-experiment always injects itself). +// This is the SERVER-side source of truth: create-experiment validates a +// createDataContainer request generically against a provider's +// containerInput list, so it never needs to name a specific provider or know +// its researcherInput shape, and adding a new provider never requires +// editing that endpoint. +export interface ContainerInputField { + name: string; + label: string; + required: boolean; + placeholder?: string; + // "hidden" means the client supplies this through a bespoke UI (gdrive's + // Google Picker) rather than a rendered text field. + inputType?: "text" | "textarea" | "hidden"; +} + export interface StorageProvider { id: StorageProviderId; authMethod: AuthMethod; capabilities: ProviderCapabilities; + // The researcher-supplied fields this provider's createDataContainer needs + // beyond the experiment title. See ContainerInputField above -- this is + // the SERVER-side source of truth create-experiment validates against. + containerInput: ContainerInputField[]; + // Optional because only providers on the generic OAuth2 storage-GRANT flow // have one. OSF deliberately does not -- its OAuth is a separate legacy // IDENTITY flow (oauth2-callback.ts) with its own env vars -- and that From c98603bde0e927e06883a9b5ac15e23c820ce652 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 09:15:05 -0400 Subject: [PATCH 055/181] feat: provider-generic new-experiment form, driven by containerInput The new-experiment page hardcoded gdrive: gdrive-specific state, a hand-written two-option radio pair, and a gdrive-only form block. Nothing collected the five fields Dataverse needs, so a Dataverse experiment could not be created even though the backend was ready. The provider options now come from STORAGE_PROVIDERS, and the connected form renders one input per the selected provider's containerInputFields -- a client mirror of the server's containerInput spec, with a comment saying plainly that the SERVER is authoritative (create-experiment validates against its own declaration and 400s naming missing fields) and that this copy is presentational only. The duplication is deliberate: lib/ is bundled into the Next app and cannot import from functions/src/. createProviderExperiment takes a fourth researcherInput argument, sent only when non-empty. Only declared field names are sent -- never a spread of arbitrary form state -- and empty optional fields are omitted. Client-side required-field checks mirror the server's rule as a UX nicety. Two things stay deliberately provider-specific, both commented so they do not read as leftover hardcoding: OSF remains a hardcoded option absent from STORAGE_PROVIDERS (it keeps its bespoke legacy identity flow), and the folder picker stays gated to gdrive because it is Google's Picker SDK, not a generic capability. Fixed while reviewing: handleProviderChange did not reset selectedFolder, so picking a Drive folder and then switching to Dataverse posted a Drive folder id as parentFolderId on a Dataverse create. Harmless in effect -- the server folds parentFolderId in unconditionally and Dataverse's adapter ignores parentId -- but wrong to send, and exactly the stale-carry-over class the reset exists to prevent. Pinned by a regression test verified to fail without the fix. The title deliberately survives a provider change: it describes the study, not the storage. OSF and gdrive behavior is unchanged; all six pre-existing page tests pass untouched. Next production build compiles clean. Full suite green: 43 suites / 333 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/experiment-creation.test.js | 52 +++++ __tests__/new-experiment-page.test.jsx | 253 +++++++++++++++++++++++++ lib/experiment-creation.js | 5 +- lib/provider-config.js | 26 +++ pages/admin/new.js | 244 +++++++++++++++++------- 5 files changed, 507 insertions(+), 73 deletions(-) diff --git a/__tests__/experiment-creation.test.js b/__tests__/experiment-creation.test.js index d865a31..aca5c6e 100644 --- a/__tests__/experiment-creation.test.js +++ b/__tests__/experiment-creation.test.js @@ -127,4 +127,56 @@ describe("createProviderExperiment", () => { createProviderExperiment("gdrive", "My Experiment") ).rejects.toThrow("Forbidden"); }); + + it("forwards researcherInput in the request body when provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-4" }), + }); + + const researcherInput = { + collectionAlias: "my-lab", + authorName: "Smith, Jane", + contactEmail: "jane@example.edu", + description: "A study about things", + }; + + const result = await createProviderExperiment( + "dataverse", + "My Experiment", + undefined, + researcherInput + ); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.researcherInput).toEqual(researcherInput); + expect(body).toEqual({ + provider: "dataverse", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + researcherInput, + }); + expect(result).toEqual({ experimentId: "exp-4" }); + }); + + it("omits researcherInput from the request body when empty or undefined", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-5" }), + }); + + await createProviderExperiment("gdrive", "My Experiment", undefined, undefined); + let [, options] = global.fetch.mock.calls[0]; + let body = JSON.parse(options.body); + expect(body.researcherInput).toBeUndefined(); + + await createProviderExperiment("gdrive", "My Experiment", undefined, {}); + [, options] = global.fetch.mock.calls[1]; + body = JSON.parse(options.body); + expect(body.researcherInput).toBeUndefined(); + }); }); diff --git a/__tests__/new-experiment-page.test.jsx b/__tests__/new-experiment-page.test.jsx index 638630c..f4569c7 100644 --- a/__tests__/new-experiment-page.test.jsx +++ b/__tests__/new-experiment-page.test.jsx @@ -54,6 +54,13 @@ jest.mock("react-firebase-hooks/firestore", () => ({ useDocumentData: jest.fn(), })); +// pickDriveFolder loads Google's Picker SDK from the network; stub it so the +// folder-selection flow is drivable in jsdom. +const mockPickDriveFolder = jest.fn(); +jest.mock("../lib/google-picker", () => ({ + pickDriveFolder: (...args) => mockPickDriveFolder(...args), +})); + import { useDocumentData } from "react-firebase-hooks/firestore"; import NewExperimentPage from "../pages/admin/new"; @@ -216,3 +223,249 @@ describe("NewExperimentPage — Google Drive provider selector", () => { expect(mockPush).not.toHaveBeenCalled(); }); }); + +describe("NewExperimentPage — Dataverse provider (provider-generic rendering)", () => { + function selectDataverse() { + fireEvent.click(screen.getByLabelText(/^Dataverse$/i)); + } + + function selectGoogleDrive() { + fireEvent.click(screen.getByLabelText(/Google Drive/i)); + } + + function fillDataverseFields({ + collectionAlias = "my-lab", + authorName = "Smith, Jane", + contactEmail = "jane@example.edu", + description = "A study about things", + subject = "Social Sciences", + } = {}) { + fireEvent.change(screen.getByLabelText(/Collection alias/i), { + target: { value: collectionAlias }, + }); + fireEvent.change(screen.getByLabelText(/Author name/i), { + target: { value: authorName }, + }); + fireEvent.change(screen.getByLabelText(/Contact email/i), { + target: { value: contactEmail }, + }); + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: description }, + }); + fireEvent.change(screen.getByLabelText(/Subject/i), { + target: { value: subject }, + }); + } + + it("the provider selector offers Dataverse", () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", connectedAccounts: {} }, + false, + undefined, + ]); + + renderPage(); + + expect(screen.getByLabelText(/^Dataverse$/i)).toBeInTheDocument(); + }); + + it("selecting Dataverse with no connection shows the connect CTA and no create form", () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", connectedAccounts: {} }, + false, + undefined, + ]); + + renderPage(); + selectDataverse(); + + const cta = screen.getByRole("link", { + name: /Connect Dataverse Account/i, + }); + expect(cta).toHaveAttribute("href", "/admin/account"); + expect( + screen.queryByRole("button", { name: /^Create$/i }) + ).not.toBeInTheDocument(); + }); + + it("selecting a CONNECTED Dataverse renders the declared fields alongside Title", () => { + useDocumentData.mockReturnValue([ + { + refreshToken: "osf-refresh-token", + connectedAccounts: { dataverse: true }, + }, + false, + undefined, + ]); + + renderPage(); + selectDataverse(); + + expect(screen.getByLabelText(/^Title$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Collection alias/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Author name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Contact email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Subject/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Create$/i })).toBeInTheDocument(); + }); + + it("submitting with a required field blank does NOT call the API", () => { + useDocumentData.mockReturnValue([ + { + refreshToken: "osf-refresh-token", + connectedAccounts: { dataverse: true }, + }, + false, + undefined, + ]); + + renderPage(); + selectDataverse(); + + fireEvent.change(screen.getByLabelText(/^Title$/i), { + target: { value: "My Dataverse Study" }, + }); + // Fill every declared field except the required authorName. + fireEvent.change(screen.getByLabelText(/Collection alias/i), { + target: { value: "my-lab" }, + }); + fireEvent.change(screen.getByLabelText(/Contact email/i), { + target: { value: "jane@example.edu" }, + }); + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: "A study about things" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /^Create$/i })); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("a full submit calls /api/createexperiment with researcherInput carrying exactly the five declared fields, and navigates on success", async () => { + useDocumentData.mockReturnValue([ + { + refreshToken: "osf-refresh-token", + connectedAccounts: { dataverse: true }, + }, + false, + undefined, + ]); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, experimentID: "exp-dv-1" }), + }); + + renderPage(); + selectDataverse(); + + fireEvent.change(screen.getByLabelText(/^Title$/i), { + target: { value: "My Dataverse Study" }, + }); + fillDataverseFields(); + + fireEvent.click(screen.getByRole("button", { name: /^Create$/i })); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/createexperiment"); + const body = JSON.parse(options.body); + expect(body.provider).toBe("dataverse"); + expect(body.title).toBe("My Dataverse Study"); + expect(Object.keys(body.researcherInput).sort()).toEqual( + ["authorName", "collectionAlias", "contactEmail", "description", "subject"].sort() + ); + expect(body.researcherInput).toEqual({ + collectionAlias: "my-lab", + authorName: "Smith, Jane", + contactEmail: "jane@example.edu", + description: "A study about things", + subject: "Social Sciences", + }); + + await waitFor(() => + expect(mockPush).toHaveBeenCalledWith("/admin/exp-dv-1") + ); + }); + + it("a picked Drive folder does not leak onto a Dataverse create", async () => { + // Regression guard: selectedFolder is submitted as the top-level + // parentFolderId, so it must be cleared when the provider changes -- + // otherwise a Drive folder id rides along on a Dataverse request. + useDocumentData.mockReturnValue([ + { + refreshToken: "osf-refresh-token", + connectedAccounts: { dataverse: true, gdrive: true }, + }, + false, + undefined, + ]); + global.fetch.mockImplementation((url) => { + if (url.includes("/api/getprovideraccesstoken")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ accessToken: "drive-token" }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ experimentId: "exp-no-leak" }), + }); + }); + mockPickDriveFolder.mockResolvedValue({ + id: "leaky-folder-id", + name: "leaky-folder", + }); + + renderPage(); + + // Pick a Drive folder on the gdrive path. + selectGoogleDrive(); + fireEvent.change(screen.getByLabelText(/^Title$/i), { + target: { value: "Leak check" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Choose Drive folder/i })); + await waitFor(() => + expect(screen.getByText("leaky-folder")).toBeInTheDocument() + ); + + // Switch to Dataverse and submit. + selectDataverse(); + fillDataverseFields(); + fireEvent.click(screen.getByRole("button", { name: /^Create$/i })); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const createCall = global.fetch.mock.calls.find(([url]) => + url.includes("/api/createexperiment") + ); + const body = JSON.parse(createCall[1].body); + expect(body.provider).toBe("dataverse"); + expect(body.parentFolderId).toBeUndefined(); + }); + + it("switching provider away and back clears the entered values (no stale carry-over)", () => { + useDocumentData.mockReturnValue([ + { + refreshToken: "osf-refresh-token", + connectedAccounts: { dataverse: true, gdrive: true }, + }, + false, + undefined, + ]); + + renderPage(); + selectDataverse(); + fillDataverseFields({ collectionAlias: "stale-lab" }); + + expect(screen.getByLabelText(/Collection alias/i)).toHaveValue("stale-lab"); + + selectGoogleDrive(); + selectDataverse(); + + expect(screen.getByLabelText(/Collection alias/i)).toHaveValue(""); + expect(screen.getByLabelText(/Author name/i)).toHaveValue(""); + expect(screen.getByLabelText(/Contact email/i)).toHaveValue(""); + expect(screen.getByLabelText(/Description/i)).toHaveValue(""); + expect(screen.getByLabelText(/Subject/i)).toHaveValue(""); + }); +}); diff --git a/lib/experiment-creation.js b/lib/experiment-creation.js index 4fc22df..a4700a4 100644 --- a/lib/experiment-creation.js +++ b/lib/experiment-creation.js @@ -161,7 +161,7 @@ export async function createExperimentDocument(experimentData) { // decrypted provider token that only resolve-token.ts can produce. This // helper just calls that endpoint and normalizes the response shape to // match createExperiment()'s { experimentId } contract. -export async function createProviderExperiment(provider, title, parentFolderId) { +export async function createProviderExperiment(provider, title, parentFolderId, researcherInput) { const user = auth.currentUser; if (!user) { throw new Error("User not authenticated"); @@ -180,6 +180,9 @@ export async function createProviderExperiment(provider, title, parentFolderId) uid: user.uid, idToken, ...(parentFolderId ? { parentFolderId } : {}), + ...(researcherInput && Object.keys(researcherInput).length > 0 + ? { researcherInput } + : {}), }), }); diff --git a/lib/provider-config.js b/lib/provider-config.js index f833c8a..c1dbf59 100644 --- a/lib/provider-config.js +++ b/lib/provider-config.js @@ -7,6 +7,18 @@ // tells the connect UI which flow to run: "oauth2" redirects to the provider's // consent screen, "static-token" collects a pasted token (and, for federated // providers, a server URL) in a form. See components/account/ProviderConnections.js. +// +// `containerInputFields` mirrors the SERVER-side `containerInput` declared on +// each adapter in functions/src/providers/*.ts (see ContainerInputField in +// functions/src/providers/types.ts). THE SERVER IS AUTHORITATIVE: +// create-experiment.ts validates a createDataContainer request against its +// own provider's containerInput and returns a 400 naming exactly the missing +// fields, regardless of what this file says. This client-side copy is +// PRESENTATIONAL ONLY -- it just decides which inputs pages/admin/new.js +// renders and does client-side required-field validation as a UX nicety -- +// and it must be kept in sync by hand with functions/src/providers/*.ts +// whenever containerInput changes there. The duplication is deliberate: +// lib/ is bundled into the Next.js app and cannot import from functions/src/. export const STORAGE_PROVIDERS = { gdrive: { id: "gdrive", @@ -17,6 +29,10 @@ export const STORAGE_PROVIDERS = { `https://drive.google.com/drive/folders/${exp.providerContainer?.folderId}`, containerLabel: "Google Drive Folder", containerLinkText: "Open folder", + // gdrive's only containerInput field (parentId) is "hidden" server-side -- + // supplied by the Google Picker, never typed into a rendered field -- so + // there is nothing here for the new-experiment page to render. + containerInputFields: [], }, dataverse: { id: "dataverse", @@ -42,5 +58,15 @@ export const STORAGE_PROVIDERS = { )}`, containerLabel: "Dataverse Dataset", containerLinkText: "Open dataset", + // Mirrors functions/src/providers/dataverse.ts's containerInput exactly + // (labels, required-ness, placeholders). `description` gets `multiline` + // because the server declares it inputType "textarea". + containerInputFields: [ + { name: "collectionAlias", label: "Collection alias", required: true, placeholder: "my-lab" }, + { name: "authorName", label: "Author name", required: true, placeholder: "Lastname, Firstname" }, + { name: "contactEmail", label: "Contact email", required: true, placeholder: "you@example.edu" }, + { name: "description", label: "Description", required: true, multiline: true }, + { name: "subject", label: "Subject", required: false, placeholder: "Social Sciences" }, + ], }, }; diff --git a/pages/admin/new.js b/pages/admin/new.js index c82ebd4..f155885 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -16,6 +16,7 @@ import { Heading, Field, Input, + Textarea, Spinner, Group, InputAddon, @@ -45,17 +46,42 @@ function NewExperimentForm() { const [region, setRegion] = useState("us"); const [provider, setProvider] = useState("osf"); - const [gdriveTitle, setGdriveTitle] = useState(""); - const [gdriveTitleError, setGdriveTitleError] = useState(false); - const [gdriveSubmitting, setGdriveSubmitting] = useState(false); - const [gdriveError, setGdriveError] = useState(null); + const [providerTitle, setProviderTitle] = useState(""); + const [providerTitleError, setProviderTitleError] = useState(false); + const [providerSubmitting, setProviderSubmitting] = useState(false); + const [providerError, setProviderError] = useState(null); + // Researcher-supplied container fields (see lib/provider-config.js's + // containerInputFields), keyed by field name. Reset on provider change so + // switching providers never carries stale values across (e.g. a + // half-filled Dataverse form leaking into a later Dataverse selection). + const [containerValues, setContainerValues] = useState({}); + const [containerFieldErrors, setContainerFieldErrors] = useState({}); const [selectedFolder, setSelectedFolder] = useState(null); const [folderPickerLoading, setFolderPickerLoading] = useState(false); const [data, loading, error] = useDocumentData(doc(db, "users", user.uid)); const isValid = data && (data.usingPersonalToken ? data.osfTokenValid : data.refreshToken !== ""); - const gdriveConnected = STORAGE_PROVIDERS.gdrive.isConnected(data); + const providerConnected = STORAGE_PROVIDERS[provider]?.isConnected(data); + + const handleProviderChange = (newProvider) => { + setProvider(newProvider); + setContainerValues({}); + setContainerFieldErrors({}); + // A picked Drive folder is meaningless to any other provider, and it is + // sent as the top-level parentFolderId on submit -- without this reset, + // picking a folder and then switching to Dataverse would post a Drive + // folder id on a Dataverse create. (The server folds parentFolderId in + // unconditionally and Dataverse's adapter ignores parentId, so it was + // harmless, but sending it at all is wrong.) The title deliberately + // survives a provider change: it describes the study, not the storage. + setSelectedFolder(null); + }; + + const handleContainerValueChange = (name, value) => { + setContainerValues((prev) => ({ ...prev, [name]: value })); + setContainerFieldErrors((prev) => ({ ...prev, [name]: false })); + }; const handleSubmit = async () => { setIsSubmitting(true); @@ -96,33 +122,65 @@ function NewExperimentForm() { } }; - const handleGdriveSubmit = async () => { - setGdriveSubmitting(true); - setGdriveError(null); + const handleProviderSubmit = async () => { + setProviderSubmitting(true); + setProviderError(null); + + if (providerTitle.length === 0) { + setProviderTitleError(true); + setProviderSubmitting(false); + return; + } + + // Client-side required-field check, mirroring create-experiment.ts's own + // rule (a field is missing when its trimmed value is empty). This is a + // UX nicety only -- the server still validates authoritatively against + // its own containerInput declaration. + const fields = STORAGE_PROVIDERS[provider]?.containerInputFields || []; + const fieldErrors = {}; + let hasMissingField = false; + for (const field of fields) { + const value = containerValues[field.name]; + if (field.required && (!value || value.trim().length === 0)) { + fieldErrors[field.name] = true; + hasMissingField = true; + } + } - if (gdriveTitle.length === 0) { - setGdriveTitleError(true); - setGdriveSubmitting(false); + if (hasMissingField) { + setContainerFieldErrors(fieldErrors); + setProviderSubmitting(false); return; } + // Send only the declared field names, and omit empty optional fields -- + // never spread arbitrary containerValues state. + const researcherInput = {}; + for (const field of fields) { + const value = containerValues[field.name]; + if (value && value.trim().length > 0) { + researcherInput[field.name] = value; + } + } + try { const result = await createProviderExperiment( - "gdrive", - gdriveTitle, - selectedFolder?.id + provider, + providerTitle, + selectedFolder?.id, + researcherInput ); Router.push(`/admin/${result.experimentId}`); } catch (err) { console.error(err); - setGdriveSubmitting(false); - setGdriveError(err.message); + setProviderSubmitting(false); + setProviderError(err.message); } }; const handleChooseFolder = async () => { setFolderPickerLoading(true); - setGdriveError(null); + setProviderError(null); try { const user = auth.currentUser; @@ -160,7 +218,7 @@ function NewExperimentForm() { } } catch (err) { console.error(err); - setGdriveError(err.message); + setProviderError(err.message); } finally { setFolderPickerLoading(false); } @@ -180,26 +238,32 @@ function NewExperimentForm() { <Field.Root> <Field.Label>Where should data be stored?</Field.Label> <HStack gap={6} mt={2} role="radiogroup" aria-label="Where should data be stored?"> + {/* OSF is deliberately hardcoded here and absent from + STORAGE_PROVIDERS -- it keeps its bespoke legacy UI (identity + OAuth flow, existing form below) rather than becoming a + generic provider option. */} <HStack as="label" gap={2} cursor="pointer"> <input type="radio" name="storage-provider" value="osf" checked={provider === "osf"} - onChange={() => setProvider("osf")} + onChange={() => handleProviderChange("osf")} /> <Text>OSF</Text> </HStack> - <HStack as="label" gap={2} cursor="pointer"> - <input - type="radio" - name="storage-provider" - value="gdrive" - checked={provider === "gdrive"} - onChange={() => setProvider("gdrive")} - /> - <Text>Google Drive</Text> - </HStack> + {Object.values(STORAGE_PROVIDERS).map((p) => ( + <HStack as="label" gap={2} cursor="pointer" key={p.id}> + <input + type="radio" + name="storage-provider" + value={p.id} + checked={provider === p.id} + onChange={() => handleProviderChange(p.id)} + /> + <Text>{p.name}</Text> + </HStack> + ))} </HStack> </Field.Root> @@ -294,75 +358,111 @@ function NewExperimentForm() { </VStack> )} - {provider === "gdrive" && !gdriveConnected && ( + {provider !== "osf" && !providerConnected && ( <VStack gap={3}> <Text color="gray.400" textAlign="center"> - DataPipe sends experiment data directly to your Google Drive. - Connect your Google Drive account to get started. + DataPipe sends experiment data directly to your{" "} + {STORAGE_PROVIDERS[provider]?.name}. Connect your{" "} + {STORAGE_PROVIDERS[provider]?.name} account to get started. </Text> <Link href="/admin/account"> <Button variant={"solid"} colorPalette={"brandTeal"} size={"lg"}> - Connect Google Drive Account + Connect {STORAGE_PROVIDERS[provider]?.name} Account </Button> </Link> </VStack> )} - {provider === "gdrive" && gdriveConnected && ( + {provider !== "osf" && providerConnected && ( <> - {gdriveError && ( + {providerError && ( <Text color="red.400" fontSize="sm"> - {gdriveError} + {providerError} </Text> )} - <Field.Root invalid={gdriveTitleError}> + <Field.Root invalid={providerTitleError}> <Field.Label>Title</Field.Label> <Input type="text" - value={gdriveTitle} + value={providerTitle} onChange={(e) => { - setGdriveTitle(e.target.value); - setGdriveTitleError(false); + setProviderTitle(e.target.value); + setProviderTitleError(false); }} /> <Field.ErrorText color="red.400"> This field is required </Field.ErrorText> </Field.Root> - <Field.Root> - <Field.Label>Parent Drive Folder (optional)</Field.Label> - <HStack gap={3}> - <Button - variant="outline" - colorPalette="brandTeal" - size="md" - loading={folderPickerLoading} - onClick={handleChooseFolder} - > - Choose Drive folder - </Button> - {selectedFolder && ( - <HStack gap={2}> - <Text fontSize="sm">{selectedFolder.name}</Text> - <Button - variant="ghost" - size="xs" - onClick={handleClearFolder} - > - Clear - </Button> - </HStack> + + {STORAGE_PROVIDERS[provider]?.containerInputFields.map((field) => ( + <Field.Root key={field.name} invalid={!!containerFieldErrors[field.name]}> + <Field.Label>{field.label}</Field.Label> + {field.multiline ? ( + <Textarea + value={containerValues[field.name] || ""} + placeholder={field.placeholder} + onChange={(e) => + handleContainerValueChange(field.name, e.target.value) + } + /> + ) : ( + <Input + type="text" + value={containerValues[field.name] || ""} + placeholder={field.placeholder} + onChange={(e) => + handleContainerValueChange(field.name, e.target.value) + } + /> )} - </HStack> - <Field.HelperText color="gray"> - {selectedFolder - ? "The experiment's data folder will be created inside this folder." - : "If not set, the experiment's data folder will be created in My Drive/DataPipe."} - </Field.HelperText> - </Field.Root> + <Field.ErrorText color="red.400"> + This field is required + </Field.ErrorText> + </Field.Root> + ))} + + {/* The Google Picker "choose a folder" UI stays gated to gdrive + specifically -- it is a Google product loaded from Google's + JS SDK, not a generic provider capability, so it does not + belong in the containerInputFields render above. */} + {provider === "gdrive" && ( + <Field.Root> + <Field.Label>Parent Drive Folder (optional)</Field.Label> + <HStack gap={3}> + <Button + variant="outline" + colorPalette="brandTeal" + size="md" + loading={folderPickerLoading} + onClick={handleChooseFolder} + > + Choose Drive folder + </Button> + {selectedFolder && ( + <HStack gap={2}> + <Text fontSize="sm">{selectedFolder.name}</Text> + <Button + variant="ghost" + size="xs" + onClick={handleClearFolder} + > + Clear + </Button> + </HStack> + )} + </HStack> + <Field.HelperText color="gray"> + {selectedFolder + ? "The experiment's data folder will be created inside this folder." + : "If not set, the experiment's data folder will be created in My Drive/DataPipe."} + </Field.HelperText> + </Field.Root> + )} + <Button - onClick={handleGdriveSubmit} - loading={gdriveSubmitting} + onClick={handleProviderSubmit} + loading={providerSubmitting} colorPalette={"brandTeal"} > Create From 193e3cdc3d09fa3a62e64571a07c00466ca5e490 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 14:09:37 -0400 Subject: [PATCH 056/181] test: add the Dataverse gating-spike script docs/provider-migration-design.md gates the Dataverse adapter (build step 6) on a live spike that only a human with an account can run. This script is that spike, so the manual part is reduced to getting a token and running one command. It drives the REAL shipping adapter (functions/lib/providers/dataverse.js) rather than a hand-written approximation, so it validates our multipart body, response parsing and error mapping against a live server at the same time as it validates the service -- none of which has ever touched a real Dataverse installation. Covers the three named gates: concurrent-write locking (the disqualifier -- a burst of N writes to ONE dataset, reporting lock rejections), tabular ingest suppression (uploads a real CSV and reads the stored contentType back to confirm tabIngest=false was honored), and silent rename. Also exercises updateFile's delete + re-add path, which exists because /replace is unavailable on a never-published draft. Leaves the dataset in draft and never publishes, so no DOI is minted. Cleanup is opt-in via DATAVERSE_CLEANUP=1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- scripts/dataverse-spike.mjs | 184 ++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 scripts/dataverse-spike.mjs diff --git a/scripts/dataverse-spike.mjs b/scripts/dataverse-spike.mjs new file mode 100644 index 0000000..2dcdaa0 --- /dev/null +++ b/scripts/dataverse-spike.mjs @@ -0,0 +1,184 @@ +// Dataverse gating spike (docs/provider-migration-design.md, build step 6). +// +// Runs the three go/no-go gates against a LIVE Dataverse installation, using +// the real shipping adapter (functions/lib/providers/dataverse.js) rather than +// a hand-written approximation -- so this validates our multipart body, +// response parsing and error mapping at the same time as it validates the +// service. +// +// Usage: +// cd functions && npm run build && cd .. +// DATAVERSE_TOKEN=xxxx DATAVERSE_COLLECTION=my-alias node scripts/dataverse-spike.mjs +// +// Env: +// DATAVERSE_TOKEN (required) API token from your account's API Token tab +// DATAVERSE_COLLECTION (required) alias of a collection you can create datasets in +// DATAVERSE_SERVER (default https://demo.dataverse.org) +// DATAVERSE_BURST (default 40) files written concurrently for gate A +// DATAVERSE_CLEANUP (set to 1 to delete the test dataset when done) +// +// Leaves the dataset in DRAFT and never publishes. Nothing here mints a DOI +// that outlives the run beyond the draft itself. + +import { dataverseProvider } from "../functions/lib/providers/dataverse.js"; + +const token = process.env.DATAVERSE_TOKEN; +const collectionAlias = process.env.DATAVERSE_COLLECTION; +const serverUrl = process.env.DATAVERSE_SERVER || "https://demo.dataverse.org"; +const burst = Number(process.env.DATAVERSE_BURST || 40); + +if (!token || !collectionAlias) { + console.error("DATAVERSE_TOKEN and DATAVERSE_COLLECTION are required. See the header of this file."); + process.exit(1); +} + +const auth = { token, serverUrl }; +const results = []; +const record = (gate, verdict, detail) => { + results.push({ gate, verdict, detail }); + const mark = verdict === "PASS" ? "PASS" : verdict === "FAIL" ? "FAIL" : "INFO"; + console.log(`\n[${mark}] ${gate}\n ${detail}`); +}; + +const meta = (body, contentType) => ({ size: Buffer.byteLength(body), contentType }); + +async function main() { + console.log(`Dataverse spike against ${serverUrl}`); + console.log(`Collection: ${collectionAlias} Burst size: ${burst}\n`); + + // ---- sanity: does the token work at all ------------------------------- + const valid = await dataverseProvider.validateStaticToken(auth); + if (!valid) { + console.error("Token rejected by /api/users/:me. Check DATAVERSE_TOKEN and DATAVERSE_SERVER."); + process.exit(1); + } + console.log("Token accepted."); + + // ---- create the dataset ------------------------------------------------ + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + let container; + try { + container = await dataverseProvider.createDataContainer(auth, { + name: `DataPipe spike ${stamp}`, + title: `DataPipe spike ${stamp}`, + collectionAlias, + authorName: "DataPipe, Spike", + contactEmail: "spike@example.edu", + description: "Throwaway dataset created by scripts/dataverse-spike.mjs. Safe to delete.", + }); + } catch (e) { + console.error(`createDataContainer failed: ${e.message}`); + process.exit(1); + } + console.log(`Dataset created: id=${container.datasetId} doi=${container.persistentId}`); + console.log(` ${serverUrl}/dataset.xhtml?persistentId=${encodeURIComponent(container.persistentId)}`); + + // ---- GATE A: concurrent-write locking ---------------------------------- + // The named disqualifier. A class section submits 30-100 sessions to ONE + // dataset within a minute; if writes serialize behind a lock at a rate that + // cannot absorb that, Dataverse is out. + const payload = JSON.stringify([{ trial_type: "html-keyboard-response", rt: 421 }]); + const started = Date.now(); + const writes = await Promise.all( + Array.from({ length: burst }, (_, i) => + dataverseProvider + .writeSessionFile(auth, container, `burst-${String(i).padStart(3, "0")}.json`, payload, meta(payload, "application/json")) + .then((r) => r) + .catch((e) => ({ success: false, error: "THREW", providerMessage: e.message })) + ) + ); + const elapsed = ((Date.now() - started) / 1000).toFixed(1); + const ok = writes.filter((w) => w.success); + const bad = writes.filter((w) => !w.success); + const locked = bad.filter((w) => /dataset lock/i.test(w.providerMessage || "")); + const byError = bad.reduce((acc, w) => ({ ...acc, [w.error]: (acc[w.error] || 0) + 1 }), {}); + + record( + "GATE A - concurrent writes", + bad.length === 0 ? "PASS" : locked.length > 0 ? "FAIL" : "INFO", + `${ok.length}/${burst} succeeded in ${elapsed}s. ` + + `${locked.length} lock rejections. ` + + (bad.length ? `Failures by code: ${JSON.stringify(byError)}. Sample: ${bad[0].providerMessage}` : "No failures.") + ); + if (locked.length > 0) { + console.log(" Lock rejections are the disqualifying signal -- note the rate and whether the retry queue could absorb it."); + } + + // ---- GATE B: tabular ingest suppression -------------------------------- + // We always send tabIngest="false". If a CSV still comes back as + // tab-separated, Dataverse rewrote the researcher's data. + const csv = "trial_type,rt\nhtml-keyboard-response,421\nhtml-keyboard-response,530\n"; + const csvWrite = await dataverseProvider.writeSessionFile(auth, container, "ingest-check.csv", csv, meta(csv, "text/csv")); + if (!csvWrite.success) { + record("GATE B - tabular ingest", "INFO", `CSV upload failed outright: ${csvWrite.providerMessage}`); + } else { + // Ingest is asynchronous; give it a moment before reading the type back. + await new Promise((r) => setTimeout(r, 15000)); + const listing = await fetch(`${serverUrl}/api/datasets/${container.datasetId}/versions/:draft/files`, { + headers: { "X-Dataverse-key": token }, + }); + const body = await listing.json(); + const entry = (body.data || []).find((f) => (f.label || "").startsWith("ingest-check")); + const type = entry?.dataFile?.contentType; + const ingested = type && /tab-separated/i.test(type); + record( + "GATE B - tabular ingest", + ingested ? "FAIL" : "PASS", + `Stored as label="${entry?.label}" contentType="${type}". ` + + (ingested + ? "Dataverse INGESTED the CSV despite tabIngest=false -- suppression is not honored on this installation." + : "tabIngest=false was honored; the CSV was stored as-is.") + ); + } + + // ---- GATE C: silent rename --------------------------------------------- + // Confirms duplicates are renamed rather than rejected, and that we read the + // stored name back rather than trusting the requested one. + const a = await dataverseProvider.writeSessionFile(auth, container, "dupe.json", payload, meta(payload, "application/json")); + const b = await dataverseProvider.writeSessionFile(auth, container, "dupe.json", payload, meta(payload, "application/json")); + record( + "GATE C - silent rename", + a.success && b.success && b.storedFilename !== "dupe.json" ? "PASS" : "INFO", + `First upload stored as "${a.storedFilename}", second as "${b.storedFilename}". ` + + (b.storedFilename === "dupe.json" + ? "Second upload kept the same name -- verify no data was overwritten." + : "Rename detected and read back correctly from the response.") + ); + + // ---- updateFile: DELETE + re-add on a draft ---------------------------- + // /replace is unavailable on a never-published draft, so the adapter deletes + // and re-adds. Confirm that actually works against a live server. + const updated = JSON.stringify([{ trial_type: "updated", rt: 999 }]); + const upd = await dataverseProvider.updateFile(auth, container, a.fileRef, updated, meta(updated, "application/json")); + record( + "updateFile (delete + re-add)", + upd.success ? "PASS" : "FAIL", + upd.success + ? `Re-added as "${upd.storedFilename}" with new id ${upd.fileRef.id} (was ${a.fileRef.id}).` + : `Failed: ${upd.error} ${upd.providerMessage}` + ); + + // ---- listing round-trip ------------------------------------------------- + const files = await dataverseProvider.listFiles(auth, container); + record("listFiles", "INFO", `${files.length} files in the draft. Sample: ${files.slice(0, 3).map((f) => f.name).join(", ")}`); + + // ---- summary ------------------------------------------------------------ + console.log("\n==================== SUMMARY ===================="); + for (const r of results) console.log(`${r.verdict.padEnd(5)} ${r.gate}`); + const failed = results.filter((r) => r.verdict === "FAIL"); + console.log(failed.length === 0 ? "\nNo gate failed." : `\n${failed.length} gate(s) FAILED -- see detail above.`); + console.log(`\nDataset left in draft: ${serverUrl}/dataset.xhtml?persistentId=${encodeURIComponent(container.persistentId)}`); + + if (process.env.DATAVERSE_CLEANUP === "1") { + const del = await fetch(`${serverUrl}/api/datasets/${container.datasetId}`, { + method: "DELETE", + headers: { "X-Dataverse-key": token }, + }); + console.log(del.ok ? "Cleanup: draft dataset deleted." : `Cleanup failed (${del.status}) -- delete it manually.`); + } +} + +main().catch((e) => { + console.error("\nSpike aborted:", e); + process.exit(1); +}); From e6b8a606a35f23b276bc852439f30d95ccdb00bd Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 14:41:48 -0400 Subject: [PATCH 057/181] fix: Dataverse create accepts 201; record live spike result (GATE A FAILED) Ran the gating spike against demo.dataverse.org with the real adapter. GATE A FAILED. Exactly one concurrent write to a dataset succeeds; every other in-flight write is rejected. There is no safe concurrency above 1 -- 2, 3, 4, 6 and 12 concurrent writes each land exactly one, and 30 land two. Sequential writes are flawless (8/8, ~600ms each). Rejections arrive in ~250ms as a generic 400 "Failed to add file to dataset.", NOT the 403 "dataset lock" the design anticipated; failing at concurrency 2 points at optimistic locking on the dataset version rather than demo being small. Against the stated criterion this is disqualifying: writes don't merely serialize, concurrent ones fail. Not data loss -- the 400 maps to UNAVAILABLE and the queue retries -- but a 30-person section would push ~29 submissions into the retry queue to drain serially against a 1-minute backoff. Options recorded in the design doc: swap to Box (the pre-approved path), re-test on a production installation first, or serialize writes per dataset behind a distributed lock. Gates B and C passed: tabIngest=false was honored (CSV stored as text/csv, not ingested), duplicate names are silently renamed and read back correctly, and updateFile's DELETE + re-add works on a draft. Two code fixes fell out of the run: - createDataContainer hardcoded `status !== 200`, but a real Dataverse returns 201 Created, so EVERY live dataset creation threw. It now uses isSuccessStatus like every other method. The guides are wrong in both directions here: they claim create returns 200 and /add returns 201, and the truth is the reverse. Pinned by a regression test. - The spike script reused one payload for every burst file, which confounded Gate A: Dataverse rejects duplicate CONTENT by checksum, not just duplicate names, so dedup rejections were indistinguishable from lock failures. It now sends distinct content per file and counts the two signatures separately. The content-dedup behavior is itself an undocumented finding: a byte-identical file under a different name is accepted with a warning, under the same name it is a 400. DataPipe's per-session filenames make this survivable. Full suite green: 43 suites / 334 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/provider-migration-design.md | 64 ++++++++++++++++++- .../src/__tests__/providers-dataverse.test.js | 30 +++++++++ functions/src/providers/dataverse.ts | 8 ++- scripts/dataverse-spike.mjs | 25 ++++++-- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index ec8182c..88ec6f2 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -355,7 +355,69 @@ provider, it does not trigger a redesign. flow has more failure modes than a single PUT; verify behavior under concurrent submissions, including media-sized files. -### Dataverse spike — partial findings (source-verified, NOT yet empirical) +### Dataverse spike — RESULT: GATE A FAILED (live, demo.dataverse.org, 2026-07-26) + +Run with `scripts/dataverse-spike.mjs` plus targeted follow-ups, against +demo.dataverse.org using the real adapter. + +**Gate A (concurrent-write locking): FAILED.** Exactly ONE concurrent write to +a dataset succeeds; every other in-flight write is rejected. This is not a +degradation curve — there is no safe concurrency above 1: + +| concurrent writes to one dataset (distinct content) | succeeded | +|---|---| +| 2 | 1 | +| 3 | 1 | +| 4 | 1 | +| 6 | 1 | +| 12 | 1 | +| 30 | 2 | + +Sequential writes are flawless (8/8) at ~600 ms each, so a dataset absorbs +~100 files/minute *if strictly serialized*. Rejections come back in ~250 ms as +a generic **400 `Failed to add file to dataset.`** — NOT the 403 `dataset +lock` the design anticipated. The speed and the failure at concurrency 2 both +point at optimistic-locking on the dataset version, i.e. architectural rather +than demo being underpowered. + +Against the stated criterion — "if the spike shows writes serialize through a +lock at a rate that can't absorb a class section, that is disqualifying" — +this fails: writes don't merely serialize, concurrent ones outright fail. + +Not data loss: the 400 maps to `UNAVAILABLE`, which the upload queue retries. +But a 30-person section would push ~29 submissions into the retry queue to +drain serially against a 1-minute backoff, making the queue the primary write +path rather than the exception. + +**Also found: Dataverse rejects duplicate CONTENT, not just duplicate names.** +Re-uploading a byte-identical file draws `This file has the same content as +X that is in the dataset.` Under a *different* filename it is accepted with +that text as a warning (200); under the same name it is a 400. DataPipe's +filenames are unique per session, so this is survivable — but it is a real +provider behavior nobody had documented, and it confounded the first spike run +(the script reused one payload, so lock failures and dedup failures were +indistinguishable). `scripts/dataverse-spike.mjs` now sends distinct content +per burst file. + +**Gates B and C passed.** `tabIngest=false` was honored (CSV stored as +`text/csv`, not ingested to `.tab`). Duplicate names are silently renamed +(`dupe.json` → `dupe-1.json`) and the adapter reads the stored name back +correctly. `updateFile`'s DELETE + re-add also works on a draft. + +**Corrections to the API contract, both live-verified:** dataset creation +returns **201**, not the documented 200 (this threw on every real create until +fixed); `/add` returns **200**, not the documented 201. The published guides +are wrong in both directions. + +Options, in the order they should be considered: (1) take the design's +pre-approved path and swap Dataverse for Box; (2) re-run against a production +institutional installation before deciding — cheap, and the one thing that +could overturn this, though the concurrency-2 failure suggests it will not; +(3) keep Dataverse but serialize writes per dataset, which needs a distributed +single-writer lock across Cloud Functions instances (Firestore-based), caps an +experiment at ~100 submissions/minute, and is real architecture, not a tweak. + +### Earlier source-verified findings (superseded in part by the live run above) Recorded 2026-07-25 alongside the Dataverse adapter (backend only, not exposed). These come from reading Dataverse's Java source and its IQSS diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js index 1488fc8..367f42b 100644 --- a/functions/src/__tests__/providers-dataverse.test.js +++ b/functions/src/__tests__/providers-dataverse.test.js @@ -600,6 +600,36 @@ describe("6. downloadFile", () => { }); describe("7. createDataContainer", () => { + it("accepts a 201 Created, which is what a real Dataverse returns", async () => { + // Regression guard, found live against demo.dataverse.org on 2026-07-26: + // the guides say dataset creation returns 200, but the server returns 201. + // This method used to hardcode `status !== 200`, so EVERY real dataset + // creation threw. (The docs are wrong both ways -- they also claim /add + // returns 201 when it actually returns 200.) + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + statusText: "Created", + jsonBody: { status: "OK", data: { id: 456, persistentId: "doi:10.5072/FK2/CREATED" } }, + }) + ); + + const result = await dataverseProvider.createDataContainer(auth, { + collectionAlias: "my-collection", + title: "My Study", + authorName: "Ada Lovelace", + contactEmail: "ada@example.edu", + description: "A study", + }); + + expect(result).toEqual({ + provider: "dataverse", + datasetId: 456, + persistentId: "doi:10.5072/FK2/CREATED", + serverUrl: SERVER_URL, + }); + }); + it("posts the citation metadata block and returns datasetId/persistentId/serverUrl", async () => { mockFetch.mockResolvedValueOnce( mockResponse({ diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index 9a29c95..e39d10d 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -297,7 +297,13 @@ export const dataverseProvider: StorageProvider = { // createDataContainer has no error union in the StorageProvider // interface (matches osf/gdrive) -- signal failure by throwing, same as // gdrive's folder-creation failures. - if (response.status !== 200) { + // + // Accept 200 OR 201 via isSuccessStatus. The guides say dataset creation + // returns 200, but demo.dataverse.org actually returns 201 Created -- + // verified live, 2026-07-26. (The docs are wrong in BOTH directions here: + // they also claim /add returns 201 when it really returns 200.) Hardcoding + // 200 made every real dataset creation throw. + if (!isSuccessStatus(response.status)) { const mapped = await mapErrorResponse(response); throw new Error(`Dataverse dataset creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`); } diff --git a/scripts/dataverse-spike.mjs b/scripts/dataverse-spike.mjs index 2dcdaa0..229cce9 100644 --- a/scripts/dataverse-spike.mjs +++ b/scripts/dataverse-spike.mjs @@ -78,19 +78,32 @@ async function main() { // dataset within a minute; if writes serialize behind a lock at a rate that // cannot absorb that, Dataverse is out. const payload = JSON.stringify([{ trial_type: "html-keyboard-response", rt: 421 }]); + // Every burst file gets DISTINCT content. Dataverse rejects files whose + // content checksum matches one already in the dataset, so reusing one + // payload confounds this gate: the failures look like lock contention when + // they are really duplicate-content rejections. Verified live 2026-07-26. + const burstPayload = (i) => JSON.stringify([{ trial_type: "html-keyboard-response", rt: 400 + i, session: `burst-${i}` }]); const started = Date.now(); const writes = await Promise.all( - Array.from({ length: burst }, (_, i) => - dataverseProvider - .writeSessionFile(auth, container, `burst-${String(i).padStart(3, "0")}.json`, payload, meta(payload, "application/json")) + Array.from({ length: burst }, (_, i) => { + const p = burstPayload(i); + return dataverseProvider + .writeSessionFile(auth, container, `burst-${String(i).padStart(3, "0")}.json`, p, meta(p, "application/json")) .then((r) => r) - .catch((e) => ({ success: false, error: "THREW", providerMessage: e.message })) - ) + .catch((e) => ({ success: false, error: "THREW", providerMessage: e.message })); + }) ); const elapsed = ((Date.now() - started) / 1000).toFixed(1); const ok = writes.filter((w) => w.success); const bad = writes.filter((w) => !w.success); - const locked = bad.filter((w) => /dataset lock/i.test(w.providerMessage || "")); + // Concurrent adds are rejected with a generic 400 "Failed to add file to + // dataset.", NOT the 403 "dataset lock" the design doc anticipated -- so + // count both signatures. + const locked = bad.filter((w) => /dataset lock|failed to add file/i.test(w.providerMessage || "")); + const dupeContent = bad.filter((w) => /same content/i.test(w.providerMessage || "")); + if (dupeContent.length) { + console.log(` NOTE: ${dupeContent.length} rejected as duplicate CONTENT -- these are not lock failures.`); + } const byError = bad.reduce((acc, w) => ({ ...acc, [w.error]: (acc[w.error] || 0) + 1 }), {}); record( From ff5d8057b4a3260524aa774a7b4378f897b307ee Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 15:07:50 -0400 Subject: [PATCH 058/181] feat: fast retry tier for transient provider contention The upload queue treated every failure as outage-scale: a 1-hour first retry, 2^n-HOUR backoff, on an HOURLY worker. Dataverse accepts exactly one concurrent write per dataset (verified live in e6b8a60) and rejects the rest in ~250ms with a transient error that clears in seconds -- so a submission that merely collided waited 1-2 hours. That mismatch, not the concurrency limit itself, was the real problem. Adds CONTENTION to the provider error taxonomy. Dataverse maps its concurrent-write 400 to it, deliberately EXCLUDING the byte-identical "same content" 400, which is permanent and would spin uselessly on a fast tier. That exclusion is commented with an open question it exposes: if a write succeeded server-side but the response was lost, the retry re-uploads identical content and gets the same-content error, so treating it as failure can mark a SUCCEEDED upload as failed. Worth a separate look; not fixed here. Tiering, with the boundary defined once in queue-upload.ts and imported by the worker so the two cannot drift: CONTENTION / RATE_LIMITED -> first retry 60s, backoff 2^n minutes, cap 30m everything else, incl. no code -> first retry 1h, 2^n hours, cap 24h (unchanged) Retry-After still wins where present, clamped to its own tier's cap. The worker moves from hourly to every 5 minutes, which is what makes a 60-second nextRetryAt mean anything, and from 10 to 25 items per run. The query already gates on nextRetryAt, so slow-tier items are untouched -- they simply aren't due. Drain rate goes from 10/hour to 300/hour. For a class of 30 spread over a few minutes this puts the handful of collided submissions in within a minute or two instead of an hour, which makes Dataverse's concurrency-1 limit a non-issue at realistic arrival rates. Testing note: the arithmetic-only backoff tests replicate the production formula and so cannot catch a mis-read field or an inverted tier check, so this also drives the REAL handleRetryFailure through retryPendingUploads, using a queue item that fails at token resolution (no network needed). Verified to fail when isFastRetry is stubbed to false. retryPendingUploads gains an ownerScope test seam, mirroring recoverPendingUploads' prefix seam from ddef109: unscoped it sweeps and mutates every pending queue doc in the shared emulator, which is exactly the cross-suite hazard that commit fixed. Filtering is in memory so the production query shape, and its Firestore index, are untouched. Full suite green across two runs: 43 suites / 346 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/providers-dataverse.test.js | 76 ++++++ functions/src/__tests__/upload-queue.test.js | 253 ++++++++++++++++++ functions/src/api-base64.ts | 2 +- functions/src/api-data.ts | 7 +- functions/src/metadata-derived-upload.ts | 11 +- functions/src/providers/dataverse.ts | 22 ++ functions/src/providers/types.ts | 11 +- functions/src/queue-upload.ts | 31 ++- functions/src/scheduled-upload-retry.ts | 67 ++++- 9 files changed, 458 insertions(+), 22 deletions(-) diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js index 367f42b..73c2fdf 100644 --- a/functions/src/__tests__/providers-dataverse.test.js +++ b/functions/src/__tests__/providers-dataverse.test.js @@ -357,6 +357,82 @@ describe("3. error mapping", () => { }); }); + it("maps a 400 'Failed to add file to dataset.' to CONTENTION (concurrent write rejected)", async () => { + // Live-verified against demo.dataverse.org, 2026-07-26: exactly one + // concurrent write per dataset succeeds, every other in-flight write is + // rejected with this generic 400 in ~250ms. It clears in seconds, so the + // upload queue should retry it on the fast tier rather than the + // outage-scale backoff UNAVAILABLE gets. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 400, + statusText: "Bad Request", + jsonBody: { status: "ERROR", message: "Failed to add file to dataset." }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result).toEqual({ + success: false, + error: "CONTENTION", + providerStatus: 400, + providerMessage: "Failed to add file to dataset.", + retryAfter: null, + }); + }); + + it("does NOT map a 400 'same content' rejection to CONTENTION (regression guard for the exclusion)", async () => { + // Dataverse also rejects byte-identical content with this message, which + // also contains "Failed to add file to dataset." -- but re-uploading + // identical content is not transient the way a concurrent-write + // collision is, so this must NOT be treated as CONTENTION (retrying fast + // would just spin uselessly). It falls through to the existing + // UNAVAILABLE mapping instead. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 400, + statusText: "Bad Request", + jsonBody: { + status: "ERROR", + message: "This file has the same content as an existing file. \nFailed to add file to dataset.", + }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result.error).toBe("UNAVAILABLE"); + expect(result.error).not.toBe("CONTENTION"); + }); + + it("still maps the existing size-limit 400 to QUOTA_EXCEEDED, not CONTENTION (order-of-checks regression guard)", async () => { + // The size-limit/quota checks must keep winning over the new CONTENTION + // check for their own 400 messages -- they don't mention "failed to add + // file" so this mostly documents the existing behavior stays intact + // after the new branch was inserted between them and the 429 check. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 400, + statusText: "Bad Request", + jsonBody: { status: "ERROR", message: "the file exceeds the size limit of 1000 bytes" }, + }) + ); + + const result = await dataverseProvider.writeSessionFile(auth, container, "file.json", "data", { + size: 4, + contentType: "application/json", + }); + + expect(result.error).toBe("QUOTA_EXCEEDED"); + }); + it("maps anything else (e.g. 500) to UNAVAILABLE", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ status: 500, statusText: "Internal Server Error", jsonBody: undefined })); diff --git a/functions/src/__tests__/upload-queue.test.js b/functions/src/__tests__/upload-queue.test.js index 2c80aa5..6f9a932 100644 --- a/functions/src/__tests__/upload-queue.test.js +++ b/functions/src/__tests__/upload-queue.test.js @@ -4,8 +4,32 @@ import { initializeApp, deleteApp, getApp } from "firebase-admin/app"; import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +// app.js (imported transitively by the compiled queue-upload.js below, via a +// dynamic import so it runs AFTER these process.env assignments) calls +// initializeApp() with no args, which reads the default bucket from +// FIREBASE_CONFIG -- set it so storage.bucket() resolves to the same +// emulator bucket used elsewhere in the suite. Mirrors +// early-persist-emulator.test.js / scheduled-pending-recovery-emulator.test.js. +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +// Importing the compiled scheduled-upload-retry.js pulls in providers/index.js +// and therefore every adapter, each of which imports "node-fetch" at module +// scope. node-fetch is ESM-only and Jest's CJS transform can't parse it. This +// suite never makes a provider HTTP call (its queue items fail at token +// resolution, before any network use), so a bare stub is enough -- same +// convention as providers-*.test.js. +jest.mock("node-fetch", () => ({ + __esModule: true, + default: jest.fn(), +})); const config = { projectId: "datapipe-test" }; @@ -13,6 +37,7 @@ jest.setTimeout(30000); let db; let app; +let queueUpload; beforeAll(async () => { try { @@ -21,6 +46,15 @@ beforeAll(async () => { app = initializeApp(config, "upload-queue-test"); } db = getFirestore(app); + + // Dynamic import, deferred until after the process.env assignments above + // run — same seam as pending-recovery-provider-regression.test.js's + // `await import("../../lib/scheduled-pending-recovery.js")`. queue-upload.js + // is the COMPILED module (functions/lib/, so `npm run build` must run + // first), and its app.js does a bare, unnamed initializeApp() -- distinct + // from this suite's own NAMED "upload-queue-test" app above, so the two + // don't collide. + ({ default: queueUpload } = await import("../../lib/queue-upload.js")); }); // Only the docs THIS suite created. A collection-wide wipe here used to @@ -151,6 +185,129 @@ describe("handleRetryFailure backoff", () => { }); }); +// scheduled-upload-retry.ts's handleRetryFailure is not exported, so these +// mirror the arithmetic (same convention as the "handleRetryFailure backoff" +// describe block above, which also replicates the production formula rather +// than calling it directly) instead of exercising the real function. See the +// "queueUpload tiers the first nextRetryAt" describe block below for coverage +// of the piece that IS reachable: isFastRetry and queueUpload's own tiering. +describe("scheduled-upload-retry tiered backoff arithmetic", () => { + const FAST_MAX_BACKOFF_MS = 30 * 60 * 1000; // 30 minutes + const SLOW_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24 hours, unchanged + + test("fast tier (CONTENTION/RATE_LIMITED) produces ~2, 4, 8, 16, 30 minutes", () => { + const expectedMinutes = [2, 4, 8, 16, 30]; + for (let retryCount = 1; retryCount <= 5; retryCount++) { + const backoffMs = Math.min(Math.pow(2, retryCount) * 60 * 1000, FAST_MAX_BACKOFF_MS); + expect(backoffMs).toBe(expectedMinutes[retryCount - 1] * 60 * 1000); + } + }); + + test("slow tier (everything else) is unchanged: ~2, 4, 8, 16, 24 hours", () => { + const expectedHours = [2, 4, 8, 16, 24]; + for (let retryCount = 1; retryCount <= 5; retryCount++) { + const backoffMs = Math.min(Math.pow(2, retryCount) * 60 * 60 * 1000, SLOW_MAX_BACKOFF_MS); + expect(backoffMs).toBe(expectedHours[retryCount - 1] * 60 * 60 * 1000); + } + }); + + test("a Retry-After header is clamped to the fast tier's shorter cap, not the slow tier's", () => { + const retryAfterSeconds = 3600; // 1 hour — larger than the fast cap, smaller than the slow cap + const fastBackoffMs = Math.min(retryAfterSeconds * 1000, FAST_MAX_BACKOFF_MS); + const slowBackoffMs = Math.min(retryAfterSeconds * 1000, SLOW_MAX_BACKOFF_MS); + expect(fastBackoffMs).toBe(FAST_MAX_BACKOFF_MS); + expect(slowBackoffMs).toBe(retryAfterSeconds * 1000); + }); +}); + +describe("queueUpload tiers the first nextRetryAt by providerErrorCode", () => { + test("a CONTENTION providerErrorCode sets nextRetryAt ~60 seconds out (fast tier)", async () => { + const experimentID = `queue-fast-tier-${randomUUID()}`; + const filename = `file-${randomUUID()}.json`; + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + queueDoc(docId); // scope cleanup to this suite, per the queueDoc() convention above + + const before = Date.now(); + await queueUpload({ + experimentID, + owner: "upload-queue-test-owner", + filename, + data: "[]", + dataType: "data", + osfFilesLink: "https://osf.io/files/", + errorCode: 400, + providerErrorCode: "CONTENTION", + sessionIncremented: true, + }); + const after = Date.now(); + + const doc = await db.collection("uploadQueue").doc(docId).get(); + expect(doc.exists).toBe(true); + expect(doc.data().providerErrorCode).toBe("CONTENTION"); + + const deltaMs = doc.data().nextRetryAt.toMillis() - before; + // ~60 seconds, with slack for the ~1 hour it would be if the fast tier + // were not applied. + expect(deltaMs).toBeGreaterThanOrEqual(59 * 1000); + expect(deltaMs).toBeLessThan(after - before + 5 * 60 * 1000); + }); + + test("no providerErrorCode sets nextRetryAt ~1 hour out (slow tier, unchanged)", async () => { + const experimentID = `queue-slow-tier-undefined-${randomUUID()}`; + const filename = `file-${randomUUID()}.json`; + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + queueDoc(docId); + + const before = Date.now(); + await queueUpload({ + experimentID, + owner: "upload-queue-test-owner", + filename, + data: "[]", + dataType: "data", + osfFilesLink: "https://osf.io/files/", + errorCode: 0, + sessionIncremented: true, + }); + + const doc = await db.collection("uploadQueue").doc(docId).get(); + expect(doc.exists).toBe(true); + expect(doc.data().providerErrorCode).toBeUndefined(); + + const deltaMs = doc.data().nextRetryAt.toMillis() - before; + expect(deltaMs).toBeGreaterThan(55 * 60 * 1000); + expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); + }); + + test("an UNAVAILABLE providerErrorCode also sets nextRetryAt ~1 hour out (slow tier)", async () => { + const experimentID = `queue-slow-tier-unavailable-${randomUUID()}`; + const filename = `file-${randomUUID()}.json`; + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + queueDoc(docId); + + const before = Date.now(); + await queueUpload({ + experimentID, + owner: "upload-queue-test-owner", + filename, + data: "[]", + dataType: "data", + osfFilesLink: "https://osf.io/files/", + errorCode: 500, + providerErrorCode: "UNAVAILABLE", + sessionIncremented: true, + }); + + const doc = await db.collection("uploadQueue").doc(docId).get(); + expect(doc.exists).toBe(true); + expect(doc.data().providerErrorCode).toBe("UNAVAILABLE"); + + const deltaMs = doc.data().nextRetryAt.toMillis() - before; + expect(deltaMs).toBeGreaterThan(55 * 60 * 1000); + expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); + }); +}); + describe("queue entry lifecycle in Firestore", () => { test("pending entry can transition to processing", async () => { const docRef = queueDoc("lifecycle-test"); @@ -226,3 +383,99 @@ describe("queue entry lifecycle in Firestore", () => { ); }); }); + +// The tiered backoff that MATTERS lives inside scheduled-upload-retry.ts's +// handleRetryFailure, which is not exported. The arithmetic blocks above +// replicate its formula and so cannot catch a bug in the real function (a +// mis-read field name, an inverted tier test). These drive the REAL worker +// instead, via a queue item whose token resolution fails -- a dataverse +// experiment whose owner has no dataverse connection -- which routes straight +// to handleRetryFailure without any network call. +// +// retryPendingUploads is scoped to this suite's own owner id. Unscoped it +// sweeps and mutates every pending uploadQueue doc in the shared emulator, +// which is the cross-suite hazard fixed in ddef109 for the sibling recovery +// worker; re-introducing it here would make other suites flaky again. +describe("tiered backoff, exercised through the real retry worker", () => { + let retryPendingUploads; + + beforeAll(async () => { + ({ retryPendingUploads } = await import("../../lib/scheduled-upload-retry.js")); + }); + + async function seedDueItem(providerErrorCode) { + const owner = `retry-tier-owner-${randomUUID()}`; + const experimentID = `retry-tier-exp-${randomUUID()}`; + const docId = `${experimentID}:data.json`.replace(/[/\\]/g, "_"); + + // Owner exists but has NO connectedAccounts.dataverse -> resolveToken + // returns PROVIDER_NOT_CONNECTED -> handleRetryFailure. + await db.collection("users").doc(owner).set({ email: `${owner}@example.test` }); + await db.collection("experiments").doc(experimentID).set({ + active: true, + owner, + storageProvider: "dataverse", + providerContainer: { provider: "dataverse", datasetId: 1, persistentId: "doi:x/y", serverUrl: "https://example.test" }, + }); + + const doc = { + experimentID, + owner, + filename: "data.json", + storagePath: `upload-queue/${docId}`, + dataType: "data", + status: "pending", + errorCode: 400, + retryCount: 0, + maxRetries: 5, + createdAt: Timestamp.now(), + lastAttemptAt: null, + // Already due. + nextRetryAt: Timestamp.fromMillis(Date.now() - 1000), + completedAt: null, + failureReason: null, + deduplicationKey: `${experimentID}:data.json`, + sessionIncremented: true, + }; + if (providerErrorCode) doc.providerErrorCode = providerErrorCode; + + await queueDoc(docId).set(doc); + return { owner, docId }; + } + + it("CONTENTION reschedules in MINUTES, not hours", async () => { + const { owner, docId } = await seedDueItem("CONTENTION"); + + await retryPendingUploads(owner); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(after.status).toBe("pending"); + expect(after.retryCount).toBe(1); + + const delayMs = after.nextRetryAt.toMillis() - Date.now(); + // retryCount 1 on the fast tier => 2^1 * 60s = 2 minutes. + expect(delayMs).toBeGreaterThan(30 * 1000); + expect(delayMs).toBeLessThan(10 * 60 * 1000); + }); + + it("a slow-tier code still reschedules in HOURS (regression)", async () => { + const { owner, docId } = await seedDueItem("UNAVAILABLE"); + + await retryPendingUploads(owner); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + const delayMs = after.nextRetryAt.toMillis() - Date.now(); + // retryCount 1 on the slow tier => 2^1 * 1h = 2 hours, unchanged. + expect(delayMs).toBeGreaterThan(60 * 60 * 1000); + }); + + it("no providerErrorCode at all keeps the original hours-scale behavior", async () => { + const { owner, docId } = await seedDueItem(undefined); + + await retryPendingUploads(owner); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + const delayMs = after.nextRetryAt.toMillis() - Date.now(); + expect(delayMs).toBeGreaterThan(60 * 60 * 1000); + }); +}); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 1b5833d..38a6216 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -238,7 +238,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: experimentID, owner: exp_data.owner, filename, data, dataType: "base64", osfFilesLink: exp_data.osfFilesLink, storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, - errorCode: result.providerStatus || 0, sessionIncremented: false, + errorCode: result.providerStatus || 0, providerErrorCode: result.error, sessionIncremented: false, failureReason: `Provider error ${result.providerStatus}: ${result.providerMessage}`, claimToken, }); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index a877da1..18e9f80 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -302,14 +302,15 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 experimentID, owner: exp_data.owner, filename: uploadFilename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, - errorCode: result.providerStatus || 0, sessionIncremented: true, + errorCode: result.providerStatus || 0, providerErrorCode: result.error, sessionIncremented: true, failureReason: `Provider error ${result.providerStatus}: ${result.providerMessage}`, claimToken, }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy - // OSF is failing, so queue the derived files alongside the raw data. - await queueDerivedFiles(derivedFiles, derivedTarget, `Queued alongside data file: OSF error ${result.providerStatus}`); + // OSF is failing, so queue the derived files alongside the raw data — + // same provider error code, since it's the same provider write path. + await queueDerivedFiles(derivedFiles, derivedTarget, `Queued alongside data file: OSF error ${result.providerStatus}`, result.error); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_ERROR, osfStatus: result.providerStatus, osfStatusText: result.providerMessage}); return; diff --git a/functions/src/metadata-derived-upload.ts b/functions/src/metadata-derived-upload.ts index 927f264..e290704 100644 --- a/functions/src/metadata-derived-upload.ts +++ b/functions/src/metadata-derived-upload.ts @@ -1,5 +1,5 @@ import { getProvider } from "./providers/index.js"; -import { StorageProviderId, ContainerRef, ResolvedAuth } from "./providers/types.js"; +import { StorageProviderId, ContainerRef, ResolvedAuth, ProviderErrorCode } from "./providers/types.js"; import queueUpload from "./queue-upload.js"; import writeLog from "./write-log.js"; import MESSAGES from "./api-messages.js"; @@ -63,7 +63,7 @@ export async function uploadDerivedFiles( ); if (result.success) return; if (result.error === "NAME_CONFLICT") return; - await queueDerivedFiles([file], target, `Derived file provider error ${result.providerStatus}: ${result.providerMessage}`); + await queueDerivedFiles([file], target, `Derived file provider error ${result.providerStatus}: ${result.providerMessage}`, result.error); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await queueDerivedFiles([file], target, `Derived file upload exception: ${detail}`); @@ -76,11 +76,17 @@ export async function uploadDerivedFiles( * when the raw data file itself just failed to reach the provider (it was * queued, so the provider is known to be unavailable). sessionIncremented is * true because only the raw data file accounts for the session count. + * providerErrorCode is optional — only present when this is being called + * after an actual provider WriteResult failure (either the derived file's own + * attempt in uploadDerivedFiles, or the raw data file's failure in + * api-data.ts); absent for the exception-path callers that never got a + * WriteResult at all, which correctly fall back to the slow retry tier. */ export async function queueDerivedFiles( files: DerivedFile[], target: DerivedUploadTarget, failureReason: string, + providerErrorCode?: ProviderErrorCode, ): Promise<void> { for (const file of files) { try { @@ -94,6 +100,7 @@ export async function queueDerivedFiles( storageProvider: target.storageProvider, providerContainer: target.providerContainer, errorCode: 0, + providerErrorCode, sessionIncremented: true, failureReason, }); diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index e39d10d..bf7e6c6 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -94,6 +94,28 @@ function mapDataverseError( (/exceeds the size limit/i.test(message) || /exceeds the remaining storage quota/i.test(message)) ) { error = "QUOTA_EXCEEDED"; + } else if (status === 400 && /failed to add file/i.test(message) && !/same content/i.test(message)) { + // Live-verified against demo.dataverse.org, 2026-07-26: Dataverse accepts + // exactly ONE concurrent write per dataset and rejects every other + // in-flight write with this generic 400 in ~250ms. It clears in seconds + // (sequential writes are flawless), so this is CONTENTION, not an + // outage -- the queue retries it on a fast tier instead of the + // hours-scale backoff UNAVAILABLE gets. + // + // The "same content" exclusion is essential. Dataverse ALSO rejects + // byte-identical content with `This file has the same content as X that + // is in the dataset. \nFailed to add file to dataset.`, which matches + // "failed to add file" too but is NOT transient -- retrying it fast would + // spin uselessly. That case is deliberately excluded here and falls + // through to UNAVAILABLE below, unchanged. + // + // Open question, not fixed here: if a write actually succeeded + // server-side but the response was lost (timeout, dropped connection), + // the retry re-uploads identical content and gets THIS same-content 400. + // Treating that as a failure can mark an upload that already SUCCEEDED + // as failed. Worth a separate look -- see also WriteResult's lack of an + // idempotent "already there" signal for this case. + error = "CONTENTION"; } else if (status === 429) { error = "RATE_LIMITED"; } else { diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 3d89444..22a06c1 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -14,12 +14,21 @@ export type AuthMethod = "oauth2" | "static-token"; // Generic error taxonomy that every adapter maps its provider's errors into. // QUOTA_EXCEEDED covers both storage-full and file-too-large. +// CONTENTION: the provider rejected a write because another write to the +// same container was already in flight. Transient and clears in seconds -- +// unlike the other codes here, this is never an outage or something a human +// needs to act on -- so the upload queue retries it on a fast tier instead of +// the outage-scale exponential backoff the other codes get. Motivating case: +// Dataverse allows exactly one concurrent write per dataset and rejects every +// other in-flight write with a generic 400 (verified live against +// demo.dataverse.org, 2026-07-26 -- see mapDataverseError in dataverse.ts). export type ProviderErrorCode = | "RATE_LIMITED" | "AUTH_EXPIRED" | "NAME_CONFLICT" | "QUOTA_EXCEEDED" - | "UNAVAILABLE"; + | "UNAVAILABLE" + | "CONTENTION"; // A resolved, decrypted credential handed to adapter calls. serverUrl is only // present for federated providers (Dataverse). diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index a2620f8..987bdb2 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -1,6 +1,6 @@ import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; -import { StorageProviderId, ContainerRef } from "./providers/types.js"; +import { StorageProviderId, ContainerRef, ProviderErrorCode } from "./providers/types.js"; interface QueueUploadParams { experimentID: string; @@ -20,10 +20,29 @@ interface QueueUploadParams { // since Firestore rejects undefined field values). storageProvider?: StorageProviderId; providerContainer?: ContainerRef; + // Taxonomy code from the provider WriteResult that caused this call, when + // one is available (i.e. this is a provider write failure, not a + // collision-cache/metadata failure). Drives which retry tier the first + // nextRetryAt below — and every subsequent backoff computed by + // scheduled-upload-retry.ts's handleRetryFailure — falls into. Omitted from + // the Firestore write below when undefined, same convention as + // osfFilesLink/storageProvider/providerContainer. + providerErrorCode?: ProviderErrorCode; } const MAX_RETRIES = 5; +// CONTENTION and RATE_LIMITED both mean "the provider is busy right now" and +// clear in seconds, unlike AUTH_EXPIRED / QUOTA_EXCEEDED / UNAVAILABLE (or no +// code at all), which need human action or an outage to end. Exported so +// scheduled-upload-retry.ts reads the exact same tier boundary rather than +// keeping its own copy that could drift out of sync with this one. +export const FAST_RETRY_CODES: ReadonlySet<string> = new Set(["CONTENTION", "RATE_LIMITED"]); + +export function isFastRetry(code?: string): boolean { + return code !== undefined && FAST_RETRY_CODES.has(code); +} + export default async function queueUpload(params: QueueUploadParams): Promise<string> { const deduplicationKey = `${params.experimentID}:${params.filename}`; const docId = deduplicationKey.replace(/[/\\]/g, "_"); @@ -31,7 +50,12 @@ export default async function queueUpload(params: QueueUploadParams): Promise<st const docRef = db.collection("uploadQueue").doc(docId); const now = Timestamp.now(); - const nextRetryAt = Timestamp.fromMillis(now.toMillis() + 60 * 60 * 1000); // 1 hour + // Fast tier (CONTENTION/RATE_LIMITED): 60 seconds — the cadence of + // scheduled-upload-retry.ts is what makes this meaningful (see that file). + // Everything else, including no providerErrorCode at all: 1 hour, exactly + // as before this change. + const firstRetryDelayMs = isFastRetry(params.providerErrorCode) ? 60 * 1000 : 60 * 60 * 1000; + const nextRetryAt = Timestamp.fromMillis(now.toMillis() + firstRetryDelayMs); // If the doc already exists: a "processing" entry is actively being // uploaded, so leave it alone (retry worker owns the storage payload right @@ -107,6 +131,9 @@ export default async function queueUpload(params: QueueUploadParams): Promise<st if (params.providerContainer !== undefined) { queueDocData.providerContainer = params.providerContainer; } + if (params.providerErrorCode !== undefined) { + queueDocData.providerErrorCode = params.providerErrorCode; + } await docRef.set(queueDocData); diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index 0fa9022..444050b 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -6,41 +6,70 @@ import { ContainerRef, StorageProviderId, ResolvedAuth } from "./providers/types import resolveToken from "./resolve-token.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from "./interfaces.js"; +import { isFastRetry } from "./queue-upload.js"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; -const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24 hours +const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24 hours (slow tier cap, unchanged) +// Fast tier (CONTENTION/RATE_LIMITED, see queue-upload.ts's isFastRetry): a +// much shorter cap, since these clear in seconds/minutes rather than needing +// an outage to end. +const FAST_MAX_BACKOFF_MS = 30 * 60 * 1000; // 30 minutes /** - * Scheduled function that runs every hour to retry failed OSF uploads. - * Processes up to 10 pending items per run, applies exponential backoff, - * and cleans up entries older than 7 days. + * Scheduled function that runs every 5 minutes to retry failed uploads. + * Processes up to 25 pending items per run, applies tiered exponential + * backoff, and cleans up entries older than 7 days. + * + * The 5-minute cadence (was hourly) is what makes the fast retry tier real: + * queueUpload can set a 60-second nextRetryAt for CONTENTION/RATE_LIMITED + * failures, but that's meaningless if this worker only wakes up once an + * hour. The query below already gates on `nextRetryAt <= now`, so slow-tier + * items are unaffected by the faster cadence — they simply aren't due yet + * most of the times this runs. */ -export const scheduledUploadRetry = onSchedule("0 * * * *", async () => { +export const scheduledUploadRetry = onSchedule("*/5 * * * *", async () => { await retryPendingUploads(); await cleanupOldEntries(); }); -async function retryPendingUploads() { +/** + * `ownerScope` is a TEST SEAM, defaulting to production behavior (every + * pending item). This worker sweeps the whole uploadQueue collection and + * mutates what it finds, so a test that runs it unscoped against the shared + * emulator processes whatever other suites have queued -- the same + * cross-suite hazard that recoverPendingUploads' `prefix` seam exists for + * (see ddef109). Filtering happens in memory rather than in the query so the + * production query shape, and therefore its Firestore index, is untouched. + */ +export async function retryPendingUploads(ownerScope?: string) { const now = Timestamp.now(); + // 25 items processed serially at ~600ms per provider write is ~15s of + // work per run — safely inside the scheduled-function budget — and lifts + // the drain rate from 10/hour (old hourly cadence) to 25 * 12 runs/hour = + // 300/hour under the new 5-minute cadence. const pendingItems = await db .collection("uploadQueue") .where("status", "==", "pending") .where("nextRetryAt", "<=", now) .orderBy("nextRetryAt", "asc") - .limit(10) + .limit(25) .get(); - if (pendingItems.empty) { + const dueDocs = ownerScope + ? pendingItems.docs.filter((d) => d.data().owner === ownerScope) + : pendingItems.docs; + + if (dueDocs.length === 0) { console.log("No pending uploads to retry."); return; } - console.log(`Found ${pendingItems.size} pending upload(s) to retry.`); + console.log(`Found ${dueDocs.length} pending upload(s) to retry.`); // Group by owner to avoid hammering same user's rate limit const byOwner = new Map<string, FirebaseFirestore.QueryDocumentSnapshot[]>(); - for (const doc of pendingItems.docs) { + for (const doc of dueDocs) { const owner = doc.data().owner; if (!byOwner.has(owner)) { byOwner.set(owner, []); @@ -244,10 +273,22 @@ async function handleRetryFailure( return; } - // Honor Retry-After header if provided, otherwise use exponential backoff + // Tier the backoff by the provider error code stored on the queue doc (set + // by queue-upload.ts at initial queue time). CONTENTION/RATE_LIMITED are + // "the provider is busy right now" and resolve in seconds, unlike + // AUTH_EXPIRED / QUOTA_EXCEEDED / UNAVAILABLE (or no code at all), which + // need human action or an outage to end — so the fast tier gets a + // minutes-scale base/cap (~2, 4, 8, 16, 30 minutes) instead of the + // hours-scale one (~2, 4, 8, 16, 24 hours, unchanged). + const fastTier = isFastRetry(data.providerErrorCode); + const baseMs = fastTier ? 60 * 1000 : 60 * 60 * 1000; + const capMs = fastTier ? FAST_MAX_BACKOFF_MS : MAX_BACKOFF_MS; + + // Honor Retry-After header if provided, otherwise use exponential backoff. + // A Retry-After still wins where present, clamped to this item's tier cap. const backoffMs = retryAfterSeconds - ? Math.min(retryAfterSeconds * 1000, MAX_BACKOFF_MS) - : Math.min(Math.pow(2, newRetryCount) * 60 * 60 * 1000, MAX_BACKOFF_MS); + ? Math.min(retryAfterSeconds * 1000, capMs) + : Math.min(Math.pow(2, newRetryCount) * baseMs, capMs); const nextRetryAt = Timestamp.fromMillis(Date.now() + backoffMs); console.log(`Upload ${docRef.id} retry ${newRetryCount} failed: ${reason}. Next retry at ${nextRetryAt.toDate().toISOString()}`); From 835821f853ba4b7c27878aeb9191f3df4c3b5d65 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 15:10:07 -0400 Subject: [PATCH 059/181] docs: revise the Dataverse gate to CONDITIONAL PASS The original FAIL measured the wrong thing -- it assumed 30 simultaneous writes, which is not how a class submits. Re-run against demo.dataverse.org with realistic random arrivals (30 submissions across 60 seconds, the worst realistic case): 21/30 succeeded first try, 9 collided, all 9 correctly mapped to CONTENTION, 0 other failures. With the fast retry tier (ff5d805) that whole cohort lands inside ~2 minutes, and 9 items is well under the worker's 25-per-run limit so they drain in one pass. Collisions fall off sharply as the window widens (~6% over 5 minutes). So the concurrency-1 limit is a real constraint but not disqualifying: sequential throughput comfortably exceeds the requirement and the fast tier absorbs the contention. The condition is that tier. The original analysis is kept -- its concurrency measurements and API corrections are still accurate, only the verdict changed. Still open: this is demo, and ingest suppression is version-dependent, so a run against a real institutional installation is worth doing before launch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/provider-migration-design.md | 32 ++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 88ec6f2..0bc3859 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -355,7 +355,37 @@ provider, it does not trigger a redesign. flow has more failure modes than a single PUT; verify behavior under concurrent submissions, including media-sized files. -### Dataverse spike — RESULT: GATE A FAILED (live, demo.dataverse.org, 2026-07-26) +### Dataverse spike — RESULT: CONDITIONAL PASS (revised 2026-07-26) + +**Revised verdict, after fixing the retry tier.** The original FAIL below +measured the wrong thing: it assumed 30 *simultaneous* writes, which is not +how a class submits. Re-run with realistic random arrivals — 30 submissions +across 60 seconds, the worst realistic case — against demo.dataverse.org: + +- **21/30 succeeded on the first attempt** +- **9 collided, all 9 correctly mapped to `CONTENTION`**, 0 other failures + +A 30% collision rate, all of it transient and now on a 60-second retry tier +(`ff5d805`), so the whole cohort lands inside ~2 minutes. Nine items is well +under the worker's 25-per-run limit, so they drain in a single pass. +Collisions fall off sharply as the window widens (~6% over 5 minutes, ~3% +over 10). + +So Dataverse's concurrency-1 limit is a real constraint but not a +disqualifying one: sequential throughput (~100 files/minute) comfortably +exceeds the requirement, and the fast retry tier absorbs the contention. +The condition is that tier — without it, collided submissions waited 1–2 +hours. + +Still open: this is demo.dataverse.org. Ingest suppression is +version-dependent and federation means DataPipe does not choose the version, +so a run against a real institutional installation is still worth doing +before launch. `scripts/dataverse-spike.mjs` takes `DATAVERSE_SERVER`. + +The original analysis follows, kept because the concurrency measurements and +the API corrections in it remain accurate — only the verdict changed. + +### Original assessment — GATE A FAILED (live, demo.dataverse.org, 2026-07-26) Run with `scripts/dataverse-spike.mjs` plus targeted follow-ups, against demo.dataverse.org using the real adapter. From b1f0a3b7a53af83cb76c36bf68fd9573ce989560 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 16:55:15 -0400 Subject: [PATCH 060/181] feat: warn at experiment setup when a Dataverse install predates 5.11 `tabIngest` -- which stops Dataverse rewriting researchers' CSVs into archival .tab -- was added in Dataverse 5.11 (released 2022-06-13; its release notes read "Tabular ingest can be skipped via API. Issue #8525, PR #8532"). Below that the parameter is silently ignored, so the data is transformed with no error anywhere. Federation means DataPipe does not choose the version, so the adapter now detects it and says so. Adds an optional `setupWarnings(auth)` to StorageProvider: non-blocking, researcher-facing advisories, returning plain strings for the UI. Dataverse implements it against GET /api/info/version and warns, naming the detected version, when the installation is older than 5.11 (noting JSON is unaffected). A new /api/providersetupwarnings endpoint resolves the token and calls it; the new-experiment page fetches on provider change and renders each warning as a caution above the Title field. Creation is never blocked. Version parsing is deliberately lenient, because real installations do not agree on a format: demo reports "6.11", Harvard "6.10.1", and Borealis "v6.8.2-SP" -- a leading v and arbitrary suffixes both occur, so a strict semver parse would throw away the answer. Verified live against all three: none warns. The exported helper is unit-tested against those exact strings plus the 5.11 boundary, 5.10, 4.20 and an unparseable value. Everything fails OPEN -- an unreachable server, an odd response shape, an unparseable version, or a token that will not resolve all yield no warning rather than an error. This runs every time a researcher picks a provider, so a transient blip must not cry wolf; the trade-off, commented at the call site, is that a genuinely unreachable installation is indistinguishable from a healthy modern one. Replaces the vague "suppression is version-dependent" caveat that had been carried in the design doc since before the spike, in three places, with the concrete version, date, and the check that now enforces it. Two pre-existing page tests read global.fetch.mock.calls[0] assuming it was the createexperiment call; the warnings fetch now precedes it, so those lookups find the call by URL instead -- matching the pattern already used elsewhere in that file. Their assertions are unchanged. Full suite green: 43 suites / 360 tests. Next production build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/new-experiment-page.test.jsx | 146 +++++++++++++++++- docs/provider-migration-design.md | 24 ++- firebase.json | 4 + .../src/__tests__/providers-dataverse.test.js | 84 +++++++++- functions/src/index.ts | 4 +- functions/src/provider-setup-warnings.ts | 127 +++++++++++++++ functions/src/providers/dataverse.ts | 93 +++++++++++ functions/src/providers/types.ts | 10 ++ pages/admin/new.js | 60 ++++++- 9 files changed, 537 insertions(+), 15 deletions(-) create mode 100644 functions/src/provider-setup-warnings.ts diff --git a/__tests__/new-experiment-page.test.jsx b/__tests__/new-experiment-page.test.jsx index f4569c7..2936f65 100644 --- a/__tests__/new-experiment-page.test.jsx +++ b/__tests__/new-experiment-page.test.jsx @@ -177,8 +177,19 @@ describe("NewExperimentPage — Google Drive provider selector", () => { }); fireEvent.click(screen.getByRole("button", { name: /^Create$/i })); - await waitFor(() => expect(global.fetch).toHaveBeenCalled()); - const [url, options] = global.fetch.mock.calls[0]; + // Look up the createexperiment call by URL rather than assuming index 0: + // selecting a connected provider also fires a background + // /api/providersetupwarnings fetch (see pages/admin/new.js's + // setup-warnings effect), which can land in the mock's call log before + // this one. + await waitFor(() => + expect( + global.fetch.mock.calls.some(([callUrl]) => callUrl === "/api/createexperiment") + ).toBe(true) + ); + const [url, options] = global.fetch.mock.calls.find( + ([callUrl]) => callUrl === "/api/createexperiment" + ); expect(url).toBe("/api/createexperiment"); expect(JSON.parse(options.body)).toEqual({ provider: "gdrive", @@ -366,8 +377,16 @@ describe("NewExperimentPage — Dataverse provider (provider-generic rendering)" fireEvent.click(screen.getByRole("button", { name: /^Create$/i })); - await waitFor(() => expect(global.fetch).toHaveBeenCalled()); - const [url, options] = global.fetch.mock.calls[0]; + // Look up the createexperiment call by URL rather than assuming index 0 + // -- same reasoning as the gdrive submit test above. + await waitFor(() => + expect( + global.fetch.mock.calls.some(([callUrl]) => callUrl === "/api/createexperiment") + ).toBe(true) + ); + const [url, options] = global.fetch.mock.calls.find( + ([callUrl]) => callUrl === "/api/createexperiment" + ); expect(url).toBe("/api/createexperiment"); const body = JSON.parse(options.body); expect(body.provider).toBe("dataverse"); @@ -469,3 +488,122 @@ describe("NewExperimentPage — Dataverse provider (provider-generic rendering)" expect(screen.getByLabelText(/Subject/i)).toHaveValue(""); }); }); + +describe("NewExperimentPage — provider setup warnings (Dataverse)", () => { + function selectDataverse() { + fireEvent.click(screen.getByLabelText(/^Dataverse$/i)); + } + + it("selecting a connected Dataverse shows a warning returned by /api/providersetupwarnings", async () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", connectedAccounts: { dataverse: true } }, + false, + undefined, + ]); + // Dispatch by URL, per the file's existing convention (see the + // Google-Drive-folder-leak test above), rather than a blanket + // mockResolvedValue -- this endpoint is called alongside others. + global.fetch.mockImplementation((url) => { + if (url.includes("/api/providersetupwarnings")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + warnings: [ + "This Dataverse installation reports version 5.10, which predates Dataverse 5.11.", + ], + }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + renderPage(); + selectDataverse(); + + await waitFor(() => + expect(screen.getByText(/predates Dataverse 5\.11/i)).toBeInTheDocument() + ); + }); + + it("an empty warnings array shows nothing", async () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", connectedAccounts: { dataverse: true } }, + false, + undefined, + ]); + global.fetch.mockImplementation((url) => { + if (url.includes("/api/providersetupwarnings")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ warnings: [] }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + renderPage(); + selectDataverse(); + + await waitFor(() => + expect( + global.fetch.mock.calls.some(([callUrl]) => callUrl.includes("/api/providersetupwarnings")) + ).toBe(true) + ); + + expect(screen.queryByText(/predates Dataverse/i)).not.toBeInTheDocument(); + // The rest of the connected-provider form still renders normally. + expect(screen.getByLabelText(/^Title$/i)).toBeInTheDocument(); + }); + + it("a failed warnings fetch is silent -- the form still renders and submission is not blocked", async () => { + useDocumentData.mockReturnValue([ + { refreshToken: "osf-refresh-token", connectedAccounts: { dataverse: true } }, + false, + undefined, + ]); + global.fetch.mockImplementation((url) => { + if (url.includes("/api/providersetupwarnings")) { + return Promise.reject(new Error("network down")); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, experimentID: "exp-warn-fail" }), + }); + }); + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + renderPage(); + selectDataverse(); + + // Let the failed warnings fetch settle before interacting further. + await waitFor(() => + expect( + global.fetch.mock.calls.some(([callUrl]) => callUrl.includes("/api/providersetupwarnings")) + ).toBe(true) + ); + + expect(screen.getByLabelText(/^Title$/i)).toBeInTheDocument(); + expect(screen.queryByText(/predates Dataverse/i)).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/^Title$/i), { + target: { value: "My Dataverse Study" }, + }); + fireEvent.change(screen.getByLabelText(/Collection alias/i), { target: { value: "my-lab" } }); + fireEvent.change(screen.getByLabelText(/Author name/i), { target: { value: "Smith, Jane" } }); + fireEvent.change(screen.getByLabelText(/Contact email/i), { + target: { value: "jane@example.edu" }, + }); + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: "A study about things" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /^Create$/i })); + + await waitFor(() => + expect( + global.fetch.mock.calls.some(([callUrl]) => callUrl === "/api/createexperiment") + ).toBe(true) + ); + await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/admin/exp-warn-fail")); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 0bc3859..771c928 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -251,7 +251,7 @@ handling) carry over unchanged — already provider-agnostic. | Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire** (commonly yearly, installation-configurable) — needs expiry-warning UX, not just storage | | Container | **App-created "DataPipe" folder at Drive root.** Under `drive.file` the app can only touch files it created or the user explicitly picked — a researcher-picked parent would force a Google Picker frontend integration for little gain. Revisit only if researchers demand placement control | Article inside a Project (two levels only) | Dataset inside a Collection | | Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | -| Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case | Per-file and total-quota caps on the free tier; upload is a multi-step multipart flow (initiate → parts → complete) with correspondingly more failure modes; no in-place update (delete + re-upload) | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed, which transforms presentation and extends dataset locking — suppression support is version-dependent | +| Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case | Per-file and total-quota caps on the free tier; upload is a multi-step multipart flow (initiate → parts → complete) with correspondingly more failure modes; no in-place update (delete + re-upload) | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed via `tabIngest`, which requires **Dataverse >= 5.11** (released 2022-06-13); older installations silently ignore it. The adapter's `setupWarnings` checks `/api/info/version` and warns at experiment setup | | Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher, and DataPipe integrates whatever software version each installation runs (version drift is a permanent fact of this adapter) | | DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | | Gating spike | None (comfortable fit) — but OAuth app verification has **weeks of lead time**; start it at build step 0 | See "Gating spikes" below — duplicate-filename behavior, per-item **file-count cap** (historically ~500 files/item; a semester-long study can exceed it), multipart burst behavior | See "Gating spikes" below — **dataset locking under concurrent adds** (most likely disqualifier in the plan), tabular-ingest suppression, silent-rename response shape | @@ -339,9 +339,15 @@ provider, it does not trigger a redesign. queue softens this, but if the spike shows writes serialize through a lock at a rate that can't absorb a class section, that is disqualifying. This is the single most likely spike to fail in the plan. -- **Dataverse — tabular ingest.** Confirm CSV ingest-into-`.tab` can be - suppressed on the installations researchers actually use (suppression is - version-dependent, and federation means DataPipe doesn't choose the version). +- **Dataverse — tabular ingest.** RESOLVED. `tabIngest` was added in + Dataverse **5.11** (released 2022-06-13, per its release notes: "Tabular + ingest can be skipped via API. Issue #8525, PR #8532"). Below that the + parameter is silently ignored. Since federation means DataPipe doesn't + choose the version, `dataverseProvider.setupWarnings` reads + `/api/info/version` and warns the researcher at experiment setup rather + than leaving it as a docs caveat. Version parsing must stay lenient: real + installations report `6.11` (demo), `6.10.1` (Harvard) and `v6.8.2-SP` + (Borealis) — a leading `v` and arbitrary suffixes both occur. - **Dataverse — silent rename.** Confirm the exact response shape on a duplicate filename so DataPipe compares `storedFilename` against the request rather than trusting it blindly. @@ -377,10 +383,12 @@ exceeds the requirement, and the fast retry tier absorbs the contention. The condition is that tier — without it, collided submissions waited 1–2 hours. -Still open: this is demo.dataverse.org. Ingest suppression is -version-dependent and federation means DataPipe does not choose the version, -so a run against a real institutional installation is still worth doing -before launch. `scripts/dataverse-spike.mjs` takes `DATAVERSE_SERVER`. +Still open: these numbers come from demo.dataverse.org, so a burst run +against a real institutional installation is worth doing before launch — +`scripts/dataverse-spike.mjs` takes `DATAVERSE_SERVER`. The ingest-version +question is no longer part of that: `tabIngest` needs Dataverse >= 5.11 and +the adapter now checks and warns at setup (demo 6.11, Harvard 6.10.1 and +Borealis v6.8.2-SP all clear it comfortably). The original analysis follows, kept because the concurrency measurements and the API corrections in it remain accurate — only the verdict changed. diff --git a/firebase.json b/firebase.json index 7a48211..9fb5245 100644 --- a/firebase.json +++ b/firebase.json @@ -73,6 +73,10 @@ { "source": "/api/getprovideraccesstoken", "function": "getprovideraccesstoken" + }, + { + "source": "/api/providersetupwarnings", + "function": "providersetupwarnings" } ] }, diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js index 73c2fdf..b7958d7 100644 --- a/functions/src/__tests__/providers-dataverse.test.js +++ b/functions/src/__tests__/providers-dataverse.test.js @@ -16,7 +16,7 @@ jest.mock("node-fetch", () => ({ default: (...args) => mockFetch(...args), })); -import { dataverseProvider } from "../../lib/providers/dataverse.js"; +import { dataverseProvider, supportsTabIngest } from "../../lib/providers/dataverse.js"; const SERVER_URL = "https://dataverse.mock.test"; @@ -841,6 +841,88 @@ describe("8. federated serverUrl resolution", () => { }); }); +describe("10. supportsTabIngest (version parsing, lenient by design)", () => { + // tabIngest was added in Dataverse 5.11. Real installations return version + // strings in inconsistent shapes -- verified live, 2026-07-26 -- so parsing + // must tolerate all of them rather than requiring strict semver. + it("returns true for demo.dataverse.org's \"6.11\"", () => { + expect(supportsTabIngest("6.11")).toBe(true); + }); + + it("returns true for dataverse.harvard.edu's \"6.10.1\"", () => { + expect(supportsTabIngest("6.10.1")).toBe(true); + }); + + it("returns true for borealisdata.ca's \"v6.8.2-SP\" (leading v, trailing -SP suffix)", () => { + expect(supportsTabIngest("v6.8.2-SP")).toBe(true); + }); + + it("returns true at the exact boundary, \"5.11\" (supported)", () => { + expect(supportsTabIngest("5.11")).toBe(true); + }); + + it("returns false just below the boundary, \"5.10\" (unsupported)", () => { + expect(supportsTabIngest("5.10")).toBe(false); + }); + + it("returns false for an old major version, \"4.20\" (unsupported)", () => { + expect(supportsTabIngest("4.20")).toBe(false); + }); + + it("returns null for an unparseable version string", () => { + expect(supportsTabIngest("unknown")).toBeNull(); + }); +}); + +describe("11. setupWarnings", () => { + it("returns [] when the installation reports a version >= 5.11", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "6.11" } } }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${SERVER_URL}/api/info/version`); + expect(options.method).toBe("GET"); + // The key is sent for consistency even though the endpoint is + // unauthenticated and ignores it. + expect(header(options.headers, "X-Dataverse-key")).toBe("test-token"); + expect(result).toEqual([]); + }); + + it("returns a single warning naming the detected version when it predates 5.11", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "5.10" } } }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toHaveLength(1); + expect(result[0]).toMatch(/5\.10/); + expect(result[0]).toMatch(/5\.11/i); + }); + + it("returns [] (fails open) on a non-OK response", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 500, statusText: "Internal Server Error", jsonBody: undefined }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toEqual([]); + }); + + it("returns [] (fails open) when fetch rejects", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toEqual([]); + }); +}); + describe("9. validateStaticToken", () => { it("returns true on a 200 response from /api/users/:me", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: {} })); diff --git a/functions/src/index.ts b/functions/src/index.ts index f2c0f30..efb568b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -17,6 +17,7 @@ import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; import { createExperiment } from "./create-experiment.js"; import { getProviderAccessToken } from "./get-provider-access-token.js"; +import { providerSetupWarnings } from "./provider-setup-warnings.js"; setGlobalOptions({ maxInstances: 20 @@ -41,5 +42,6 @@ export { getOsfToken as getosftoken, onUserDeleted as onuserdeleted, createExperiment as createexperiment, - getProviderAccessToken as getprovideraccesstoken + getProviderAccessToken as getprovideraccesstoken, + providerSetupWarnings as providersetupwarnings }; diff --git a/functions/src/provider-setup-warnings.ts b/functions/src/provider-setup-warnings.ts new file mode 100644 index 0000000..719e398 --- /dev/null +++ b/functions/src/provider-setup-warnings.ts @@ -0,0 +1,127 @@ +// Advisory-only endpoint, checked when a researcher is setting up an +// experiment against a non-OSF provider (docs/provider-migration-design.md). +// Surfaces non-blocking, researcher-facing warnings about things the +// connected provider *installation* can't honor -- the motivating case is +// Dataverse's tabIngest suppression param (added in 5.11), which an older +// installation silently ignores with no error anywhere (see +// providers/dataverse.ts's setupWarnings). A warning at setup beats +// discovering the problem months later. +// +// Auth + request shape mirrors get-provider-access-token.ts closely (POST +// only, { provider, uid, idToken } body, verifyOwnership for 401/403, +// resolveToken for the decrypted credential). The key difference: THIS +// ENDPOINT NEVER RETURNS AN ERROR STATUS FOR A PROVIDER-SIDE PROBLEM. It is +// advisory only -- every failure mode (unknown provider aside, which is a +// caller bug, not a provider problem) degrades to 200 { warnings: [] } +// rather than blocking or confusing experiment setup. + +import { onRequest } from "firebase-functions/v2/https"; +import { db } from "./app.js"; +import { verifyOwnership } from "./connect-provider.js"; +import resolveToken from "./resolve-token.js"; +import { getProvider } from "./providers/index.js"; +import { StorageProviderId } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; + +export const providerSetupWarnings = onRequest({ cors: true }, async (req, res) => { + try { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const { + provider, + uid, + idToken, + }: { provider?: string; uid?: string; idToken?: string } = req.body || {}; + + if (!provider || !uid) { + res.status(400).json({ error: "Missing required parameters" }); + return; + } + + // The provider must be registered and not "osf" -- OSF's identity flow + // is a separate legacy path (oauth2-callback.ts) with no + // connectedAccounts entry to resolve here. Opaque "Unknown provider" for + // both rejections, same convention as connect-provider.ts/ + // get-provider-access-token.ts: a registered-but-wrong-shape provider + // looks identical to the caller as genuinely unknown. + let storageProvider; + try { + storageProvider = getProvider(provider as StorageProviderId); + } catch { + res.status(400).json({ error: "Unknown provider" }); + return; + } + if (storageProvider.id === "osf") { + res.status(400).json({ error: "Unknown provider" }); + return; + } + + // Verify the caller owns the uid they claim -- no signup path here. + const authCheck = await verifyOwnership(uid, idToken); + if (!authCheck.ok) { + res.status(authCheck.status).json({ error: authCheck.error }); + return; + } + + const userDoc = await db.doc(`users/${uid}`).get(); + // A freshly-signed-up user may have no Firestore doc yet -- treat that + // the same as "no connected accounts" rather than throwing, same as + // get-provider-access-token.ts/create-experiment.ts. + const userData: UserData = (userDoc.data() as UserData) || ({} as UserData); + + const tokenResult = await resolveToken(userData, { + storageProvider: provider as StorageProviderId, + owner: uid, + } as ExperimentData); + + // Any token-resolution failure (not connected, expired, etc.) becomes + // an empty warnings list, NOT an error response -- this endpoint is + // advisory only and must never block or confuse setup. The connect flow + // and create-experiment.ts are where a real, blocking error for these + // cases belongs; by the time setup UI calls this, the "connect your + // account" CTA has already been decided from userData directly. + if (!tokenResult.success) { + res.status(200).json({ warnings: [] }); + return; + } + + if (!storageProvider.setupWarnings) { + res.status(200).json({ warnings: [] }); + return; + } + + let warnings: string[]; + try { + warnings = await storageProvider.setupWarnings({ + token: tokenResult.token, + serverUrl: tokenResult.serverUrl, + }); + } catch (e) { + // setupWarnings is documented to never throw, but this endpoint is + // advisory-only -- defend against a provider implementation that + // breaks that contract rather than letting it 500 an experiment-setup + // page. + console.error( + "Error getting provider setup warnings:", + e instanceof Error ? e.message : "Unknown error" + ); + warnings = []; + } + + res.status(200).json({ warnings }); + } catch (error) { + // Never a 5xx for a provider-side problem -- but a genuinely unexpected + // failure here (e.g. a Firestore outage) still needs *some* response, + // and 200 { warnings: [] } is indistinguishable from "nothing to + // report", which is the correct fail-open behavior for an advisory-only + // endpoint. + console.error( + "Error getting provider setup warnings:", + error instanceof Error ? error.message : "Unknown error" + ); + res.status(200).json({ warnings: [] }); + } +}); diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index bf7e6c6..cb7fe5d 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -166,6 +166,33 @@ function buildMultipartBody( return Buffer.concat([Buffer.from(preamble), dataBuffer, Buffer.from(middle), Buffer.from(epilogue)]); } +// tabIngest (writeSessionFile's suppression of Dataverse's CSV->.tab +// archival rewrite) was added in Dataverse 5.11 (released 13 June 2022). On +// an older installation the parameter is silently ignored -- no error +// anywhere -- and researchers' CSVs get transformed with no signal that it +// happened. This compares the (major, minor) of a version string against +// (5, 11), ignoring patch/suffix. +// +// PARSING IS DELIBERATELY LENIENT, not strict semver -- verified live against +// real installations, 2026-07-26: +// - demo.dataverse.org -> "6.11" +// - dataverse.harvard.edu -> "6.10.1" +// - borealisdata.ca -> "v6.8.2-SP" (leading "v", trailing "-SP") +// /^v?(\d+)\.(\d+)/ tolerates all three; anything that doesn't match at +// least major.minor returns null (unparseable) rather than throwing. +export function supportsTabIngest(rawVersion: string): boolean | null { + const match = /^v?(\d+)\.(\d+)/.exec(rawVersion); + if (!match) { + return null; + } + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + if (major !== 5) { + return major > 5; + } + return minor >= 11; +} + interface AddFileResponseBody { status?: string; data?: { @@ -493,6 +520,72 @@ export const dataverseProvider: StorageProvider = { return results; }, + // Checked at experiment SETUP time (provider-setup-warnings.ts), not on + // every write -- a one-time, non-blocking advisory so a researcher on an + // old installation finds out now, rather than discovering months later + // that every CSV they thought was raw got converted to Dataverse's + // archival .tab format. + async setupWarnings(auth: ResolvedAuth): Promise<string[]> { + try { + const serverUrl = resolveServerUrl(auth); + + // GET /api/info/version is UNAUTHENTICATED on every Dataverse + // installation -- verified live -- but the key is sent anyway for + // consistency with every other call this adapter makes; the server + // just ignores it here. + const response = await fetch(`${serverUrl}/api/info/version`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (response.status !== 200) { + console.warn( + `dataverse setupWarnings: /api/info/version returned ${response.status}, failing open (no warning)` + ); + return []; + } + + const body = (await response.json()) as { status?: string; data?: { version?: string } }; + const version = body.data?.version; + if (body.status !== "OK" || !version) { + console.warn( + "dataverse setupWarnings: unexpected /api/info/version response shape, failing open (no warning)" + ); + return []; + } + + const supported = supportsTabIngest(version); + if (supported === null) { + console.warn(`dataverse setupWarnings: could not parse version "${version}", failing open (no warning)`); + return []; + } + + if (supported) { + return []; + } + + return [ + `This Dataverse installation reports version ${version}, which predates Dataverse 5.11. ` + + "Tabular-ingest suppression is unavailable on installations older than 5.11, so CSV files " + + "uploaded to this dataset will be converted to Dataverse's archival .tab format. JSON data is unaffected.", + ]; + } catch (e) { + // FAIL OPEN, DELIBERATELY. This runs every time a researcher sets up + // an experiment against this provider, not once -- so a transient + // network blip (or resolveServerUrl throwing because neither auth nor + // a container carries a serverUrl) must not cry wolf at every + // researcher who happens to hit it. The trade-off this accepts: an + // installation that is genuinely unreachable (down, firewalled, + // misconfigured URL) gets NO warning either, indistinguishable here + // from one that's healthy and modern. + console.warn( + "dataverse setupWarnings: failed to check installation version, failing open (no warning):", + e instanceof Error ? e.message : "Unknown error" + ); + return []; + } + }, + async downloadFile( auth: ResolvedAuth, container: ContainerRef, diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 22a06c1..66ab361 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -165,6 +165,16 @@ export interface StorageProvider { // static-token providers only validateStaticToken?(auth: ResolvedAuth): Promise<boolean>; + // Optional, non-blocking, researcher-facing advisories checked when an + // experiment is being set up against this provider (see + // provider-setup-warnings.ts). Returns human-readable strings for the UI + // to display; an empty array means nothing to report. Never throws -- a + // provider that cannot determine its answer returns [] rather than + // failing setup. Motivating case: Dataverse's tabIngest suppression param + // (writeSessionFile) is silently ignored by installations older than + // 5.11, so dataverse.ts's implementation warns when it detects one. + setupWarnings?(auth: ResolvedAuth): Promise<string[]>; + // One-time setup at experiment creation. researcherInput is provider-shaped // (e.g. parent project for Figshare, collection + serverUrl for Dataverse). createDataContainer( diff --git a/pages/admin/new.js b/pages/admin/new.js index f155885..78c2002 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -1,7 +1,7 @@ import AuthCheck from "../../components/AuthCheck"; import { doc } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { UserContext } from "../../lib/context"; import { useDocumentData } from "react-firebase-hooks/firestore"; import Link from "next/link"; @@ -23,6 +23,7 @@ import { VStack, Text, NativeSelect, + Alert, } from "@chakra-ui/react"; export default function NewExperimentPage({}) { @@ -58,6 +59,12 @@ function NewExperimentForm() { const [containerFieldErrors, setContainerFieldErrors] = useState({}); const [selectedFolder, setSelectedFolder] = useState(null); const [folderPickerLoading, setFolderPickerLoading] = useState(false); + // Non-blocking, researcher-facing advisories about the connected + // provider's installation (e.g. a Dataverse server too old to honor + // tabIngest suppression -- see functions/src/providers/dataverse.ts's + // setupWarnings). Cleared on every provider change so a warning from one + // provider never carries over and is momentarily shown against another. + const [providerWarnings, setProviderWarnings] = useState([]); const [data, loading, error] = useDocumentData(doc(db, "users", user.uid)); @@ -76,8 +83,53 @@ function NewExperimentForm() { // harmless, but sending it at all is wrong.) The title deliberately // survives a provider change: it describes the study, not the storage. setSelectedFolder(null); + setProviderWarnings([]); }; + // Fetch setup warnings for the selected provider: on every provider + // change, and on initial mount when a non-osf, already-connected provider + // is preselected. Never fires for osf (it has no StorageProvider adapter, + // hence no setupWarnings) and never fires while the provider is not yet + // connected (there is no resolvable token to check against). A failed + // fetch is silent (console only) -- this is advisory only and must never + // block or error the form. + useEffect(() => { + if (provider === "osf" || !providerConnected) { + setProviderWarnings([]); + return; + } + + let cancelled = false; + + (async () => { + try { + const currentUser = auth.currentUser; + if (!currentUser) return; + const idToken = await currentUser.getIdToken(); + + const response = await fetch("/api/providersetupwarnings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, uid: currentUser.uid, idToken }), + }); + const body = await response.json(); + + if (!cancelled) { + setProviderWarnings(response.ok && Array.isArray(body.warnings) ? body.warnings : []); + } + } catch (err) { + console.error(err); + if (!cancelled) { + setProviderWarnings([]); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [provider, providerConnected]); + const handleContainerValueChange = (name, value) => { setContainerValues((prev) => ({ ...prev, [name]: value })); setContainerFieldErrors((prev) => ({ ...prev, [name]: false })); @@ -380,6 +432,12 @@ function NewExperimentForm() { {providerError} </Text> )} + {providerWarnings.map((warning, index) => ( + <Alert.Root key={index} status="warning" variant="subtle" borderRadius="md"> + <Alert.Indicator /> + <Alert.Description>{warning}</Alert.Description> + </Alert.Root> + ))} <Field.Root invalid={providerTitleError}> <Field.Label>Title</Field.Label> <Input From 903ac9768231f579b316b953a8316c931edb6cfe Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 17:25:02 -0400 Subject: [PATCH 061/181] fix: classify queue failures by provider taxonomy, not HTTP status QueuePanel guessed at failure copy from the HTTP status embedded in failureReason -- an OSF-era assumption that does not survive other providers. Dataverse returns 400 for BOTH write contention and quota-exceeded, neither of which the status map covered, so both fell through to the raw string. A researcher whose submission merely collided saw "Provider error 400: Failed to add file to dataset.", which reads like data loss for something that retries successfully within a couple of minutes. The queue doc has carried providerErrorCode (the provider-agnostic taxonomy) since the retry-tier work, and this panel was not reading it. Copy is now keyed off that code, with the old status/string matching kept as a fallback for queue docs written before the field existed and for failures that never reached a provider at all (interrupted uploads, collision-cache and metadata problems), which carry no code. CONTENTION copy is deliberately reassuring rather than alarming: it says the provider was busy with another upload from this experiment, that this is normal when several participants finish at once, and that it is being retried automatically. Paired with the existing "Next retry in 2m" that is now the whole truth. Also drops the last OSF-specific string a non-OSF researcher would see: CodeHints' base64 tab said the server "uploads the file to OSF". It takes only expId and has no provider context, so it now says "to your storage provider", matching the phrasing already used throughout QueuePanel. Tests cover the taxonomy path and assert the raw provider string does NOT leak through when a code is present; verified to fail when the taxonomy lookup is removed. The pre-existing legacy-mapping test is untouched and still passes -- its entries carry no providerErrorCode, which is exactly the fallback case. Full suite green: 43 suites / 363 tests. Next production build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/queue-panel.test.jsx | 66 ++++++++++++++++++++++++++++++ components/dashboard/CodeHints.js | 2 +- components/dashboard/QueuePanel.js | 38 ++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/__tests__/queue-panel.test.jsx b/__tests__/queue-panel.test.jsx index 1a83e36..7322a50 100644 --- a/__tests__/queue-panel.test.jsx +++ b/__tests__/queue-panel.test.jsx @@ -38,6 +38,40 @@ const entries = [ }, ]; +// Entries carrying the provider-agnostic taxonomy code, which is now the +// preferred classification. The three above deliberately have NO +// providerErrorCode -- they pin the legacy status/string fallback for queue +// docs written before that field existed. +const codedEntries = [ + { + id: "c1", + filename: "sub-10_data.csv", + status: "pending", + providerErrorCode: "CONTENTION", + // A raw, alarming reason that must NOT reach the researcher now that a + // taxonomy code is present. + failureReason: "Provider error 400: Failed to add file to dataset.", + createdAt: new Date(), + nextRetryAt: null, + }, + { + id: "c2", + filename: "sub-11_data.csv", + status: "failed", + providerErrorCode: "QUOTA_EXCEEDED", + failureReason: "Provider error 400: This file size (2.0 GB) exceeds the size limit of 1.0 GB.", + createdAt: new Date(), + }, +]; + +function renderCodedPanel() { + return render( + <ChakraProvider value={system}> + <QueuePanel entries={codedEntries} experimentId="exp1" /> + </ChakraProvider> + ); +} + function renderPanel() { return render( <ChakraProvider value={system}> @@ -84,3 +118,35 @@ describe("QueuePanel — provider-neutral copy", () => { ).not.toBeInTheDocument(); }); }); + +describe("QueuePanel provider error taxonomy", () => { + it("CONTENTION reads as routine and self-resolving, not as a raw 400", () => { + renderCodedPanel(); + + expect( + screen.getByText(/busy with another upload from this experiment/i) + ).toBeInTheDocument(); + expect(screen.getByText(/retried automatically/i)).toBeInTheDocument(); + // The raw provider string must not leak through -- it reads like data + // loss for what is a benign, auto-resolving collision. + expect( + screen.queryByText(/Failed to add file to dataset/i) + ).not.toBeInTheDocument(); + }); + + it("QUOTA_EXCEEDED is explained rather than shown as a 400", () => { + renderCodedPanel(); + + expect( + screen.getByText(/out of space, or this file is larger than it allows/i) + ).toBeInTheDocument(); + expect(screen.queryByText(/exceeds the size limit/i)).not.toBeInTheDocument(); + }); + + it("the taxonomy code wins over the status embedded in failureReason", () => { + // c1's failureReason carries a 400 that the legacy path would have shown + // verbatim; the code is what decides the copy now. + renderCodedPanel(); + expect(screen.queryByText(/^Provider error 400/)).not.toBeInTheDocument(); + }); +}); diff --git a/components/dashboard/CodeHints.js b/components/dashboard/CodeHints.js index d74dd82..ae6c84d 100644 --- a/components/dashboard/CodeHints.js +++ b/components/dashboard/CodeHints.js @@ -171,7 +171,7 @@ export default function CodeHints({ expId }) { <Tabs.Content value="send-base64-js"> <VStack alignItems={"start"} gap={3}> <Text fontSize="sm" color="gray.400"> - POST base64-encoded binary data. The server decodes and uploads the file to OSF. + POST base64-encoded binary data. The server decodes and uploads the file to your storage provider. </Text> <CodeBlock> {` diff --git a/components/dashboard/QueuePanel.js b/components/dashboard/QueuePanel.js index 99c513c..498ba80 100644 --- a/components/dashboard/QueuePanel.js +++ b/components/dashboard/QueuePanel.js @@ -13,7 +13,35 @@ import { import { Download } from "lucide-react"; import { auth } from "../../lib/firebase"; -function friendlyReason(reason) { +// Copy keyed off the provider-agnostic error taxonomy that adapters map their +// own failures into (functions/src/providers/types.ts's ProviderErrorCode). +// This is the preferred classification: guessing from an HTTP status (below) +// was an OSF-era assumption that does not survive other providers -- Dataverse +// returns 400 for BOTH write contention and quota-exceeded, so a status-based +// map either mislabels them or, as before this change, shows the researcher a +// raw string like "Provider error 400: Failed to add file to dataset." +const PROVIDER_ERROR_COPY = { + // Contention is routine and self-resolving: some providers (Dataverse) + // accept only one write per container at a time, so simultaneous + // submissions collide. The retry lands within a couple of minutes, so this + // copy is deliberately reassuring rather than alarming. + CONTENTION: + "Your storage provider was busy with another upload from this experiment. This is normal when several participants finish at once, and it is being retried automatically.", + RATE_LIMITED: "Your storage provider rate-limited the request.", + AUTH_EXPIRED: + "Authentication error. Your storage provider connection may need to be refreshed.", + QUOTA_EXCEEDED: + "Your storage provider is out of space, or this file is larger than it allows.", + NAME_CONFLICT: + "A file with this name already exists in your storage provider.", + UNAVAILABLE: "Your storage provider was temporarily unavailable.", +}; + +// Fallback for queue docs written before providerErrorCode was stored on them, +// and for failures that never reached the provider at all (interrupted +// uploads, collision-cache and metadata problems), which carry no taxonomy +// code. +function legacyFriendlyReason(reason) { if (!reason) return null; if (reason.includes("interrupted upload") || reason.includes("memory limit")) { return "Upload was interrupted by a server restart or memory limit."; @@ -36,6 +64,12 @@ function friendlyReason(reason) { return reason; } +function friendlyReason(entry) { + const copy = PROVIDER_ERROR_COPY[entry?.providerErrorCode]; + if (copy) return copy; + return legacyFriendlyReason(entry?.failureReason); +} + function statusBadge(status) { const labels = { pending: { color: "orange", text: "Retrying" }, @@ -247,7 +281,7 @@ export default function QueuePanel({ entries, experimentId }) { </Table.Cell> <Table.Cell> <Text fontSize="xs"> - {friendlyReason(entry.failureReason) || "\u2014"} + {friendlyReason(entry) || "\u2014"} </Text> </Table.Cell> <Table.Cell> From 0c08f2968127ea0c4b6cac00f90e23fb2912549a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 17:46:15 -0400 Subject: [PATCH 062/181] feat: read and warn on Dataverse API token expiry Confirms and acts on something the design doc asserted but nobody had verified. Dataverse API tokens DO expire: generateApiTokenForUser calls generateApiTokenForUser(au, INTERVAL.YEARS, 1), and live confirmation -- a token created 2026-07-26 reports "expires on 2027-07-26". Crucially the Dataverse UI never shows this, so a researcher can be blindsided mid-study. resolveToken has had a PROVIDER_TOKEN_EXPIRED branch gated on connection.tokenExpiresAt since the adapter landed, but nothing ever set that field -- it was dead code. connectStaticTokenProvider now reads the expiry at connect time and persists it, making that branch live. A failure or an unknown expiry never fails the connect: the token is already validated, and "we don't know when this expires" is the same omitted-field state the endpoint always had. setupWarnings also checks expiry, warning when the token has already expired or expires within 60 days, naming the date and noting Dataverse does not surface it. It fetches LIVE rather than reading the stored value, because a researcher who recreates their token moves the expiry out by a year and the stored value would pessimistically warn about an expiry that no longer applies. The version check and the expiry check run independently, so an old installation with a near-expiry token reports both. The expiry is only obtainable from GET /api/users/token, which embeds it in an ENGLISH SENTENCE -- the server literally does ok(String.format("Token %s expires on %s", ...)) with no structured field. parseTokenExpiry is therefore deliberately defensive: it returns null for a reworded message, an empty string, or an unparseable date, and everything fails open to no warning. Java's Timestamp format carries no timezone, so the parsed instant can be off by up to a day (live: 4 hours) -- fine at 60-day granularity, and commented so nobody mistakes it for precise. Also corrects the design doc, which claimed the lifetime was "installation-configurable" in two places. The one-year lifetime is confirmed; the configurability is NOT -- a search of SettingsServiceBean's key enum found only MinutesUntilConfirmEmailTokenExpires, which is email confirmation. Now stated as "one year, effectively fixed; no admin setting found" rather than repeating an unverified claim. One pre-existing test asserted setupWarnings made exactly one fetch; it now makes two (version + expiry), so that count was updated and a second mocked response queued. Its meaningful assertions are unchanged. Full suite green across two runs: 43 suites / 376 tests. Next build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/provider-migration-design.md | 13 +- .../connect-static-token-emulator.test.js | 53 ++++++ .../src/__tests__/providers-dataverse.test.js | 150 ++++++++++++++++- functions/src/connect-provider.ts | 27 ++- functions/src/providers/dataverse.ts | 154 ++++++++++++++---- functions/src/providers/types.ts | 6 + 6 files changed, 366 insertions(+), 37 deletions(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 771c928..0f53b34 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -51,9 +51,14 @@ already provider-agnostic and requiring no change. The OSF-specific surface: the account, DataPipe never holds a password, access must survive unattended for the life of a study (months). **Deliberately relaxed for Dataverse**, which only offers static API tokens: accepted because DataPipe already - maintains an equivalent PAT path for OSF, but Dataverse tokens expire - (commonly yearly, installation-configurable), so the unattended-for-months - constraint requires expiry-warning UX, not just token storage. + maintains an equivalent PAT path for OSF, but Dataverse tokens expire one + year after creation (`generateApiTokenForUser(au, INTERVAL.YEARS, 1)`, + verified live: a token created 2026-07-26 reports expiry 2027-07-26) — + effectively fixed, no admin setting found that exposes a different + lifetime — so the unattended-for-months constraint requires expiry-warning + UX, not just token storage. The Dataverse UI never surfaces this expiry; + only `GET /api/users/token` does, and DataPipe now reads it at connect + time and warns again at experiment setup. 2. A file-write API that handles **binary media as well as text** — the `/api/base64` path (audio/video recordings) is in scope for all providers from day one, so per-provider file-size caps and quota behavior are launch @@ -248,7 +253,7 @@ handling) carry over unchanged — already provider-agnostic. | | Google Drive | Figshare | Dataverse | |---|---|---|---| | Auth | OAuth2, `drive.file` scope | OAuth2, `authorization_code` + `refresh_token` | Static API token — same shape as today's OSF PAT fallback (`usingPersonalToken`) | -| Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire** (commonly yearly, installation-configurable) — needs expiry-warning UX, not just storage | +| Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire one year after creation**, effectively fixed — no admin setting found that exposes a different lifetime (confirmed via `generateApiTokenForUser(au, INTERVAL.YEARS, 1)`; a search of `SettingsServiceBean`'s key enum found only `MinutesUntilConfirmEmailTokenExpires`, which is email confirmation, not API tokens). The Dataverse UI does not surface this expiry at all — only `GET /api/users/token` does, which DataPipe now reads at connect time and warns on again at experiment setup — needs expiry-warning UX, not just storage | | Container | **App-created "DataPipe" folder at Drive root.** Under `drive.file` the app can only touch files it created or the user explicitly picked — a researcher-picked parent would force a Google Picker frontend integration for little gain. Revisit only if researchers demand placement control | Article inside a Project (two levels only) | Dataset inside a Collection | | Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | | Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case | Per-file and total-quota caps on the free tier; upload is a multi-step multipart flow (initiate → parts → complete) with correspondingly more failure modes; no in-place update (delete + re-upload) | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed via `tabIngest`, which requires **Dataverse >= 5.11** (released 2022-06-13); older installations silently ignore it. The adapter's `setupWarnings` checks `/api/info/version` and warns at experiment setup | diff --git a/functions/src/__tests__/connect-static-token-emulator.test.js b/functions/src/__tests__/connect-static-token-emulator.test.js index 1e57154..490b865 100644 --- a/functions/src/__tests__/connect-static-token-emulator.test.js +++ b/functions/src/__tests__/connect-static-token-emulator.test.js @@ -181,6 +181,59 @@ describe("connectStaticTokenProvider", () => { expect(mockFetch).toHaveBeenCalledWith(`${SERVER_URL}/api/users/:me`, expect.anything()); }); + it("persists tokenExpiresAt when staticTokenExpiry resolves a number", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + // First call is validateStaticToken (/api/users/:me); second is the new + // staticTokenExpiry call (/api/users/token) connect-provider.ts now makes + // after a successful validation. + mockFetch.mockResolvedValueOnce({ status: 200 }); + mockFetch.mockResolvedValueOnce({ + status: 200, + json: () => + Promise.resolve({ + status: "OK", + data: { message: "Token 72211678-a30c-4523-81bf-e703c904656e expires on 2027-07-26 14:14:52.317" }, + }), + }); + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "dataverse", + uid, + idToken, + token: "plaintext-dataverse-api-token", + serverUrl: SERVER_URL, + }); + + expect(status).toBe(200); + expect(body).toEqual({ success: true, provider: "dataverse" }); + + const userData = await getUserData(uid); + const dataverse = userData.connectedAccounts.dataverse; + expect(typeof dataverse.tokenExpiresAt).toBe("number"); + expect(new Date(dataverse.tokenExpiresAt).getUTCFullYear()).toBe(2027); + }); + + it("persists no tokenExpiresAt field when staticTokenExpiry resolves null, and still succeeds", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + mockFetch.mockResolvedValueOnce({ status: 200 }); // validateStaticToken succeeds + mockFetch.mockResolvedValueOnce({ status: 404 }); // staticTokenExpiry fails open -> null + + const { status, body } = await callConnectStaticTokenProvider({ + provider: "dataverse", + uid, + idToken, + token: "plaintext-dataverse-api-token", + serverUrl: SERVER_URL, + }); + + expect(status).toBe(200); + expect(body).toEqual({ success: true, provider: "dataverse" }); + + const userData = await getUserData(uid); + const dataverse = userData.connectedAccounts.dataverse; + expect(dataverse).not.toHaveProperty("tokenExpiresAt"); + }); + it("rejects an invalid token with 400 and persists nothing", async () => { const { uid, idToken } = await signUpEmulatorUser(); mockFetch.mockResolvedValueOnce({ status: 401 }); diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js index b7958d7..9819530 100644 --- a/functions/src/__tests__/providers-dataverse.test.js +++ b/functions/src/__tests__/providers-dataverse.test.js @@ -16,7 +16,7 @@ jest.mock("node-fetch", () => ({ default: (...args) => mockFetch(...args), })); -import { dataverseProvider, supportsTabIngest } from "../../lib/providers/dataverse.js"; +import { dataverseProvider, supportsTabIngest, parseTokenExpiry } from "../../lib/providers/dataverse.js"; const SERVER_URL = "https://dataverse.mock.test"; @@ -875,14 +875,28 @@ describe("10. supportsTabIngest (version parsing, lenient by design)", () => { }); describe("11. setupWarnings", () => { - it("returns [] when the installation reports a version >= 5.11", async () => { + it("returns [] when the installation reports a version >= 5.11 and the token isn't near expiry", async () => { mockFetch.mockResolvedValueOnce( mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "6.11" } } }) ); + // setupWarnings also runs the (independent) expiry check -- see suite + // 12 below -- so a second call against /api/users/token happens here + // too. A far-future expiry keeps this scenario warning-free. + const farFuture = new Date(Date.now() + 2 * 365 * 24 * 60 * 60 * 1000) + .toISOString() + .replace("T", " ") + .replace("Z", ""); + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { message: `Token abc expires on ${farFuture}` } }, + }) + ); const result = await dataverseProvider.setupWarnings(auth); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); const { url, options } = callArgs(0); expect(url).toBe(`${SERVER_URL}/api/info/version`); expect(options.method).toBe("GET"); @@ -921,6 +935,136 @@ describe("11. setupWarnings", () => { expect(result).toEqual([]); }); + + it("returns an expiry warning naming the date when the token expires within 60 days", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "6.11" } } }) + ); + const tenDaysOut = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000); + const message = `Token abc expires on ${tenDaysOut.toISOString().slice(0, 10)} 14:14:52.317`; + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { message } } }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toHaveLength(1); + expect(result[0]).toContain(tenDaysOut.toISOString().slice(0, 10)); + expect(result[0]).toMatch(/recreated and reconnected/i); + }); + + it("returns an expired-token warning when the token's expiry is in the past", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "6.11" } } }) + ); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + const message = `Token abc expires on ${yesterday.toISOString().slice(0, 10)} 14:14:52.317`; + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { message } } }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toHaveLength(1); + expect(result[0]).toMatch(/expired/i); + expect(result[0]).toMatch(/recreated and reconnected/i); + }); + + it("returns no expiry warning when the token expires 2 years out", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "6.11" } } }) + ); + const twoYearsOut = new Date(Date.now() + 2 * 365 * 24 * 60 * 60 * 1000); + const message = `Token abc expires on ${twoYearsOut.toISOString().slice(0, 10)} 14:14:52.317`; + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { message } } }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toEqual([]); + }); + + it("returns BOTH warnings when an old version AND a near expiry apply together", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { version: "5.10" } } }) + ); + const tenDaysOut = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000); + const message = `Token abc expires on ${tenDaysOut.toISOString().slice(0, 10)} 14:14:52.317`; + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: { message } } }) + ); + + const result = await dataverseProvider.setupWarnings(auth); + + expect(result).toHaveLength(2); + expect(result.some((w) => /5\.10/.test(w) && /5\.11/i.test(w))).toBe(true); + expect(result.some((w) => w.includes(tenDaysOut.toISOString().slice(0, 10)))).toBe(true); + }); +}); + +describe("12. parseTokenExpiry", () => { + it("parses the real live-verified message into a time in 2027", () => { + const ms = parseTokenExpiry("Token 72211678-a30c-4523-81bf-e703c904656e expires on 2027-07-26 14:14:52.317"); + + expect(ms).not.toBeNull(); + expect(new Date(ms).getUTCFullYear()).toBe(2027); + }); + + it("returns null for a reworded message", () => { + expect(parseTokenExpiry("Your token will no longer be valid after 2027-07-26 14:14:52.317")).toBeNull(); + }); + + it("returns null for an empty string", () => { + expect(parseTokenExpiry("")).toBeNull(); + }); + + it("returns null for a message with an unparseable date", () => { + expect(parseTokenExpiry("Token abc expires on not-a-real-date")).toBeNull(); + }); +}); + +describe("13. staticTokenExpiry", () => { + it("returns epoch ms on a 200 response with a valid message", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + status: "OK", + data: { message: "Token 72211678-a30c-4523-81bf-e703c904656e expires on 2027-07-26 14:14:52.317" }, + }, + }) + ); + + const result = await dataverseProvider.staticTokenExpiry(auth); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const { url, options } = callArgs(0); + expect(url).toBe(`${SERVER_URL}/api/users/token`); + expect(options.method).toBe("GET"); + expect(header(options.headers, "X-Dataverse-key")).toBe("test-token"); + expect(result).not.toBeNull(); + expect(new Date(result).getUTCFullYear()).toBe(2027); + }); + + it("returns null on a 404", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 404, statusText: "Not Found", jsonBody: { status: "ERROR", message: "not found" } }) + ); + + const result = await dataverseProvider.staticTokenExpiry(auth); + + expect(result).toBeNull(); + }); + + it("returns null (never throws) when fetch rejects", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + const result = await dataverseProvider.staticTokenExpiry(auth); + + expect(result).toBeNull(); + }); }); describe("9. validateStaticToken", () => { diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts index 7097700..ae3e687 100644 --- a/functions/src/connect-provider.ts +++ b/functions/src/connect-provider.ts @@ -269,10 +269,32 @@ export const connectStaticTokenProvider = onRequest({ cors: true }, async (req, return; } + // Best-effort: if the provider can report its credential's expiry (only + // Dataverse does today), fetch it now and persist it so resolveToken's + // PROVIDER_TOKEN_EXPIRED branch -- previously dead, since nothing ever + // set tokenExpiresAt -- actually has data to act on. A failure or an + // unknown (null) expiry here MUST NOT fail the connect: the token was + // already validated above, and "we don't know when this expires" is not + // an error, just the same omitted-field state the endpoint always had. + let tokenExpiresAt: number | null = null; + if (storageProvider.staticTokenExpiry) { + try { + tokenExpiresAt = await storageProvider.staticTokenExpiry({ token, serverUrl: normalizedServerUrl }); + } catch (e) { + console.error( + 'Static token expiry check error:', + e instanceof Error ? e.message : 'Unknown error' + ); + tokenExpiresAt = null; + } + } + // Same dot-path persist convention as connectProvider: set()+mergeFields // creates users/{uid} if absent and leaves sibling provider connections - // untouched. tokenExpiresAt is deliberately omitted -- Dataverse does not - // tell us the expiry at connect time, and the field is optional. + // untouched. tokenExpiresAt is included only when it resolved to a + // number -- Firestore rejects undefined, and resolveToken already treats + // a missing tokenExpiresAt as "no known expiry", so omitting it entirely + // when unknown is correct, not a gap. const fieldPath = `connectedAccounts.${provider}`; await db.doc(`users/${uid}`).set( { @@ -281,6 +303,7 @@ export const connectStaticTokenProvider = onRequest({ cors: true }, async (req, authMethod: 'static-token', encryptedToken: encrypt(token), serverUrl: normalizedServerUrl, + ...(tokenExpiresAt !== null ? { tokenExpiresAt } : {}), }, }, }, diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index cb7fe5d..deecadd 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -193,6 +193,42 @@ export function supportsTabIngest(rawVersion: string): boolean | null { return minor >= 11; } +// How far ahead of a token's expiry setupWarnings starts telling researchers +// to recreate + reconnect. 60 days: long enough to act on for a study that +// might otherwise run unattended for months, short enough that the warning +// doesn't fire a whole year early. +const TOKEN_EXPIRY_WARNING_MS = 60 * 24 * 60 * 60 * 1000; + +// Extracts the expiry timestamp Dataverse embeds in an ENGLISH SENTENCE -- +// there is no structured field anywhere in GET /api/users/token. Verified +// live, 2026-07-26: {"status":"OK","data":{"message":"Token <uuid> expires +// on 2027-07-26 14:14:52.317"}}. The server source is literally +// `ok(String.format("Token %s expires on %s", token.getTokenString(), +// token.getExpireTime()))` -- so this is parsing prose, not a contract. +// +// Two deliberate trade-offs, both accepted: +// 1. The timestamp is Java `Timestamp.toString()` format: "yyyy-MM-dd +// HH:mm:ss.SSS" -- a SPACE, not "T", and NO TIMEZONE. It's the server's +// local time, which we have no way to know, so the result can be off by +// up to a day. That's fine for a 60-day-ahead warning and must never be +// presented as precise. +// 2. Parsing a date out of prose is inherently brittle -- if a future +// Dataverse version rewords this message, this silently returns null +// rather than throwing. That's an accepted fail-open trade-off: an +// unparseable expiry means no expiry warning, not a broken connect/setup +// flow. +export function parseTokenExpiry(message: string): number | null { + const match = /expires on\s+(.+?)\s*$/.exec(message); + if (!match) { + return null; + } + // Normalize Java's space-separated, timezone-less format into something + // Date can parse: "2027-07-26 14:14:52.317" -> "2027-07-26T14:14:52.317". + const isoish = match[1].replace(" ", "T"); + const ms = new Date(isoish).getTime(); + return Number.isFinite(ms) ? ms : null; +} + interface AddFileResponseBody { status?: string; data?: { @@ -266,6 +302,41 @@ export const dataverseProvider: StorageProvider = { return response.status === 200; }, + // Reads the token's expiry from the one endpoint that reports it, GET + // /api/users/token. Never throws -- a non-200, unexpected body shape, or a + // message parseTokenExpiry can't parse all just mean "unknown expiry", + // which callers (connect-provider.ts, setupWarnings below) treat as null, + // not an error. + async staticTokenExpiry(auth: ResolvedAuth): Promise<number | null> { + try { + const serverUrl = resolveServerUrl(auth); + const response = await fetch(`${serverUrl}/api/users/token`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (response.status !== 200) { + console.warn(`dataverse staticTokenExpiry: /api/users/token returned ${response.status}`); + return null; + } + + const body = (await response.json()) as { data?: { message?: string } }; + const message = body.data?.message; + if (!message) { + console.warn("dataverse staticTokenExpiry: unexpected /api/users/token response shape"); + return null; + } + + return parseTokenExpiry(message); + } catch (e) { + console.warn( + "dataverse staticTokenExpiry: failed to check token expiry:", + e instanceof Error ? e.message : "Unknown error" + ); + return null; + } + }, + async createDataContainer(auth: ResolvedAuth, researcherInput: Record<string, unknown>): Promise<ContainerRef> { const serverUrl = resolveServerUrl(auth); const collectionAlias = researcherInput.collectionAlias as string; @@ -524,8 +595,13 @@ export const dataverseProvider: StorageProvider = { // every write -- a one-time, non-blocking advisory so a researcher on an // old installation finds out now, rather than discovering months later // that every CSV they thought was raw got converted to Dataverse's - // archival .tab format. + // archival .tab format. Runs the version check and the expiry check + // independently so BOTH warnings can be returned together (e.g. an old, + // soon-to-expire installation) -- one check failing/failing-open must + // never suppress the other's warning. async setupWarnings(auth: ResolvedAuth): Promise<string[]> { + const warnings: string[] = []; + try { const serverUrl = resolveServerUrl(auth); @@ -542,33 +618,28 @@ export const dataverseProvider: StorageProvider = { console.warn( `dataverse setupWarnings: /api/info/version returned ${response.status}, failing open (no warning)` ); - return []; - } - - const body = (await response.json()) as { status?: string; data?: { version?: string } }; - const version = body.data?.version; - if (body.status !== "OK" || !version) { - console.warn( - "dataverse setupWarnings: unexpected /api/info/version response shape, failing open (no warning)" - ); - return []; - } - - const supported = supportsTabIngest(version); - if (supported === null) { - console.warn(`dataverse setupWarnings: could not parse version "${version}", failing open (no warning)`); - return []; - } - - if (supported) { - return []; + } else { + const body = (await response.json()) as { status?: string; data?: { version?: string } }; + const version = body.data?.version; + if (body.status !== "OK" || !version) { + console.warn( + "dataverse setupWarnings: unexpected /api/info/version response shape, failing open (no warning)" + ); + } else { + const supported = supportsTabIngest(version); + if (supported === null) { + console.warn( + `dataverse setupWarnings: could not parse version "${version}", failing open (no warning)` + ); + } else if (!supported) { + warnings.push( + `This Dataverse installation reports version ${version}, which predates Dataverse 5.11. ` + + "Tabular-ingest suppression is unavailable on installations older than 5.11, so CSV files " + + "uploaded to this dataset will be converted to Dataverse's archival .tab format. JSON data is unaffected." + ); + } + } } - - return [ - `This Dataverse installation reports version ${version}, which predates Dataverse 5.11. ` + - "Tabular-ingest suppression is unavailable on installations older than 5.11, so CSV files " + - "uploaded to this dataset will be converted to Dataverse's archival .tab format. JSON data is unaffected.", - ]; } catch (e) { // FAIL OPEN, DELIBERATELY. This runs every time a researcher sets up // an experiment against this provider, not once -- so a transient @@ -582,8 +653,35 @@ export const dataverseProvider: StorageProvider = { "dataverse setupWarnings: failed to check installation version, failing open (no warning):", e instanceof Error ? e.message : "Unknown error" ); - return []; } + + // Expiry is fetched LIVE via staticTokenExpiry rather than read from the + // stored tokenExpiresAt -- a researcher who recreates their token on the + // Dataverse installation moves the expiry out by another year, and the + // stored value (set once, at connect time) would otherwise be stale and + // pessimistically warn about an expiry that no longer applies. + // Non-null assertion: staticTokenExpiry is defined a few lines up on this + // same object literal -- it's only optional in the StorageProvider + // interface for providers that don't implement it at all. + const expiresAt = await dataverseProvider.staticTokenExpiry!(auth); + if (expiresAt !== null) { + const now = Date.now(); + if (expiresAt < now) { + warnings.push( + "The API token for this Dataverse installation has expired and must be recreated and reconnected " + + "before data can be saved to this experiment." + ); + } else if (expiresAt - now < TOKEN_EXPIRY_WARNING_MS) { + const expiryDate = new Date(expiresAt).toISOString().slice(0, 10); + warnings.push( + `The API token for this Dataverse installation expires on ${expiryDate}. It must be recreated and ` + + "reconnected before then, or data can no longer be saved to this experiment. Dataverse does not " + + "surface this expiry anywhere in its own UI." + ); + } + } + + return warnings; }, async downloadFile( diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 66ab361..619afce 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -165,6 +165,12 @@ export interface StorageProvider { // static-token providers only validateStaticToken?(auth: ResolvedAuth): Promise<boolean>; + // static-token providers only. Returns the credential's absolute expiry as + // epoch milliseconds, or null when the provider does not report one or it + // cannot be determined. Never throws -- callers treat null as "unknown" + // and must not fail on it. + staticTokenExpiry?(auth: ResolvedAuth): Promise<number | null>; + // Optional, non-blocking, researcher-facing advisories checked when an // experiment is being set up against this provider (see // provider-setup-warnings.ts). Returns human-readable strings for the UI From 2f42ad411edc1266f8931c13aca446bc58f7c867 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sun, 26 Jul 2026 21:20:36 -0400 Subject: [PATCH 063/181] docs: audit the pre-spike claims; several were wrong or stale Two inherited claims had already failed verification this week (ingest suppression "version-dependent"; token expiry "installation-configurable"), so this checks the rest against primary sources and the repo. Dated 2026-07-26 inline so future readers can tell audited claims from unaudited ones. FIGSHARE -- the most consequential, and it has had no spike at all: - "historically ~500 files/item" is a CURRENT documented limit, not a historical approximation. Also uncovered: 500 items, 50 versions/item, 20 GB total and 20 GB per file on the free tier. - NEW RISK the doc never raised: Figshare documents no automatic rate limiting but asks clients to stay under 1 request/second, with no documented 429. The upload flow has no single-request path even for small files, so each session file is ~4 API calls -- a 30-student burst is ~120 requests/minute against a 1/second guideline. That is in direct tension with requirement 6. - "Subfolders: None" was WRONG: folder hierarchies are supported via the API up to 10 levels. The File object exposes only a flat name, so the encoding is unverified and a prefix fallback may still be what we build. - "Article inside a Project (two levels only)" omitted Collections, a parallel top-level container that also holds Articles via API. RULED-OUT PROVIDERS -- three justifications were inaccurate: - ICPSR "no automated API at all" is wrong; it has a read-only Metadata Export API. The defensible claim is no automated DEPOSIT API. - Box "strongest native atomic-write guarantee of anything evaluated" does not survive: S3 added ETag conditional writes in Nov 2024. Box's edge is breadth of operations, not strength. - Dryad's fee is tiered ($150 only up to 5 GB, rising to $6.08/GB; waivers capped at 10 GB), not a flat $150/dataset. - Zenodo's dismissal overstates its rigidity: drafts DO take incremental API writes with no documented time limit, and published records stay editable 30 days plus versioning. Its real blocker is 100 files / 50 GB per record. The conclusion stands; the stated reason was wrong. GOOGLE DRIVE -- claims hold up, with two notes: - 6-month refresh-token disuse, 7-day testing-mode tokens and the 100-user cap all confirmed. The 7-day rule exempts profile-only scopes, which does not help us. - Favourable point the doc omitted: drive.file is non-sensitive and needs only basic verification, avoiding the restricted-scope security assessment. - Watch item, secondary sources only: new Google accounts may start at 5 GB until a phone number is linked, which would make quota exhaustion likelier. REPO-SIDE, two claims had simply aged: - 409 is no longer the "sole" collision mechanism; collision-cache.ts is the primary gate and 409 a backstop. - generate-oauth-state.ts no longer "carries over unchanged" -- it is provider-aware now. Confirmed unchanged: AES-256-GCM, and the four hardcoded OSF regions. Checked and no action needed: gdrive reads a Retry-After header that Google does not document sending, but an absent header yields null and falls back to exponential backoff, which is Google's own recommendation. Full suite green: 43 suites / 376 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/provider-migration-design.md | 48 ++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 0f53b34..9551a3b 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -31,8 +31,10 @@ already provider-agnostic and requiring no change. The OSF-specific surface: `resolve-token.ts`, `generate-oauth-state.ts`, plus a parallel static Personal-Access-Token path (`save-osf-token.ts`, `get-osf-token.ts`). - **File writes**: `put-file-osf.ts`, `update-file-osf.ts`, `subfolder.ts` — built - on OSF's Waterbutler API, and explicitly relying on OSF's `409 Conflict` response - as the sole collision-detection mechanism for per-session data files. + on OSF's Waterbutler API. (STALE as of 2026-07-26: 409 is no longer the sole + collision-detection mechanism — the Firestore collision cache in + `collision-cache.ts` is now the primary gate and 409 is a backstop, per the + dual-run plan in "Collision detection" below.) - **Metadata handling**: `metadata-block.ts`, `metadata-process.ts` — reconciles a mutable `dataset_description.json` file against a live provider-side folder listing on every update. @@ -86,17 +88,33 @@ already provider-agnostic and requiring no change. The OSF-specific surface: around a curate-once-publish-once-mint-a-DOI workflow, structurally mismatched to DataPipe's hundreds-of-small-incremental-writes-over-months pattern. This turned out to be a category-wide limitation, not specific to any one vendor — + **though the category framing overstates the case for Zenodo specifically** + (verified 2026-07-26): Zenodo drafts DO accept incremental file writes over + the API with no documented time limit on how long a draft may stay + unpublished, and published records stay editable for 30 days plus DOI + versioning after that. The real blocker for Zenodo is its per-record cap of + **100 files / 50 GB** (200 GB by one-time exception), which a + semester-long study exceeds — a size limit, not a workflow mismatch. The + conclusion stands; the stated reason was wrong. — confirmed across every Dataverse-software installation (Harvard, Borealis, DataverseNL, DataverseNO, DANS all share the same open-source codebase, same static-token-only auth, same silent-rename-on-duplicate-filename behavior). - ICPSR has no automated API at all. Dryad supports incremental draft writes but + ICPSR has no automated DEPOSIT API (it does publish a read-only Metadata + Export API for searching/exporting study metadata — verified 2026-07-26, so + the earlier "no automated API at all" was wrong as stated; deposit itself is + web-form only). Dryad supports incremental draft writes but only via a shared service-account grant (no per-researcher OAuth consent) and - charges a $150/dataset publishing fee. Databrary is architecturally a gated, + charges a tiered publishing fee (verified 2026-07-26: $150 for ≤5 GB, $180 for ≤10 GB, $520 for ≤50 GB, up to $6.08/GB beyond; waivers are not approved above 10 GB) — the earlier flat "$150/dataset" understated it. Databrary is architecturally a gated, human-reviewed video library, not a general write target. - **Box, Amazon S3, Dropbox, Microsoft OneDrive/Graph** — all technically solid, - generic-storage options with no platform-fit ambiguity. Box has the strongest - native atomic-write guarantee of anything evaluated and is already common at - universities; S3 has the best region control and longest clean API history but + generic-storage options with no platform-fit ambiguity. Box offers a real + conditional-write guarantee (If-Match, 412 on mismatch) across a wide range of + operations and is already common at universities — though the earlier claim + that it was the *strongest of anything evaluated* does not survive checking: + S3 added ETag-based conditional writes (If-Match/If-None-Match on PutObject + and CompleteMultipartUpload) in November 2024, so the two are comparable and + Box's advantage is breadth of operation types, not strength (verified + 2026-07-26); S3 has the best region control and longest clean API history but breaks OAuth-onboarding simplicity (self-provisioned IAM). These remain reasonable fallback options but were not selected as the initial three. **Box is the pre-approved substitute**: if a conditional provider fails its @@ -245,21 +263,23 @@ users/{uid}: { } ``` -`crypto-utils.ts` (AES-256-GCM) and `generate-oauth-state.ts` (CSRF state -handling) carry over unchanged — already provider-agnostic. +`crypto-utils.ts` (AES-256-GCM — confirmed still accurate) carries over +unchanged. `generate-oauth-state.ts` did NOT: it is now provider-aware (it +takes an optional `provider` and returns an `authorizeUrl`), so the original +"carry over unchanged" no longer holds for it. ### Per-provider adapter notes | | Google Drive | Figshare | Dataverse | |---|---|---|---| | Auth | OAuth2, `drive.file` scope | OAuth2, `authorization_code` + `refresh_token` | Static API token — same shape as today's OSF PAT fallback (`usingPersonalToken`) | -| Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire one year after creation**, effectively fixed — no admin setting found that exposes a different lifetime (confirmed via `generateApiTokenForUser(au, INTERVAL.YEARS, 1)`; a search of `SettingsServiceBean`'s key enum found only `MinutesUntilConfirmEmailTokenExpires`, which is email confirmation, not API tokens). The Dataverse UI does not surface this expiry at all — only `GET /api/users/token` does, which DataPipe now reads at connect time and warns on again at experiment setup — needs expiry-warning UX, not just storage | -| Container | **App-created "DataPipe" folder at Drive root.** Under `drive.file` the app can only touch files it created or the user explicitly picked — a researcher-picked parent would force a Google Picker frontend integration for little gain. Revisit only if researchers demand placement control | Article inside a Project (two levels only) | Dataset inside a Collection | -| Subfolders | Native | **None** — filename-prefix fallback, surfaced in UI as a known limitation | Native via `directoryLabel` | -| Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case | Per-file and total-quota caps on the free tier; upload is a multi-step multipart flow (initiate → parts → complete) with correspondingly more failure modes; no in-place update (delete + re-upload) | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed via `tabIngest`, which requires **Dataverse >= 5.11** (released 2022-06-13); older installations silently ignore it. The adapter's `setupWarnings` checks `/api/info/version` and warns at experiment setup | +| Auth longevity | Refresh tokens are revoked after ~6 months of disuse — paused studies need reconnect UX. App must reach **published** OAuth verification status: testing mode means 7-day refresh tokens and a 100-user cap (both verified 2026-07-26; the 7-day rule exempts apps requesting only name/email/profile, which does not help us since we need `drive.file`). Note in our favour: `drive.file` is classified non-sensitive and needs only **basic** OAuth verification — the restricted-scope security assessment that `drive`/`drive.readonly` trigger does not apply | Long-lived; confirm rotation/expiry behavior in the spike | Tokens **expire one year after creation**, effectively fixed — no admin setting found that exposes a different lifetime (confirmed via `generateApiTokenForUser(au, INTERVAL.YEARS, 1)`; a search of `SettingsServiceBean`'s key enum found only `MinutesUntilConfirmEmailTokenExpires`, which is email confirmation, not API tokens). The Dataverse UI does not surface this expiry at all — only `GET /api/users/token` does, which DataPipe now reads at connect time and warns on again at experiment setup — needs expiry-warning UX, not just storage | +| Container | **App-created "DataPipe" folder at Drive root.** Under `drive.file` the app can only touch files it created or the user explicitly picked — a researcher-picked parent would force a Google Picker frontend integration for little gain. Revisit only if researchers demand placement control | Article inside a Project (two levels — no recursive project nesting); Collections are a parallel top-level container that can also hold Articles via API | Dataset inside a Collection | +| Subfolders | Native | Folder hierarchies ARE supported via the API, preserved up to 10 levels (verified 2026-07-26 — the earlier "None" was wrong). But the File object exposes only a flat `name` with no `directoryLabel`-equivalent, so the exact encoding is UNVERIFIED and a filename-prefix fallback may still be what we implement | Native via `directoryLabel` | +| Media / size limits | Free quota is 15 GB **shared with Gmail/Photos** — quota exhaustion is an expected support scenario for audio/video studies, not an edge case. WATCH ITEM (unverified, secondary reporting only, 2026-07-26): new Google accounts may now start at 5 GB until a phone number is linked. Worth confirming against a primary source before launch, since it would make quota exhaustion more likely, not less | Free tier is **20 GB total / 20 GB per file**, plus hard caps of **500 files per item, 500 items, 50 versions per item** (all current documented limits, verified 2026-07-26). Upload is always a multi-step flow (initiate → parts → complete) with **no single-request path even for small files**, so each session file costs ~4 API calls; no in-place content update (delete + re-upload). Figshare documents **no automatic rate limiting** but asks clients to stay under **1 request/second** and reserves the right to throttle or block, with no documented 429 — see the burst risk below | Per-installation size caps (federation → varies); CSV uploads are **"ingested"** into archival `.tab` format unless suppressed via `tabIngest`, which requires **Dataverse >= 5.11** (released 2022-06-13); older installations silently ignore it. The adapter's `setupWarnings` checks `/api/info/version` and warns at experiment setup | | Federation | Single global service | Single global service | **Federated** — Harvard, Borealis, DataverseNL, etc. are different servers; `serverUrl` must be stored per researcher, and DataPipe integrates whatever software version each installation runs (version drift is a permanent fact of this adapter) | | DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | -| Gating spike | None (comfortable fit) — but OAuth app verification has **weeks of lead time**; start it at build step 0 | See "Gating spikes" below — duplicate-filename behavior, per-item **file-count cap** (historically ~500 files/item; a semester-long study can exceed it), multipart burst behavior | See "Gating spikes" below — **dataset locking under concurrent adds** (most likely disqualifier in the plan), tabular-ingest suppression, silent-rename response shape | +| Gating spike | None (comfortable fit) — but OAuth app verification has **weeks of lead time**; start it at build step 0 | See "Gating spikes" below. Two of these are now DOCUMENTED rather than speculative (verified 2026-07-26): the **500-files-per-item cap** is a current stated limit, not a historical approximation; and Figshare's **1 request/second** guidance is in direct tension with requirement 6 (30–100 submissions/minute), since the mandatory multi-step upload makes each session file ~4 requests — a 30-student burst is ~120 requests/minute. Duplicate-filename behavior remains completely undocumented | See "Gating spikes" below — **dataset locking under concurrent adds** (most likely disqualifier in the plan), tabular-ingest suppression, silent-rename response shape | ### OAuth generalization From 1b3fe3ae21070e88f5db8e2f5ec099cbf95cd829 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 11:42:17 -0400 Subject: [PATCH 064/181] feat: Zenodo storage adapter Targets the legacy deposit API plus its bucket endpoint rather than the InvenioRDM-native drafts flow. Both generations are live on Zenodo; the bucket PUT costs ONE request per session file where the native flow costs three (init -> content -> commit), which against Zenodo's 100 req/min is ~100 vs ~33 sessions/minute of first-try throughput. Requirement 6 asks for 100/minute. Every HTTP call goes through a helper so that a future migration off the legacy layer stays contained. Three behaviors here were established live against sandbox.zenodo.org rather than from documentation, because each was wrong when guessed: - The bucket PUT accepts Content-Type: application/octet-stream and NOTHING else; sending the file's real mimetype is a hard 415. - Zenodo's keyspace is FLAT. A slash cannot be stored by any route: the bucket 404s whether the slash is literal or %2F-encoded, and the legacy multipart endpoint returns 201 while silently storing "data/raw/x.json" as "data_raw_x.json". Since the collision cache matches names exactly, a silent rename would break dedup the way Dataverse's dropped directoryLabel did, so the adapter flattens up front (toZenodoKey) and still reads storedFilename back off the response. - The 100-file cap surfaces as a 400 whose message says "exceeding", not "exceeds". Matching only the latter classified a permanently full record as UNAVAILABLE, which the queue retries forever. updateFile is a single overwriting PUT, not delete-then-PUT: a PUT to an existing key replaces it in place and leaves one listing entry. That makes Zenodo the only non-OSF provider whose metadata update is atomic. Consequence worth noting: metadataActive experiments upload to data/raw/<name>, so their live depositions are flat and not valid Psych-DS during collection. The Psych-DS tree is intended to live inside the compaction archive, which is not built yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- components/account/ProviderConnections.js | 9 +- .../src/__tests__/providers-zenodo.test.js | 514 +++++++++++++++++ functions/src/providers/index.ts | 3 + functions/src/providers/types.ts | 7 +- functions/src/providers/zenodo.ts | 531 ++++++++++++++++++ lib/provider-config.js | 30 + 6 files changed, 1092 insertions(+), 2 deletions(-) create mode 100644 functions/src/__tests__/providers-zenodo.test.js create mode 100644 functions/src/providers/zenodo.ts diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 7d995a4..42103b4 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -68,7 +68,14 @@ export default function ProviderConnections() { uid: user.uid, idToken, token: apiToken.trim(), - serverUrl: serverUrl.trim(), + // connectstatictokenprovider ALWAYS requires a serverUrl, but not + // every static-token provider is federated. Dataverse is (the + // researcher types their institution's installation); Zenodo is not + // -- there is exactly one production host -- so its config supplies + // a fixed defaultServerUrl and renders no field at all. + serverUrl: STORAGE_PROVIDERS[providerId]?.needsServerUrl + ? serverUrl.trim() + : STORAGE_PROVIDERS[providerId]?.defaultServerUrl, }), }); diff --git a/functions/src/__tests__/providers-zenodo.test.js b/functions/src/__tests__/providers-zenodo.test.js new file mode 100644 index 0000000..969347b --- /dev/null +++ b/functions/src/__tests__/providers-zenodo.test.js @@ -0,0 +1,514 @@ +/** + * @jest-environment node + */ + +// Runs in the node environment, not the project-default jsdom -- mirrors +// providers-dataverse.test.js / providers-gdrive.test.js. node-fetch is +// ESM-only with no CJS build, so it must be mocked here rather than resolved. + +const mockFetch = jest.fn(); + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { zenodoProvider, isAllowedZenodoServer } from "../../lib/providers/zenodo.js"; + +const SERVER_URL = "https://sandbox.zenodo.org"; +const BUCKET_URL = "https://sandbox.zenodo.org/api/files/abc-123"; +const DEPOSITION_ID = 987654; + +beforeEach(() => { + mockFetch.mockClear(); +}); + +function mockResponse({ status, statusText, jsonBody, textBody, headers }) { + return { + status, + statusText, + json: () => Promise.resolve(jsonBody), + text: () => Promise.resolve(textBody), + headers: { get: (name) => (headers || {})[name.toLowerCase()] ?? null }, + }; +} + +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +function callArgs(index = 0) { + const [url, options] = mockFetch.mock.calls[index]; + return { url, options }; +} + +const auth = { token: "test-token", serverUrl: SERVER_URL }; + +const container = { + provider: "zenodo", + depositionId: DEPOSITION_ID, + bucketUrl: BUCKET_URL, + serverUrl: SERVER_URL, +}; + +const meta = { size: 12, contentType: "application/json" }; + +describe("1. resolveToken", () => { + it("returns the decrypted token and serverUrl for a connected account", async () => { + const userData = { + connectedAccounts: { + zenodo: { + authMethod: "static-token", + // decrypt() falls back to plaintext for values without the "v1:" + // prefix, so a plain string round-trips without needing + // TOKEN_ENCRYPTION_KEY set up for this test. + encryptedToken: "plain-token", + serverUrl: SERVER_URL, + }, + }, + }; + const result = await zenodoProvider.resolveToken(userData, "owner-uid"); + expect(result).toEqual({ success: true, token: "plain-token", serverUrl: SERVER_URL }); + }); + + it("fails with PROVIDER_NOT_CONNECTED when there is no zenodo account", async () => { + const result = await zenodoProvider.resolveToken({ connectedAccounts: {} }, "owner-uid"); + expect(result.success).toBe(false); + expect(result.error).toBe("PROVIDER_NOT_CONNECTED"); + }); + + // Zenodo tokens have no documented expiry so tokenExpiresAt is normally + // absent -- but if one is ever stored it must still be honored rather than + // ignored. + it("honors a stored tokenExpiresAt in the past", async () => { + const userData = { + connectedAccounts: { + zenodo: { + authMethod: "static-token", + encryptedToken: "plain-token", + serverUrl: SERVER_URL, + tokenExpiresAt: Date.now() - 1000, + }, + }, + }; + const result = await zenodoProvider.resolveToken(userData, "owner-uid"); + expect(result.success).toBe(false); + expect(result.error).toBe("PROVIDER_TOKEN_EXPIRED"); + }); + + it("does not implement staticTokenExpiry (Zenodo reports no expiry)", () => { + expect(zenodoProvider.staticTokenExpiry).toBeUndefined(); + }); +}); + +describe("2. server allowlist", () => { + it("accepts the two real Zenodo hosts", () => { + expect(isAllowedZenodoServer("https://zenodo.org")).toBe(true); + expect(isAllowedZenodoServer("https://sandbox.zenodo.org")).toBe(true); + }); + + it("rejects lookalike and unrelated hosts", () => { + expect(isAllowedZenodoServer("https://zenodo.org.evil.test")).toBe(false); + expect(isAllowedZenodoServer("https://evil.test")).toBe(false); + expect(isAllowedZenodoServer("not-a-url")).toBe(false); + }); + + it("refuses to make a request against a non-Zenodo server", async () => { + await expect( + zenodoProvider.listFiles( + { token: "t", serverUrl: "https://evil.test" }, + { ...container, serverUrl: "https://evil.test" } + ) + ).rejects.toThrow(/not a recognized zenodo installation/i); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // The bucket URL is Firestore-stored data that every byte flows through, + // and downloadFile echoes the response body back to the caller. + it("rejects a bucketUrl whose origin does not match the container's server", async () => { + const tampered = { ...container, bucketUrl: "https://evil.test/api/files/abc-123" }; + const result = await zenodoProvider + .writeSessionFile(auth, tampered, "s.json", "{}", meta) + .catch((e) => e); + expect(result).toBeInstanceOf(Error); + expect(result.message).toMatch(/bucketurl origin does not match/i); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("3. createDataContainer", () => { + it("creates a dataset deposition and returns the ref", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 201, + jsonBody: { id: DEPOSITION_ID, links: { bucket: BUCKET_URL } }, + }) + ); + + const ref = await zenodoProvider.createDataContainer(auth, { + title: "My Experiment", + creatorName: "Doe, Jane", + description: "A study.", + affiliation: "Test University", + }); + + expect(ref).toEqual({ + provider: "zenodo", + depositionId: DEPOSITION_ID, + bucketUrl: BUCKET_URL, + serverUrl: SERVER_URL, + }); + + const { url, options } = callArgs(0); + expect(url).toBe(`${SERVER_URL}/api/deposit/depositions`); + expect(options.method).toBe("POST"); + expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); + + const body = JSON.parse(options.body); + expect(body.metadata.upload_type).toBe("dataset"); + expect(body.metadata.title).toBe("My Experiment"); + expect(body.metadata.creators).toEqual([{ name: "Doe, Jane", affiliation: "Test University" }]); + }); + + it("omits affiliation when not supplied", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 201, jsonBody: { id: DEPOSITION_ID, links: { bucket: BUCKET_URL } } }) + ); + await zenodoProvider.createDataContainer(auth, { + title: "T", + creatorName: "Doe, Jane", + description: "D", + }); + const body = JSON.parse(callArgs(0).options.body); + expect(body.metadata.creators).toEqual([{ name: "Doe, Jane" }]); + }); + + // Both fields are load-bearing for every later write, so a malformed + // success body must fail here rather than at the first participant. + it("throws when the response omits the bucket link", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, jsonBody: { id: DEPOSITION_ID, links: {} } })); + await expect( + zenodoProvider.createDataContainer(auth, { title: "T", creatorName: "C", description: "D" }) + ).rejects.toThrow(/no id or bucket link/i); + }); + + it("throws with the provider's field-level detail on a validation error", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 400, + jsonBody: { + message: "Validation error.", + errors: [{ field: "metadata.description", message: "Field may not be null." }], + }, + }) + ); + await expect( + zenodoProvider.createDataContainer(auth, { title: "T", creatorName: "C", description: "" }) + ).rejects.toThrow(/metadata\.description: Field may not be null/); + }); +}); + +describe("4. writeSessionFile", () => { + it("PUTs raw bytes to the bucket and reports the stored key", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 201, jsonBody: { key: "session-1.json", size: 12, checksum: "md5:abc" } }) + ); + + const result = await zenodoProvider.writeSessionFile(auth, container, "session-1.json", "{}", meta); + + expect(result).toEqual({ + success: true, + fileRef: { name: "session-1.json", id: "session-1.json" }, + storedFilename: "session-1.json", + }); + + const { url, options } = callArgs(0); + expect(url).toBe(`${BUCKET_URL}/session-1.json`); + expect(options.method).toBe("PUT"); + // Must be octet-stream, not meta.contentType. The bucket endpoint rejects + // anything else with a hard 415 (live sandbox, spike gate A, 2026-08-11), + // and `meta` here declares application/json -- so this assertion is + // specifically guarding against reintroducing that bug. + expect(header(options.headers, "Content-Type")).toBe("application/octet-stream"); + expect(Buffer.isBuffer(options.body)).toBe(true); + }); + + // Zenodo's keyspace is FLAT -- a slash cannot be stored by any route, and + // the legacy multipart endpoint silently rewrites "a/b.json" to "a_b.json". + // The adapter therefore flattens up front so the name it reports is the name + // Zenodo actually holds. See toZenodoKey in providers/zenodo.ts. + it("flattens path separators into the key and encodes the result", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 201, jsonBody: { key: "data_raw set_a b.json" } }) + ); + const result = await zenodoProvider.writeSessionFile( + auth, + container, + "data/raw set/a b.json", + "{}", + meta + ); + expect(callArgs(0).url).toBe(`${BUCKET_URL}/data_raw%20set_a%20b.json`); + expect(result.storedFilename).toBe("data_raw set_a b.json"); + }); + + // metadataActive experiments upload to data/raw/<name>, so this is the + // ordinary path for them, not an edge case. + it("collapses backslashes and runs of separators too", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, jsonBody: { key: "a_b.json" } })); + await zenodoProvider.writeSessionFile(auth, container, "a//\\b.json", "{}", meta); + expect(callArgs(0).url).toBe(`${BUCKET_URL}/a_b.json`); + }); + + // The whole point of WriteResult.storedFilename: never assume the provider + // kept the name we asked for. + it("reports a server-renamed key rather than the requested name", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: { key: "renamed.json" } })); + const result = await zenodoProvider.writeSessionFile(auth, container, "asked.json", "{}", meta); + expect(result.storedFilename).toBe("renamed.json"); + expect(result.fileRef).toEqual({ name: "renamed.json", id: "renamed.json" }); + }); + + it("falls back to the requested name when the response has no key", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, jsonBody: {} })); + const result = await zenodoProvider.writeSessionFile(auth, container, "asked.json", "{}", meta); + expect(result.storedFilename).toBe("asked.json"); + }); + + // The fallback must report the FLATTENED name, not the raw request: the + // collision cache matches names exactly, so recording "data/raw/x.json" for + // an object Zenodo stored as "data_raw_x.json" would silently break dedup. + it("falls back to the flattened name, not the raw slashed one", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, jsonBody: {} })); + const result = await zenodoProvider.writeSessionFile(auth, container, "data/raw/x.json", "{}", meta); + expect(result.storedFilename).toBe("data_raw_x.json"); + expect(result.fileRef).toEqual({ name: "data_raw_x.json", id: "data_raw_x.json" }); + }); + + it("accepts a Buffer body unchanged", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, jsonBody: { key: "b.bin" } })); + const buf = Buffer.from([1, 2, 3]); + await zenodoProvider.writeSessionFile(auth, container, "b.bin", buf, { + size: 3, + contentType: "application/octet-stream", + }); + expect(callArgs(0).options.body).toEqual(buf); + }); +}); + +describe("5. error mapping", () => { + const cases = [ + { status: 401, jsonBody: { message: "Unauthorized" }, expected: "AUTH_EXPIRED" }, + // Under-scoped tokens 403 -- same fix as an invalid one, so same code. + { status: 403, jsonBody: { message: "Insufficient scope" }, expected: "AUTH_EXPIRED" }, + { status: 413, jsonBody: { message: "Too large" }, expected: "QUOTA_EXCEEDED" }, + { status: 507, jsonBody: { message: "Insufficient storage" }, expected: "QUOTA_EXCEEDED" }, + { status: 400, jsonBody: { message: "File exceeds the size limit" }, expected: "QUOTA_EXCEEDED" }, + // VERBATIM message Zenodo returns at the 101st file, captured live + // (sandbox, spike gate E, 2026-08-11). Note "exceeding" -- an earlier + // pattern matched only "exceeds" and sent this to UNAVAILABLE, which the + // queue retries forever against a record that can never accept a file + // again. This case exists to keep that regression from returning. + { + status: 400, + jsonBody: { message: "Uploading selected files will result in exceeding the max amount per record." }, + expected: "QUOTA_EXCEEDED", + }, + { status: 429, jsonBody: { message: "Rate limit exceeded" }, expected: "RATE_LIMITED" }, + { status: 500, jsonBody: { message: "Internal server error" }, expected: "UNAVAILABLE" }, + { status: 502, jsonBody: { message: "Bad gateway" }, expected: "UNAVAILABLE" }, + // A generic 400 is NOT quota -- it must not be misfiled as one. + { status: 400, jsonBody: { message: "Validation error." }, expected: "UNAVAILABLE" }, + ]; + + it.each(cases)("maps $status to $expected", async ({ status, jsonBody, expected }) => { + mockFetch.mockResolvedValueOnce(mockResponse({ status, jsonBody })); + const result = await zenodoProvider.writeSessionFile(auth, container, "s.json", "{}", meta); + expect(result.success).toBe(false); + expect(result.error).toBe(expected); + expect(result.providerStatus).toBe(status); + }); + + it("honors a numeric Retry-After header", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 429, jsonBody: { message: "slow down" }, headers: { "retry-after": "45" } }) + ); + const result = await zenodoProvider.writeSessionFile(auth, container, "s.json", "{}", meta); + expect(result.retryAfter).toBe(45); + }); + + // Invenio signals rate limiting mainly through X-RateLimit-Reset (an + // absolute epoch), so Retry-After is often absent -- never invent one. + it("returns a null retryAfter when the header is absent or unparseable", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 429, jsonBody: { message: "slow down" } })); + expect((await zenodoProvider.writeSessionFile(auth, container, "s.json", "{}", meta)).retryAfter).toBeNull(); + + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 429, jsonBody: { message: "x" }, headers: { "retry-after": "Wed, 21 Oct 2026 07:28:00 GMT" } }) + ); + expect((await zenodoProvider.writeSessionFile(auth, container, "s.json", "{}", meta)).retryAfter).toBeNull(); + }); + + it("survives a non-JSON error body", async () => { + mockFetch.mockResolvedValueOnce({ + status: 502, + statusText: "Bad Gateway", + json: () => Promise.reject(new Error("not json")), + headers: { get: () => null }, + }); + const result = await zenodoProvider.writeSessionFile(auth, container, "s.json", "{}", meta); + expect(result.success).toBe(false); + expect(result.error).toBe("UNAVAILABLE"); + expect(result.providerMessage).toBe("Bad Gateway"); + }); +}); + +// updateFile was delete-then-PUT until spike gate A established live that a +// bucket PUT to an existing key replaces it in place and leaves exactly one +// listing entry (sandbox, 2026-08-11). It is now a single atomic call, with no +// window where the file does not exist -- so the assertions below are mostly +// about the DELETE never coming back. +describe("6. updateFile", () => { + it("overwrites with a single PUT and no preceding delete", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 201, jsonBody: { key: "dataset_description.json" } }) + ); + + const result = await zenodoProvider.updateFile( + auth, + container, + { name: "dataset_description.json", id: "dataset_description.json" }, + "{}", + meta + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(callArgs(0).options.method).toBe("PUT"); + expect(callArgs(0).url).toBe(`${BUCKET_URL}/dataset_description.json`); + expect(mockFetch.mock.calls.some(([, o]) => o.method === "DELETE")).toBe(false); + }); + + it("addresses the existing ref's name, not a re-derived one", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: { key: "m.json" } })); + const result = await zenodoProvider.updateFile(auth, container, { name: "m.json" }, "{}", meta); + expect(result.success).toBe(true); + expect(result.fileRef).toEqual({ name: "m.json", id: "m.json" }); + }); + + it("returns a mapped failure rather than throwing", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 403, jsonBody: { message: "Forbidden" } })); + + const result = await zenodoProvider.updateFile(auth, container, { name: "m.json" }, "{}", meta); + expect(result.success).toBe(false); + expect(result.error).toBe("AUTH_EXPIRED"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("7. listFiles", () => { + it("returns refs from the deposition files listing", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + jsonBody: [ + { id: "uuid-1", filename: "a.json" }, + { id: "uuid-2", filename: "data/raw/b.json" }, + ], + }) + ); + + const files = await zenodoProvider.listFiles(auth, container); + + // id is the KEY, not the deposition-file UUID: every operation this + // adapter performs addresses objects by key, so refs from listFiles and + // from writeSessionFile must be interchangeable. + expect(files).toEqual([ + { name: "a.json", id: "a.json" }, + { name: "data/raw/b.json", id: "data/raw/b.json" }, + ]); + expect(callArgs(0).url).toBe(`${SERVER_URL}/api/deposit/depositions/${DEPOSITION_ID}/files`); + }); + + it("reads the bucket-shaped `key` field as well as `filename`", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, jsonBody: [{ key: "c.json" }, { filename: "d.json" }] }) + ); + const files = await zenodoProvider.listFiles(auth, container); + expect(files.map((f) => f.name)).toEqual(["c.json", "d.json"]); + }); + + // The collision cache matches on exact names, so a ref with an undefined + // name would let a duplicate session filename through. + it("drops entries that carry no usable name", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, jsonBody: [{ id: "uuid-1" }, { filename: "" }, { filename: "ok.json" }] }) + ); + const files = await zenodoProvider.listFiles(auth, container); + expect(files).toEqual([{ name: "ok.json", id: "ok.json" }]); + }); + + it("throws on a failed listing", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 500, jsonBody: { message: "boom" } })); + await expect(zenodoProvider.listFiles(auth, container)).rejects.toThrow(/Zenodo listing failed: 500 boom/); + }); +}); + +describe("8. downloadFile", () => { + it("GETs the key from the bucket and returns its text", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, textBody: '{"a":1}' })); + const result = await zenodoProvider.downloadFile(auth, container, { name: "m.json", id: "m.json" }); + expect(result).toEqual({ success: true, content: '{"a":1}' }); + expect(callArgs(0).url).toBe(`${BUCKET_URL}/m.json`); + expect(callArgs(0).options.method).toBe("GET"); + }); + + it("returns a mapped failure rather than throwing", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 404, jsonBody: { message: "Not found" } })); + const result = await zenodoProvider.downloadFile(auth, container, { name: "gone.json" }); + expect(result.success).toBe(false); + expect(result.error).toBe("UNAVAILABLE"); + expect(result.providerStatus).toBe(404); + }); +}); + +describe("9. validateStaticToken", () => { + it("returns true on 200", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: [] })); + expect(await zenodoProvider.validateStaticToken(auth)).toBe(true); + expect(callArgs(0).url).toBe(`${SERVER_URL}/api/deposit/depositions?size=1`); + }); + + // An under-scoped token is "not valid" here rather than an exception -- + // catching it at connect time is the point. + it("returns false on 403 without throwing", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 403, jsonBody: { message: "Insufficient scope" } })); + expect(await zenodoProvider.validateStaticToken(auth)).toBe(false); + }); +}); + +describe("10. registry wiring", () => { + it("declares the capability surface the framework reads", () => { + expect(zenodoProvider.id).toBe("zenodo"); + expect(zenodoProvider.authMethod).toBe("static-token"); + // No folder concept in either Zenodo API generation -- the framework's + // filename-prefix fallback has to apply. + expect(zenodoProvider.capabilities.nativeSubfolders).toBe(false); + expect(zenodoProvider.capabilities.maxFileSizeBytes).toBe(50 * 1024 * 1024 * 1024); + }); + + // lib/provider-config.js mirrors this by hand and must stay in sync. + it("declares the containerInput fields the new-experiment form renders", () => { + expect(zenodoProvider.containerInput.map((f) => f.name)).toEqual([ + "creatorName", + "description", + "affiliation", + ]); + expect(zenodoProvider.containerInput.filter((f) => f.required).map((f) => f.name)).toEqual([ + "creatorName", + "description", + ]); + }); +}); diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index 5fcd277..a94a310 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -2,12 +2,14 @@ import { registerProvider, getProvider } from "./registry.js"; import { osfProvider } from "./osf.js"; import { gdriveProvider } from "./gdrive.js"; import { dataverseProvider } from "./dataverse.js"; +import { zenodoProvider } from "./zenodo.js"; import { StorageProvider, StorageProviderId, ContainerRef, OAuthConfig } from "./types.js"; import { ExperimentData } from "../interfaces.js"; registerProvider(osfProvider); registerProvider(gdriveProvider); registerProvider(dataverseProvider); +registerProvider(zenodoProvider); // OAuth config for the generic storage-GRANT flow // (docs/provider-migration-design.md, scratchpad/step4b-oauth-connect-spec.md). @@ -54,4 +56,5 @@ export { registerProvider, getProvider, listProviders } from "./registry.js"; export { osfProvider } from "./osf.js"; export { gdriveProvider } from "./gdrive.js"; export { dataverseProvider } from "./dataverse.js"; +export { zenodoProvider } from "./zenodo.js"; export * from "./types.js"; diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 619afce..65fd9a8 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -8,7 +8,7 @@ // is erased at compile time and is safe. import type { UserData } from "../interfaces.js"; -export type StorageProviderId = "osf" | "gdrive" | "figshare" | "dataverse"; +export type StorageProviderId = "osf" | "gdrive" | "figshare" | "dataverse" | "zenodo"; export type AuthMethod = "oauth2" | "static-token"; @@ -241,6 +241,11 @@ export interface ConnectedAccounts { gdrive?: OAuth2AccountConnection; figshare?: OAuth2AccountConnection; dataverse?: StaticTokenAccountConnection; + // Zenodo reuses the static-token shape, but its tokenExpiresAt is expected + // to stay ABSENT: Zenodo personal access tokens have no documented expiry + // and no endpoint reports one, so zenodo.ts implements no staticTokenExpiry + // and connect-provider.ts therefore omits the field. + zenodo?: StaticTokenAccountConnection; } // experiments/{id}.collisionCache (additive Firestore schema). The salt is a diff --git a/functions/src/providers/zenodo.ts b/functions/src/providers/zenodo.ts new file mode 100644 index 0000000..fdc2418 --- /dev/null +++ b/functions/src/providers/zenodo.ts @@ -0,0 +1,531 @@ +import fetch from "node-fetch"; +import { decrypt } from "../crypto-utils.js"; +import { UserData } from "../interfaces.js"; +import { + StorageProvider, + ResolvedAuth, + ContainerRef, + FileRef, + FileMeta, + WriteResult, + DownloadResult, + ProviderErrorCode, + TokenResult, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// WHICH ZENODO API THIS TARGETS, AND WHY +// +// Zenodo now runs on InvenioRDM, and BOTH API generations are live -- verified +// 2026-07-27 against zenodo.org and sandbox.zenodo.org: +// GET /api/deposit/depositions -> 403 (route exists, requires auth) +// GET /api/records -> 200 +// +// This adapter targets the LEGACY DEPOSIT API (/api/deposit/depositions) plus +// its "new files API" bucket endpoint, for two reasons: +// +// 1. It is what Zenodo's own current developer documentation describes +// (developers.zenodo.org). The InvenioRDM-native drafts API is documented +// upstream at inveniordm.docs.cern.ch, not by Zenodo. +// 2. Cost per session write. A bucket PUT is ONE request per file. The +// InvenioRDM-native flow is three (POST .../draft/files to initialize the +// key, PUT .../content, POST .../commit). Against Zenodo's documented +// 100 requests/minute for authenticated users that is the difference +// between ~100 and ~33 sessions/minute of first-try throughput -- and +// 100/minute is the stated requirement. +// +// The risk this accepts: the legacy API is a compatibility layer over +// InvenioRDM and could eventually be retired. Every HTTP call in this file +// therefore goes through the small helpers below (depositUrl/bucketUrl/ +// zenodoFetch) rather than being inlined, so a future move to the native +// drafts API is contained to those helpers plus writeSessionFile/listFiles. +// --------------------------------------------------------------------------- + +// The Zenodo container ref shape. `bucketUrl` is handed to us by Zenodo on +// deposition creation and is the target for every file byte that moves -- +// storing it avoids re-fetching the deposition before each write, which would +// double the request cost of the whole point of using this API (see above). +export interface ZenodoContainerRef extends ContainerRef { + provider: "zenodo"; + depositionId: number; + bucketUrl: string; + serverUrl: string; +} + +// Zenodo is NOT federated -- unlike Dataverse there is exactly one production +// installation plus one sandbox. serverUrl exists only to let researchers (and +// the live spike) point at the sandbox, so it is an ALLOWLIST, not free-form +// input. connect-provider.ts's isAllowedServerUrl already blocks the obvious +// SSRF shapes, but that gate is generic and would happily accept any public +// https host; there is no legitimate third Zenodo, so anything else is +// rejected here rather than trusted. +const ALLOWED_HOSTS = new Set(["zenodo.org", "sandbox.zenodo.org"]); + +export function isAllowedZenodoServer(serverUrl: string): boolean { + try { + return ALLOWED_HOSTS.has(new URL(serverUrl).hostname); + } catch { + return false; + } +} + +// 50 GB, both per file and per record (help.zenodo.org). Descriptive only -- +// capabilities are never a correctness gate (see types.ts). +const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 * 1024; + +function authHeaders(auth: ResolvedAuth): Record<string, string> { + // Header rather than Zenodo's supported ?access_token= query parameter, so + // the credential never lands in a URL that could reach a log or an error + // message. + return { Authorization: `Bearer ${auth.token}` }; +} + +// serverUrl can come from the container (a deposition knows which installation +// it lives on) or from auth (the researcher's connection), container winning -- +// same precedence as dataverse.ts's resolveServerUrl, for the same reason: +// calls made before a container exists (createDataContainer, +// validateStaticToken) only have auth. +function resolveServerUrl(auth: ResolvedAuth, container?: ContainerRef): string { + const fromContainer = (container as ZenodoContainerRef | undefined)?.serverUrl; + const serverUrl = fromContainer ?? auth.serverUrl; + if (!serverUrl) { + throw new Error("Zenodo serverUrl is missing from both the container and the resolved auth"); + } + if (!isAllowedZenodoServer(serverUrl)) { + throw new Error(`Not a recognized Zenodo installation: ${serverUrl}`); + } + return serverUrl; +} + +// bucketUrl is a full URL taken from a Zenodo API RESPONSE, and everything +// this adapter uploads and downloads goes to it. Even though it originates +// from an already-allowlisted host, it is re-checked against the container's +// own serverUrl before use: a stored container ref is Firestore data, and this +// keeps a tampered or corrupted providerContainer from redirecting writes (and +// downloadFile's echoed response body) somewhere else entirely. +function resolveBucketUrl(container: ZenodoContainerRef, serverUrl: string): string { + const { bucketUrl } = container; + if (!bucketUrl) { + throw new Error("Zenodo container is missing its bucketUrl"); + } + let parsed: URL; + try { + parsed = new URL(bucketUrl); + } catch { + throw new Error(`Zenodo container has a malformed bucketUrl: ${bucketUrl}`); + } + if (parsed.origin !== new URL(serverUrl).origin) { + throw new Error(`Zenodo bucketUrl origin does not match the container's server: ${bucketUrl}`); + } + return bucketUrl.replace(/\/+$/, ""); +} + +// ZENODO'S KEYSPACE IS FLAT. A slash cannot appear in a file key by any route, +// and this was established live rather than assumed (sandbox, 2026-08-11): +// +// - bucket PUT with literal slashes -> 404 (URL addresses a path that +// does not exist) +// - bucket PUT with the key %2F-encoded -> 404 (the router decodes it back +// to a slash before matching) +// - legacy multipart POST with name="data/raw/probe.json" -> 201, but +// STORED AS "data_raw_probe.json". Zenodo silently rewrites it. +// +// That last one is why the flattening happens here, deliberately, instead of +// being left to the service: a silent server-side rename is exactly the class +// of bug that broke Dataverse's directoryLabel handling, because the collision +// cache matches names EXACTLY and would stop recognising the rehydrated name. +// Flattening to "_" reproduces Zenodo's own rewrite, so the key we ask for is +// the key we get. +// +// DataPipe does produce slashed paths in normal operation -- metadataActive +// experiments upload to data/raw/<name> and data/<base>_data.csv +// (metadata-derived-files.ts) -- so this path is load-bearing, not defensive. +// The Psych-DS directory structure is therefore NOT representable as Zenodo +// file keys; it is preserved inside the compaction archive instead, where the +// paths are ours to choose. See docs/provider-migration-design.md. +// +// Idempotent: names read back from Zenodo never contain slashes, so callers +// that pass an already-stored name (updateFile, downloadFile) are unaffected. +function toZenodoKey(name: string): string { + return name.replace(/[/\\]+/g, "_"); +} + +function encodeKey(key: string): string { + return encodeURIComponent(toZenodoKey(key)); +} + +function isSuccessStatus(status: number): boolean { + return status >= 200 && status < 300; +} + +interface MappedZenodoError { + error: ProviderErrorCode; + providerStatus: number; + providerMessage: string; + retryAfter: number | null; +} + +// Zenodo error bodies are JSON: {"status": 400, "message": "...", "errors": [...]}. +function mapZenodoError( + status: number, + statusText: string, + body: { message?: string; errors?: { field?: string; message?: string }[] } | undefined, + retryAfterHeader?: string | null +): MappedZenodoError { + // Field-level errors carry the useful detail (e.g. which metadata field was + // rejected); the top-level message is often just "Validation error." + const fieldDetail = body?.errors + ?.map((e) => [e.field, e.message].filter(Boolean).join(": ")) + .filter(Boolean) + .join("; "); + const message = [body?.message ?? statusText, fieldDetail].filter(Boolean).join(" — "); + + let error: ProviderErrorCode; + if (status === 401) { + error = "AUTH_EXPIRED"; + } else if (status === 403) { + // Zenodo returns 403 both for an invalid/revoked token and for a token + // whose scopes are insufficient (a PAT created without deposit:write). + // Neither is retryable and both are fixed the same way -- reconnect with a + // correctly scoped token -- so both map here. + error = "AUTH_EXPIRED"; + } else if (status === 413 || status === 507) { + error = "QUOTA_EXCEEDED"; + } else if (status === 400 && /quota|too large|exceed|size limit|max amount/i.test(message)) { + // Zenodo signals both of its hard caps as a plain 400 with prose, so the + // status alone cannot distinguish "you are out of room" (terminal, and the + // trigger for compaction) from "transient server problem" (retry). The + // literal 100-file-cap message, captured live at file 101 (sandbox, + // spike gate E, 2026-08-11), is: + // + // "Uploading selected files will result in exceeding the max amount + // per record." + // + // Note "exceeding", not "exceeds" -- the original pattern matched only the + // latter and sent this to UNAVAILABLE, which the queue would have retried + // indefinitely against a record that can never accept another file. Both + // "exceed" (covering either inflection) and "max amount" are matched now. + error = "QUOTA_EXCEEDED"; + } else if (status === 429) { + error = "RATE_LIMITED"; + } else { + error = "UNAVAILABLE"; + } + + // Only honor a Retry-After that is actually present and numeric. Invenio + // signals rate limiting primarily through X-RateLimit-Reset (an absolute + // epoch, not a delay) and Retry-After is not guaranteed -- so an absent or + // unparseable header yields null and the queue's own exponential backoff + // takes over, rather than inventing a delay. + let retryAfter: number | null = null; + if (retryAfterHeader) { + const seconds = parseInt(retryAfterHeader, 10); + if (Number.isFinite(seconds) && seconds > 0) { + retryAfter = seconds; + } + } + + return { error, providerStatus: status, providerMessage: message, retryAfter }; +} + +async function mapErrorResponse(response: { + status: number; + statusText: string; + json: () => Promise<unknown>; + headers?: { get: (name: string) => string | null }; +}): Promise<MappedZenodoError> { + let body: { message?: string; errors?: { field?: string; message?: string }[] } | undefined; + try { + body = (await response.json()) as { message?: string; errors?: { field?: string; message?: string }[] }; + } catch { + body = undefined; + } + return mapZenodoError(response.status, response.statusText, body, response.headers?.get("retry-after")); +} + +interface DepositionResponse { + id?: number; + links?: { bucket?: string; html?: string }; +} + +// Bucket PUT response (Invenio files-REST object shape). +interface BucketPutResponse { + key?: string; + size?: number; + checksum?: string; + version_id?: string; +} + +interface DepositionFileResponse { + id?: string; + filename?: string; + key?: string; + checksum?: string; + filesize?: number; +} + +export const zenodoProvider: StorageProvider = { + id: "zenodo", + authMethod: "static-token", + capabilities: { + // Zenodo file keys are a flat namespace -- there is no folder concept in + // either API generation. The framework's filename-prefix fallback applies. + nativeSubfolders: false, + supportsRegion: false, + maxFileSizeBytes: MAX_FILE_SIZE_BYTES, + quotaNote: + "Zenodo allows up to 100 files and 50 GB per record. DataPipe compacts completed sessions into archives to stay under the file limit.", + }, + + containerInput: [ + { name: "creatorName", label: "Creator name", required: true, placeholder: "Lastname, Firstname" }, + { name: "description", label: "Description", required: true, inputType: "textarea" }, + { name: "affiliation", label: "Affiliation", required: false, placeholder: "Your institution" }, + ], + + async resolveToken(userData: UserData, _owner: string): Promise<TokenResult> { + // _owner is unused: Zenodo is a static-token provider with no refresh token + // to rotate, so there is no persist-back step (cf. gdrive's resolveToken, + // which calls refreshGdriveToken(owner, ...)). + const zenodo = userData.connectedAccounts?.zenodo; + + if (!zenodo) { + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Zenodo account for this experiment's owner", + }; + } + + // No expiry branch here, unlike dataverse.ts. Zenodo personal access + // tokens have no documented expiry and the API exposes no endpoint that + // reports one, which is also why staticTokenExpiry is deliberately NOT + // implemented on this provider -- its absence means "this provider cannot + // report an expiry", which connect-provider.ts already handles by omitting + // tokenExpiresAt. If a stored tokenExpiresAt ever does appear (e.g. set by + // a future Zenodo change), it is still honored rather than ignored. + if (zenodo.tokenExpiresAt && zenodo.tokenExpiresAt < Date.now()) { + return { + success: false, + error: "PROVIDER_TOKEN_EXPIRED", + detail: "The Zenodo API token for this experiment's owner has expired", + }; + } + + return { success: true, token: decrypt(zenodo.encryptedToken), serverUrl: zenodo.serverUrl }; + }, + + async validateStaticToken(auth: ResolvedAuth): Promise<boolean> { + const serverUrl = resolveServerUrl(auth); + // size=1 keeps the response tiny -- this only needs the status code. A + // token missing the deposit:write scope still 403s here, which is the + // point: it would fail at the first upload otherwise, months later. + const response = await fetch(`${serverUrl}/api/deposit/depositions?size=1`, { + method: "GET", + headers: authHeaders(auth), + }); + // Never throw on a non-200 -- a bad or under-scoped token is "not valid", + // not an exceptional condition. + return response.status === 200; + }, + + async createDataContainer(auth: ResolvedAuth, researcherInput: Record<string, unknown>): Promise<ContainerRef> { + const serverUrl = resolveServerUrl(auth); + const title = researcherInput.title as string; + const creatorName = researcherInput.creatorName as string; + const description = researcherInput.description as string; + const affiliation = researcherInput.affiliation as string | undefined; + + const body = { + metadata: { + title, + // "dataset" rather than the default. Note that upload_type is the + // LEGACY field name; InvenioRDM's native API calls this resource_type. + // The legacy deposit API still expects upload_type, so changing this + // is part of any future move to the native drafts API, not a + // standalone fix. + upload_type: "dataset", + description, + creators: [ + { + name: creatorName, + ...(affiliation ? { affiliation } : {}), + }, + ], + }, + }; + + const response = await fetch(`${serverUrl}/api/deposit/depositions`, { + method: "POST", + headers: { + ...authHeaders(auth), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + // createDataContainer has no error union in the StorageProvider interface + // (matches osf/gdrive/dataverse) -- signal failure by throwing. + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Zenodo deposition creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const responseBody = (await response.json()) as DepositionResponse; + const depositionId = responseBody.id; + const bucketUrl = responseBody.links?.bucket; + + // Both are load-bearing for every subsequent write, so a deposition that + // came back without them is a hard failure now rather than a confusing + // one at the first participant's submission. + if (typeof depositionId !== "number" || !bucketUrl) { + throw new Error("Zenodo deposition creation returned no id or bucket link"); + } + + return { provider: "zenodo", depositionId, bucketUrl, serverUrl }; + }, + + async writeSessionFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + data: string | Buffer, + meta: FileMeta + ): Promise<WriteResult> { + const zenodoContainer = container as ZenodoContainerRef; + const serverUrl = resolveServerUrl(auth, zenodoContainer); + const bucket = resolveBucketUrl(zenodoContainer, serverUrl); + + const body = Buffer.isBuffer(data) ? data : Buffer.from(data); + + const response = await fetch(`${bucket}/${encodeKey(filename)}`, { + method: "PUT", + headers: { + ...authHeaders(auth), + // files-REST takes the raw bytes as the body, not multipart -- this is + // the whole reason the bucket endpoint costs one request instead of + // the native API's three. + // + // This MUST be application/octet-stream. Sending the real mimetype + // instead gets a hard 415 "Invalid 'Content-Type' header. Expected one + // of: application/octet-stream" -- the bucket endpoint accepts exactly + // that one value. (Live sandbox, spike gate A, 2026-08-11.) Zenodo + // infers the displayed file type from the key's extension, so nothing + // is lost by not sending meta.contentType here. + "Content-Type": "application/octet-stream", + "Content-Length": String(Buffer.byteLength(body)), + }, + body, + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + const responseBody = (await response.json()) as BucketPutResponse; + + // storedFilename comes from the response's `key`, never assumed to equal + // the requested name -- the same defensive read as dataverse.ts's `label`. + // It matters more here than it looks: Zenodo flattens slashes out of keys + // (see toZenodoKey), so the requested name and the stored name genuinely + // differ for metadataActive experiments, and the collision cache needs the + // stored one. The fallback is the flattened key rather than the raw + // `filename` so a response missing `key` still records what Zenodo holds. + const storedFilename = responseBody.key ?? toZenodoKey(filename); + + return { + success: true, + // Every Zenodo file operation this adapter performs addresses the object + // by KEY (bucket PUT/GET/DELETE), so the key is the durable identifier + // and `id` carries it rather than the deposition-file UUID. listFiles + // returns ids the same way, so refs from either source are + // interchangeable. metadata-block.ts also requires a defined id before + // it will persist a metadataFileRef, so leaving this unset would make it + // re-discover the metadata file by listing on every single submission. + fileRef: { name: storedFilename, id: storedFilename }, + storedFilename, + }; + }, + + // A plain overwriting PUT -- no delete first. + // + // This was originally delete-then-PUT, because Zenodo does not document + // whether a bucket PUT to an existing key replaces it and InvenioRDM's + // native API requires an explicit delete. Spike gate A settled it live + // (sandbox, 2026-08-11): re-PUTting an existing key replaced the content in + // place and left exactly ONE entry in the listing. So this is a single + // atomic call with no window where the file does not exist -- unlike + // dataverse.ts and Figshare, which still carry that caveat. + // + // Zenodo keeps the file's id stable across the replacement, so no ref + // rewriting is needed either. + async updateFile( + auth: ResolvedAuth, + container: ContainerRef, + existingFileRef: FileRef, + data: string | Buffer, + meta: FileMeta + ): Promise<WriteResult> { + return zenodoProvider.writeSessionFile(auth, container, existingFileRef.name, data, meta); + }, + + async listFiles(auth: ResolvedAuth, container: ContainerRef): Promise<FileRef[]> { + const zenodoContainer = container as ZenodoContainerRef; + const serverUrl = resolveServerUrl(auth, zenodoContainer); + + // No pagination loop, unlike dataverse.ts: a Zenodo record holds at most + // 100 files, and this endpoint returns the deposition's files in one + // response. If the cap ever rises, this needs revisiting. + const response = await fetch(`${serverUrl}/api/deposit/depositions/${zenodoContainer.depositionId}/files`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + throw new Error(`Zenodo listing failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + } + + const body = (await response.json()) as DepositionFileResponse[]; + + // The legacy deposition-files endpoint reports the name as `filename`; + // bucket-shaped responses use `key`. Read both so this keeps working + // whichever shape the compatibility layer returns, and drop entries with + // neither rather than emitting a FileRef with an undefined name -- the + // collision cache matches on exact names, so a bad entry there would let a + // duplicate through. + return (body || []) + .map((file) => file.filename ?? file.key) + .filter((name): name is string => typeof name === "string" && name.length > 0) + .map((name) => ({ name, id: name })); + }, + + async downloadFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise<DownloadResult> { + const zenodoContainer = container as ZenodoContainerRef; + const serverUrl = resolveServerUrl(auth, zenodoContainer); + const bucket = resolveBucketUrl(zenodoContainer, serverUrl); + + const response = await fetch(`${bucket}/${encodeKey(fileRef.name)}`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { + success: false, + error: mapped.error, + providerStatus: mapped.providerStatus, + providerMessage: mapped.providerMessage, + }; + } + + const content = await response.text(); + return { success: true, content }; + }, +}; diff --git a/lib/provider-config.js b/lib/provider-config.js index c1dbf59..9eb4f2c 100644 --- a/lib/provider-config.js +++ b/lib/provider-config.js @@ -69,4 +69,34 @@ export const STORAGE_PROVIDERS = { { name: "subject", label: "Subject", required: false, placeholder: "Social Sciences" }, ], }, + zenodo: { + id: "zenodo", + name: "Zenodo", + authMethod: "static-token", + // NOT federated, unlike Dataverse: there is one production Zenodo. The + // connect endpoint still requires a serverUrl, so this fixed value is sent + // on the researcher's behalf and no field is rendered + // (see ProviderConnections.js's handleTokenConnect). The sandbox + // (sandbox.zenodo.org) is reachable by the spike script, which calls the + // adapter directly, so it needs no researcher-facing option here. + needsServerUrl: false, + defaultServerUrl: "https://zenodo.org", + tokenLabel: "Personal access token", + tokenHelp: + "Create one under Applications → Personal access tokens in your Zenodo account settings. It needs the deposit:write and deposit:actions scopes. Zenodo tokens do not expire.", + isConnected: (userDoc) => !!userDoc?.connectedAccounts?.zenodo, + // Zenodo depositions stay unpublished while data is being collected, so + // the researcher-facing link is the deposit editor rather than a public + // record page (which does not exist until they publish). + containerLink: (exp) => + `https://zenodo.org/deposit/${exp.providerContainer?.depositionId}`, + containerLabel: "Zenodo Deposition", + containerLinkText: "Open deposition", + // Mirrors functions/src/providers/zenodo.ts's containerInput exactly. + containerInputFields: [ + { name: "creatorName", label: "Creator name", required: true, placeholder: "Lastname, Firstname" }, + { name: "description", label: "Description", required: true, multiline: true }, + { name: "affiliation", label: "Affiliation", required: false, placeholder: "Your institution" }, + ], + }, }; From 85b133efdd0c46a18cb878b193de05f164ed3883 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 11:42:25 -0400 Subject: [PATCH 065/181] test: add the Zenodo gating-spike script Drives the real compiled adapter against a live Zenodo, so it validates our request shapes, response parsing and error mapping at the same time as it validates the service. That paid for itself immediately: the first run failed three of four gates, and all three were adapter bugs rather than Zenodo limitations. Gates: A) does a bucket PUT overwrite an existing key; B) how slashed keys are handled; C) concurrent writes to one deposition, the gate Dataverse failed; D) files-archive on an unpublished draft; E) the 101st file's error code (opt-in, uploads 101 files). Never calls the publish action, so nothing here mints a DOI, and the draft is deleted at the end unless ZENODO_CLEANUP=0. Gate B no longer asserts that a slashed key round-trips verbatim -- that question is settled and the answer is no. It now asserts the thing that actually matters: that the name the adapter reports storing is the name Zenodo holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- scripts/zenodo-spike.mjs | 233 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 scripts/zenodo-spike.mjs diff --git a/scripts/zenodo-spike.mjs b/scripts/zenodo-spike.mjs new file mode 100644 index 0000000..f99b17d --- /dev/null +++ b/scripts/zenodo-spike.mjs @@ -0,0 +1,233 @@ +// Zenodo gating spike (docs/provider-migration-design.md). +// +// Drives the REAL shipping adapter (functions/lib/providers/zenodo.js) against +// a live Zenodo installation, so this validates our request shapes, response +// parsing and error mapping at the same time as it validates the service. +// +// Usage: +// cd functions && npm run build && cd .. +// ZENODO_TOKEN=xxxx node scripts/zenodo-spike.mjs +// +// Env: +// ZENODO_TOKEN (required) personal access token with deposit:write +// ZENODO_SERVER (default https://sandbox.zenodo.org) +// ZENODO_BURST (default 12) concurrent writes for gate C +// ZENODO_CAP_TEST (set to 1 to run gate E, which uploads 101 files -- slow) +// ZENODO_CLEANUP (default 1; set to 0 to leave the deposition behind) +// +// Leaves the deposition UNPUBLISHED and never calls the publish action, so +// nothing here mints a DOI. With cleanup on, the draft is deleted at the end. + +import { zenodoProvider } from "../functions/lib/providers/zenodo.js"; + +const token = process.env.ZENODO_TOKEN; +const serverUrl = process.env.ZENODO_SERVER || "https://sandbox.zenodo.org"; +const burst = Number(process.env.ZENODO_BURST || 12); +const runCapTest = process.env.ZENODO_CAP_TEST === "1"; +const cleanup = process.env.ZENODO_CLEANUP !== "0"; + +if (!token) { + console.error("ZENODO_TOKEN is required. See the header of this file."); + process.exit(1); +} + +const auth = { token, serverUrl }; +const results = []; +const record = (gate, verdict, detail) => { + results.push({ gate, verdict, detail }); + console.log(`\n[${verdict}] ${gate}\n ${detail}`); +}; + +const meta = (body) => ({ size: Buffer.byteLength(body), contentType: "application/json" }); + +// Distinct content per file, ALWAYS. The Dataverse spike initially reused one +// payload across a burst and Dataverse rejected the duplicates by checksum, +// which confounded the concurrency result entirely. Never reuse a payload in a +// burst again. +const payload = (label) => JSON.stringify({ label, at: new Date().toISOString(), pad: "x".repeat(64) }); + +async function main() { + console.log(`Zenodo spike against ${serverUrl} burst=${burst}\n`); + + const valid = await zenodoProvider.validateStaticToken(auth); + if (!valid) { + console.error("Token rejected. Check ZENODO_TOKEN's scopes (needs deposit:write) and ZENODO_SERVER."); + process.exit(1); + } + console.log("Token accepted."); + + const container = await zenodoProvider.createDataContainer(auth, { + title: `DataPipe spike ${new Date().toISOString()}`, + creatorName: "DataPipe, Spike", + description: "Automated gating spike. Never published; deleted after the run.", + }); + console.log(`Deposition ${container.depositionId} created (draft).`); + console.log(`Bucket ${container.bucketUrl}\n`); + + // ---- Gate A: does a bucket PUT to an existing key overwrite? ----------- + // The single most load-bearing unknown. Zenodo does not document it, and + // InvenioRDM's native API requires an explicit delete first. updateFile + // currently implements delete-then-PUT for safety; if this gate passes, + // that collapses to one call and stops being non-atomic. + { + const first = payload("gate-a-first"); + const second = payload("gate-a-second"); + const w1 = await zenodoProvider.writeSessionFile(auth, container, "gate-a.json", first, meta(first)); + const w2 = await zenodoProvider.writeSessionFile(auth, container, "gate-a.json", second, meta(second)); + + if (!w1.success) { + record("A. bucket PUT overwrite", "FAIL", `first write failed: ${w1.providerStatus} ${w1.providerMessage}`); + } else if (!w2.success) { + record( + "A. bucket PUT overwrite", + "FAIL", + `re-PUT to the same key was REJECTED (${w2.providerStatus} ${w2.providerMessage}). ` + + "Keep updateFile's delete-then-PUT." + ); + } else { + const back = await zenodoProvider.downloadFile(auth, container, { name: "gate-a.json" }); + const overwrote = back.success && back.content === second; + const files = await zenodoProvider.listFiles(auth, container); + const copies = files.filter((f) => f.name === "gate-a.json").length; + record( + "A. bucket PUT overwrite", + overwrote && copies === 1 ? "PASS" : "FAIL", + overwrote && copies === 1 + ? "PUT to an existing key replaced it in place, one entry in the listing. updateFile can drop its delete." + : `content-matched=${overwrote} listing-copies=${copies}. Keep delete-then-PUT.` + ); + } + } + + // ---- Gate B: slashed paths ------------------------------------------- + // ORIGINAL QUESTION: does a key containing "/" round-trip verbatim? + // ANSWERED, NO (2026-08-11): Zenodo's keyspace is flat and a slash cannot + // be stored by any route -- bucket PUT 404s whether the slash is literal or + // %2F-encoded, and the legacy multipart endpoint returns 201 while silently + // storing "data/raw/x.json" as "data_raw_x.json". + // + // So the adapter flattens deliberately (toZenodoKey) and this gate now + // checks the thing that actually matters: that the name the adapter REPORTS + // storing is the name Zenodo actually holds. The collision cache matches + // names EXACTLY, so any drift between those two silently breaks dedup -- + // the same class of bug as Dataverse's dropped directoryLabel. + { + const key = "data/raw/gate-b.json"; + const expected = "data_raw_gate-b.json"; + const body = payload("gate-b"); + const w = await zenodoProvider.writeSessionFile(auth, container, key, body, meta(body)); + if (!w.success) { + record("B. slashed key flattening", "FAIL", `write rejected: ${w.providerStatus} ${w.providerMessage}`); + } else { + const files = await zenodoProvider.listFiles(auth, container); + const found = files.find((f) => f.name === w.storedFilename); + const back = await zenodoProvider.downloadFile(auth, container, { name: w.storedFilename }); + const agrees = w.storedFilename === expected && !!found && back.success && back.content === body; + record( + "B. slashed key flattening", + agrees ? "PASS" : "FAIL", + `requested="${key}" stored="${w.storedFilename}" (expected "${expected}") ` + + `listed=${found ? `"${found.name}"` : "NOT FOUND"} readback=${back.success && back.content === body}` + ); + } + } + + // ---- Gate C: concurrent writes to one deposition ---------------------- + // The gate Dataverse failed: it accepts exactly ONE concurrent write per + // dataset and rejects the rest with a 400. Zenodo's bucket is object + // storage, so the expectation is that all of these succeed -- but that is + // exactly the kind of expectation this spike exists to check rather than + // assume. + { + const started = Date.now(); + const writes = await Promise.all( + Array.from({ length: burst }, (_, i) => { + const body = payload(`gate-c-${i}`); + return zenodoProvider.writeSessionFile(auth, container, `gate-c-${i}.json`, body, meta(body)); + }) + ); + const elapsed = Date.now() - started; + const ok = writes.filter((w) => w.success).length; + const byCode = {}; + for (const w of writes) { + if (!w.success) byCode[w.error] = (byCode[w.error] || 0) + 1; + } + const files = await zenodoProvider.listFiles(auth, container); + const landed = files.filter((f) => f.name.startsWith("gate-c-")).length; + + record( + "C. concurrent writes", + ok === burst && landed === burst ? "PASS" : ok > burst / 2 ? "PARTIAL" : "FAIL", + `${ok}/${burst} accepted, ${landed}/${burst} present in the listing, ${elapsed}ms wall clock` + + (Object.keys(byCode).length ? `, failures: ${JSON.stringify(byCode)}` : "") + ); + } + + // ---- Gate D: is files-archive available on an UNPUBLISHED draft? ------ + // If it is, the finalization step needs no code at all: the researcher gets + // a server-built zip of the whole record on demand. If it is published-only, + // the clean-final-structure plan depends on publishing first. + { + const response = await fetch(`${serverUrl}/api/records/${container.depositionId}/draft/files-archive`, { + headers: { Authorization: `Bearer ${token}` }, + }); + record( + "D. files-archive on a draft", + response.status === 200 ? "PASS" : "INFO", + `GET /api/records/${container.depositionId}/draft/files-archive -> ${response.status} ` + + `(content-type: ${response.headers.get("content-type")}). ` + + (response.status === 200 + ? "Draft-stage bulk download works." + : "Not available pre-publication; finalization must publish first.") + ); + } + + // ---- Gate E (opt-in): what happens at the 101st file? ----------------- + // Confirms the cap is real and that the adapter maps the refusal to + // QUOTA_EXCEEDED rather than a generic UNAVAILABLE -- the queue treats + // those very differently. + if (runCapTest) { + const existing = (await zenodoProvider.listFiles(auth, container)).length; + let firstRefusal = null; + for (let i = existing; i < 105; i++) { + const body = payload(`cap-${i}`); + const w = await zenodoProvider.writeSessionFile(auth, container, `cap-${i}.json`, body, meta(body)); + if (!w.success) { + firstRefusal = { at: i + 1, ...w }; + break; + } + } + record( + "E. 100-file cap", + firstRefusal ? (firstRefusal.error === "QUOTA_EXCEEDED" ? "PASS" : "PARTIAL") : "INFO", + firstRefusal + ? `refused at file ${firstRefusal.at}: ${firstRefusal.providerStatus} "${firstRefusal.providerMessage}" -> mapped ${firstRefusal.error}` + : "no refusal up to 105 files -- the documented 100-file cap did not bite here" + ); + } else { + console.log("\n[SKIP] E. 100-file cap (set ZENODO_CAP_TEST=1 to run)"); + } + + // ---- cleanup ---------------------------------------------------------- + if (cleanup) { + const del = await fetch(`${serverUrl}/api/deposit/depositions/${container.depositionId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + console.log(`\nCleanup: DELETE deposition ${container.depositionId} -> ${del.status}`); + } else { + console.log(`\nLeaving deposition ${container.depositionId} in place (ZENODO_CLEANUP=0).`); + } + + console.log("\n==== SUMMARY ===="); + for (const r of results) { + console.log(`${r.verdict.padEnd(8)} ${r.gate}`); + } + const failed = results.filter((r) => r.verdict === "FAIL"); + process.exit(failed.length > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error("\nSpike aborted:", e); + process.exit(1); +}); From 1bdabd2fecc566c46be68752dd41288cdfe4480b Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 11:42:33 -0400 Subject: [PATCH 066/181] docs: record the Zenodo spike result (all five gates PASS) Zenodo is the first provider to clear its gating spike outright -- Dataverse needed its verdict revised to CONDITIONAL PASS after failing gate A. Gate C is the headline: Dataverse accepts exactly one concurrent write per dataset and 400s the rest, while Zenodo took 12/12 in 1.25s. The fast-retry contention tier added in ff5d805 is not load-bearing here. Gate B was answered "no" and the adapter changed rather than the verdict; the flat-keyspace finding and its three probe results are recorded so the next person does not re-derive them. Gate D means finalization needs no server-side zip code -- draft records expose files-archive too. Also records the two adapter bugs the spike caught (415 on a non-octet- stream Content-Type, 404 on segment-encoded slashes) and the third from gate E (the cap message says "exceeding", not "exceeds"), since all three were invisible to a unit suite built on mocked responses. Adds an open question: Zenodo's flat keyspace means metadataActive experiments are not valid Psych-DS during collection. Working assumption is that the compaction archive carries the real tree; the alternatives are written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/provider-migration-design.md | 148 +++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 13 deletions(-) diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index 9551a3b..a76fd19 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -83,19 +83,21 @@ already provider-agnostic and requiring no change. The OSF-specific surface: the service even though no explicit ToS clause forbids it, and there's no precedent either way for this exact usage pattern. Ruled out on those grounds rather than a technical one. -- **Zenodo, Figshare (as archival), Harvard Dataverse, ICPSR, Dryad, Databrary, - DANS** — the entire "research-data-specific repository" category is built - around a curate-once-publish-once-mint-a-DOI workflow, structurally mismatched - to DataPipe's hundreds-of-small-incremental-writes-over-months pattern. This - turned out to be a category-wide limitation, not specific to any one vendor — - **though the category framing overstates the case for Zenodo specifically** - (verified 2026-07-26): Zenodo drafts DO accept incremental file writes over - the API with no documented time limit on how long a draft may stay - unpublished, and published records stay editable for 30 days plus DOI - versioning after that. The real blocker for Zenodo is its per-record cap of - **100 files / 50 GB** (200 GB by one-time exception), which a - semester-long study exceeds — a size limit, not a workflow mismatch. The - conclusion stands; the stated reason was wrong. — +- **Zenodo — NO LONGER RULED OUT (2026-07-27). Adapter built; spike PASSED all + five gates live 2026-08-11; see "Zenodo adapter" below.** The original + dismissal put Zenodo in the curate-once-publish-once + category, which was wrong on the facts (verified 2026-07-26): Zenodo drafts DO + accept incremental file writes over the API with no documented time limit on + how long a draft may stay unpublished, and published records stay editable for + 30 days plus DOI versioning after that. That left the per-record cap of + **100 files / 50 GB** (200 GB by one-time exception) as the only real blocker + — a size limit, not a workflow mismatch. That cap is now addressed by + end-of-study archive compaction rather than treated as disqualifying, so the + earlier conclusion is withdrawn along with its reasoning. +- **Figshare (as archival), Harvard Dataverse, ICPSR, Dryad, Databrary, + DANS** — the "research-data-specific repository" category is largely built + around a curate-once-publish-once-mint-a-DOI workflow, mismatched + to DataPipe's hundreds-of-small-incremental-writes-over-months pattern — confirmed across every Dataverse-software installation (Harvard, Borealis, DataverseNL, DataverseNO, DANS all share the same open-source codebase, same static-token-only auth, same silent-rename-on-duplicate-filename behavior). @@ -281,6 +283,116 @@ takes an optional `provider` and returns an `authorizeUrl`), so the original | DOI/publish | N/A | Publishing an Article snapshots it | Dataset publish bumps a major version — dataset should stay in **draft indefinitely**; publish (and DOI mint) becomes a manual researcher action at study completion, not something DataPipe triggers | | Gating spike | None (comfortable fit) — but OAuth app verification has **weeks of lead time**; start it at build step 0 | See "Gating spikes" below. Two of these are now DOCUMENTED rather than speculative (verified 2026-07-26): the **500-files-per-item cap** is a current stated limit, not a historical approximation; and Figshare's **1 request/second** guidance is in direct tension with requirement 6 (30–100 submissions/minute), since the mandatory multi-step upload makes each session file ~4 requests — a 30-student burst is ~120 requests/minute. Duplicate-filename behavior remains completely undocumented | See "Gating spikes" below — **dataset locking under concurrent adds** (most likely disqualifier in the plan), tabular-ingest suppression, silent-rename response shape | +### Zenodo adapter (added 2026-07-27) + +Implemented in `functions/src/providers/zenodo.ts`. Static-token auth, not +federated (allowlisted to `zenodo.org` / `sandbox.zenodo.org` in the adapter — +the researcher never types a server URL, and `lib/provider-config.js` supplies +the fixed one that `connectstatictokenprovider` still requires). + +**API generation.** Zenodo now runs on InvenioRDM and both API generations are +live — verified 2026-07-27: `GET /api/deposit/depositions` → 403 (exists, +needs auth), `GET /api/records` → 200. The adapter targets the **legacy deposit +API plus its bucket endpoint**, because (a) it is what Zenodo's own developer +documentation describes, and (b) a bucket `PUT` is **one** request per session +file where the InvenioRDM-native flow is three (init → content → commit). +Against Zenodo's documented 100 requests/minute for authenticated users that is +~100 vs ~33 sessions/minute of first-try throughput, and 100/minute is +requirement 6. The risk accepted: the legacy API is a compatibility layer that +could be retired, so every HTTP call goes through helpers rather than being +inlined, keeping a future migration contained. + +**Rate limits** are 100 req/min and 5000 req/hour authenticated (60/2000 +guest) — comfortably above the burst profile, unlike Figshare's 1 req/sec. + +**`updateFile` is a single overwriting PUT.** It was written as delete-then-PUT +while the overwrite semantics were undocumented; gate A settled it live and the +delete is gone. Zenodo is therefore the only non-OSF provider whose metadata +update is **atomic** — Dataverse and Figshare still carry the delete-then-write +window. + +**The 100-file cap is handled by end-of-study compaction, not rollover.** +Chosen because the goal is a clean *published* artifact, and record rollover +would split one experiment across several depositions — exactly the structure +being avoided. During collection, sessions are written as individual files +(researchers can preview and spot-check a single session; a growing archive +would make that require downloading everything). Compaction into batch zips +happens in a background job only as needed to stay under the cap. At +finalization a single merge produces one archive plus a loose +`dataset_description.json`, and only then are the parts deleted — upload, +verify the returned md5, *then* delete, never the reverse. + +Rejected alternative: rebuilding the archive on every submission (or every +batch). It turns each write into a read-modify-write on one object with no +provider-side arbitration — Zenodo offers no conditional write — so concurrent +submissions silently lose data rather than being rejected the way Dataverse's +lock rejects them. It also costs O(n²) transfer and rewrites already-collected +data repeatedly, which for a tool that retains no copy means every rewrite is +an unrecoverable-loss opportunity. Sealed archives are written once and never +touched again. + +**Finalization needs no server-side zip code**: published records expose +`links.archive` → `/api/records/{id}/files-archive`, and gate D confirmed the +**draft** equivalent (`/api/records/{id}/draft/files-archive`) returns a +`application/zip` on an unpublished deposition. Bulk download works throughout +collection, not only after publication. + +### Zenodo spike — RESULT: PASS (live, sandbox.zenodo.org, 2026-08-11) + +`scripts/zenodo-spike.mjs`, driving the real compiled adapter. **All five gates +pass.** Zenodo is the first provider to clear its spike outright — Dataverse +needed its verdict revised to CONDITIONAL PASS after failing gate A. + +| Gate | Result | +|---|---| +| A. bucket PUT overwrites an existing key | **PASS** — replaced in place, one listing entry | +| B. slashed keys | **PASS after redesign** — see below | +| C. 12 concurrent writes to one deposition | **PASS** — 12/12 accepted, 12/12 listed, 1.25 s | +| D. `files-archive` on an unpublished draft | **PASS** — 200 `application/zip` | +| E. behavior at the 101st file | **PASS after fix** — 400, now mapped `QUOTA_EXCEEDED` | + +**Gate C is the headline.** This is the gate Dataverse failed: Dataverse accepts +exactly one concurrent write per dataset and 400s the rest. Zenodo's bucket is +object storage and took all 12 without complaint, so the fast-retry contention +tier added for Dataverse (`ff5d805`) is not load-bearing here. + +**Gate B was answered "no", and the adapter changed rather than the verdict.** +Zenodo's keyspace is **flat**; a slash cannot be stored by any route: + +- bucket PUT with literal slashes → **404** (addresses a bucket path that does not exist) +- bucket PUT with the key `%2F`-encoded → **404** (the router decodes it back before matching) +- legacy multipart POST with `name="data/raw/probe.json"` → **201, stored as `data_raw_probe.json`** + +That third case is the dangerous one and is the reason this endpoint was not +chosen: a silent server-side rename, with a success status, against a collision +cache that matches names **exactly** — precisely the failure mode of Dataverse's +dropped `directoryLabel`. The adapter now flattens `[/\\]+` → `_` up front +(`toZenodoKey`) so the name it reports is the name Zenodo holds, and +`storedFilename` is still read back off the response rather than assumed. + +**Consequence: the Psych-DS directory layout cannot be represented in Zenodo +file keys.** This is not hypothetical — `metadataActive` experiments upload to +`data/raw/<name>` and `data/<base>_data.csv` (`metadata-derived-files.ts`), so +every metadata-enabled Zenodo experiment hits this path. Live keys will read +`data_raw_subject-1.json`. The real structure is preserved **inside the +compaction archive**, where the paths are ours to choose and Psych-DS validity +can be maintained. Open decision: whether flat live keys are acceptable given +the archive carries the true structure (see Open questions). + +**Two adapter bugs were found only because the spike drives the real adapter**, +not a hand-written request: + +1. `Content-Type: <real mimetype>` on the bucket PUT → hard **415**; the endpoint + accepts `application/octet-stream` and nothing else. Every write failed. +2. `encodeKey` encoded path segments separately, preserving literal slashes → 404. + +Both were mapped to `UNAVAILABLE`, i.e. the queue would have retried a +permanently broken request forever. Gate E surfaced a third of the same kind: +the cap message is *"Uploading selected files will result in exceeding the max +amount per record."* — `exceeding`, not `exceeds`, which the original pattern +missed, so a full record read as a transient outage. All three are fixed and +regression-tested (`providers-zenodo.test.js`, 45 tests). + ### OAuth generalization Replace today's 4–5 near-duplicated OSF-auth-URL-building blocks and single @@ -570,3 +682,13 @@ Google Drive provider is announced: - Do researchers need placement control for the Drive folder strongly enough to justify a Google Picker integration, or is the app-created root folder acceptable? (Default answer: root folder; revisit on demand.) +- **Zenodo's flat keyspace vs. Psych-DS (raised 2026-08-11, needs a decision).** + Zenodo cannot store a slash in a file key, so a `metadataActive` experiment's + live deposition shows `data_raw_subject-1.json` rather than + `data/raw/subject-1.json`, and is not a valid Psych-DS component while + collection is in progress. Three options: (a) accept it, and let the + compaction archive carry the real Psych-DS tree — cheapest, and the archive is + the artifact researchers actually cite; (b) suppress the derived Psych-DS + files on Zenodo and generate them only into the archive; (c) treat Zenodo as + unsupported for `metadataActive` experiments. (a) is the working assumption + and what the code does today. From 34e1f69d99fd18d07ff4b5bf30ae6726ff19d4b6 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 13:07:00 -0400 Subject: [PATCH 067/181] feat: warn at experiment setup about Zenodo's 100-file cap A Zenodo record holds at most 100 files and the compaction meant to keep a study under that cap is not built yet, so session 101 fails and stays failed: the refusal maps to QUOTA_EXCEEDED, which is slow-tier and needs human action to clear. No data is lost -- the submission stays in pending storage and QueuePanel surfaces the reason -- but the researcher cannot fix it themselves, so they need to hear about the limit before they start collecting rather than after. Unconditional and offline, unlike dataverse.ts's version probe: the cap is a property of Zenodo itself rather than of an installation, so there is nothing to interrogate and no failure mode to fail open from. Both this and its tests should be deleted when compaction ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/providers-zenodo.test.js | 13 ++++++++++ functions/src/providers/zenodo.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/functions/src/__tests__/providers-zenodo.test.js b/functions/src/__tests__/providers-zenodo.test.js index 969347b..6080ba9 100644 --- a/functions/src/__tests__/providers-zenodo.test.js +++ b/functions/src/__tests__/providers-zenodo.test.js @@ -489,6 +489,19 @@ describe("9. validateStaticToken", () => { }); }); +// The 100-file cap is real today because compaction is not built. These +// assertions are expected to be DELETED along with setupWarnings when it ships. +describe("9b. setupWarnings", () => { + it("warns about the 100-file cap without making a request", async () => { + const warnings = await zenodoProvider.setupWarnings(auth); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/100 files/); + // Unconditional and offline: the cap is a property of Zenodo, not of an + // installation, so unlike dataverse.ts there is nothing to probe. + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + describe("10. registry wiring", () => { it("declares the capability surface the framework reads", () => { expect(zenodoProvider.id).toBe("zenodo"); diff --git a/functions/src/providers/zenodo.ts b/functions/src/providers/zenodo.ts index fdc2418..4214b8b 100644 --- a/functions/src/providers/zenodo.ts +++ b/functions/src/providers/zenodo.ts @@ -528,4 +528,28 @@ export const zenodoProvider: StorageProvider = { const content = await response.text(); return { success: true, content }; }, + + // A Zenodo record holds at most 100 files, and the compaction that is meant + // to keep a study under that cap (batch zips during collection, one merged + // archive at finalization -- see docs/provider-migration-design.md) is NOT + // built yet. Until it is, session 101 fails and stays failed: the queue maps + // Zenodo's refusal to QUOTA_EXCEEDED, which is slow-tier and needs human + // action to clear. No data is lost -- the submission stays in pending + // storage and QueuePanel surfaces the reason -- but the researcher cannot + // fix it, so they need to hear about the limit BEFORE they start collecting + // rather than after. + // + // Unconditional and offline, unlike dataverse.ts's version probe: the cap is + // a property of Zenodo itself, not of an installation, so there is nothing + // to interrogate and no failure mode to fail open from. + // + // DELETE THIS once compaction ships. + async setupWarnings(_auth: ResolvedAuth): Promise<string[]> { + return [ + "Zenodo allows at most 100 files per deposition, and DataPipe does not yet " + + "combine sessions into archives. Plan for fewer than 100 submissions in this " + + "experiment: after that, further submissions will fail to upload and will have " + + "to be recovered by hand.", + ]; + }, }; From 4c442afde9ed1a3829e1fb90b91737bb9faf0f89 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 18:34:31 -0400 Subject: [PATCH 068/181] fix: apply the PR 158 review findings Ten findings from a high-effort review of this branch, plus two follow-ups they surfaced. Filename identity (4 findings). The collision cache claimed the raw leaf filename while listFiles reported provider-transformed names -- Zenodo flattens slashes, Drive keeps only the leaf -- so on a rehydrated cache no claim ever matched. Zenodo's writeSessionFile is an overwriting PUT and Dataverse silently renames duplicates, so neither has a NAME_CONFLICT to fall back on: the miss silently destroyed one participant's data on Zenodo and duplicated it on Dataverse. Adds StorageProvider.storedNameFor, the cache's identity function, and routes every claim/confirm through claimNameFor(provider, uploadFilename). Two adjacent bugs fell out: api-data queued the raw leaf on its collision-cache paths, which would have dropped a metadataActive submission at the container root, and the request path and the retry worker claimed in different namespaces, so a queued retry never re-entered its own pending claim. Retry queue (2 findings). handleRetryFailure tiered on the code the doc was QUEUED with and never wrote the current attempt's back, pinning an item to whatever failed first. It now tiers on the attempt that just failed and stores it; a failure that never reached the provider clears the field and drops to the slow tier. RATE_LIMITED leaves the fast tier -- five attempts inside ~31 minutes is far short of a provider's rate-limit window, after which the item is marked failed and its cached payload deleted a week later -- and a Retry-After is clamped to MAX_BACKOFF_MS rather than the tier cap, so the provider's stated delay is no longer shortened. Metadata (2 findings). performUpdate discarded the WriteResult, so a metadataFileRef never followed Dataverse's delete-and-re-add to its new file id; every later submission then 404'd and self-healed by creating another dataset_description.json, which Dataverse renames rather than rejects. CONTENTION joins NON_HEALABLE_CODES: on Dataverse a contended update means the re-add lost a race, so re-creating immediately is a third write into the same contended container. Dataverse adapter (2 findings). The participant-supplied filename went raw into a Content-Disposition header, where a quote closed the parameter and a CRLF ended the header block; it is escaped now, and the fixed multipart boundary is per-request and random so the raw submission cannot close the part either. Missing response ids are omitted rather than stringified into the truthy "undefined", and createDataContainer rejects a 2xx body carrying no id instead of returning undefined fields Firestore refuses to store. Queue panel copy. A permanently failed row still read "it is being retried automatically". That reassurance now shows only while retries are running, failures carrying no taxonomy code are explained rather than printed raw, and Zenodo's QUOTA_EXCEEDED names its 100-file cap instead of claiming the account is out of space. PROVIDER_TOKEN_EXPIRED no longer says "Dataverse" when zenodo.ts emits the same code. Adds 36 tests, including metadata-ref-refresh.test.js, which drives blockMetadata against a fake adapter -- the existing OSF mock returns existingFileRef unconditionally and structurally cannot express a ref change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/queue-panel.test.jsx | 121 +++++++++ components/dashboard/QueuePanel.js | 110 +++++++-- .../__tests__/metadata-ref-refresh.test.js | 224 +++++++++++++++++ .../src/__tests__/providers-dataverse.test.js | 143 +++++++++++ .../src/__tests__/providers-gdrive.test.js | 20 ++ .../src/__tests__/providers-zenodo.test.js | 34 +++ functions/src/__tests__/upload-queue.test.js | 232 ++++++++++++++++-- functions/src/api-base64.ts | 15 +- functions/src/api-data.ts | 31 ++- functions/src/api-messages.ts | 9 +- functions/src/metadata-block.ts | 35 ++- functions/src/providers/dataverse.ts | 119 +++++++-- functions/src/providers/gdrive.ts | 12 + functions/src/providers/index.ts | 10 + functions/src/providers/osf.ts | 16 ++ functions/src/providers/types.ts | 18 ++ functions/src/providers/zenodo.ts | 12 + functions/src/queue-upload.ts | 24 +- functions/src/scheduled-upload-retry.ts | 93 +++++-- 19 files changed, 1171 insertions(+), 107 deletions(-) create mode 100644 functions/src/__tests__/metadata-ref-refresh.test.js diff --git a/__tests__/queue-panel.test.jsx b/__tests__/queue-panel.test.jsx index 7322a50..4d717cf 100644 --- a/__tests__/queue-panel.test.jsx +++ b/__tests__/queue-panel.test.jsx @@ -143,6 +143,127 @@ describe("QueuePanel provider error taxonomy", () => { expect(screen.queryByText(/exceeds the size limit/i)).not.toBeInTheDocument(); }); + // The copy used to be chosen from the code alone, ignoring status, so a row + // that had exhausted every retry sat under a "Failed" badge still promising + // the upload "is being retried automatically" -- which reads as "no action + // needed" at the exact moment downloading the file by hand is the only + // thing that will save it. + it("withholds the still-retrying reassurance once an entry has permanently failed", () => { + const failedContention = [ + { + id: "f1", + filename: "sub-12_data.csv", + status: "failed", + providerErrorCode: "CONTENTION", + failureReason: "Upload sub-12_data.csv permanently failed after 5 retries", + createdAt: new Date(), + }, + ]; + + render( + <ChakraProvider value={system}> + <QueuePanel entries={failedContention} experimentId="exp1" /> + </ChakraProvider> + ); + + // The cause is still explained... + expect( + screen.getByText(/busy with another upload from this experiment/i) + ).toBeInTheDocument(); + // ...but nothing claims a retry is still coming. + expect(screen.queryByText(/retried automatically/i)).not.toBeInTheDocument(); + }); + + // Only a provider WriteResult produces a taxonomy code, so every failure + // that never reached the provider falls through to prose matching. These + // used to render the raw internal string in the researcher's Reason column. + describe("failures that carry no taxonomy code", () => { + function renderReason(failureReason) { + render( + <ChakraProvider value={system}> + <QueuePanel + entries={[{ id: "n1", filename: "sub-13_data.csv", status: "failed", failureReason, createdAt: new Date() }]} + experimentId="exp1" + /> + </ChakraProvider> + ); + } + + it.each([ + ["Token resolution failed: PROVIDER_NOT_CONNECTED", /could not authenticate with your storage provider/i], + ["Token resolution exception: socket hang up", /could not authenticate with your storage provider/i], + ["Owner user not found", /no longer exists/i], + ["Experiment not found", /no longer exists/i], + ["Collision cache rehydrating", /still checking this experiment's existing filenames/i], + ["Failed to read cached data: no such object", /could not read its own saved copy/i], + ])("explains %j instead of printing it verbatim", (failureReason, expected) => { + renderReason(failureReason); + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(screen.queryByText(failureReason)).not.toBeInTheDocument(); + }); + + // Ordering guard: the interpolated detail on a cache failure can itself + // contain "fetch failed", which the generic network matcher would + // otherwise claim first and report as a mere connection problem. + it("reports a rehydration failure as such even when its detail says 'fetch failed'", () => { + renderReason("Collision cache rehydration failed: Rehydration failed for experiment x: fetch failed"); + expect(screen.getByText(/could not read the existing files/i)).toBeInTheDocument(); + expect(screen.queryByText(/Could not connect to your storage provider/i)).not.toBeInTheDocument(); + }); + + it("still maps the pre-taxonomy 'OSF error <status>' shape", () => { + renderReason("OSF error 503: Service Unavailable"); + expect(screen.getByText(/temporarily unavailable/i)).toBeInTheDocument(); + }); + }); + + // One code, genuinely different provider behavior. Zenodo maps its + // 100-files-per-record cap to QUOTA_EXCEEDED, where the generic "out of + // space, or this file is larger than it allows" is wrong in both halves. + describe("provider-specific overrides", () => { + function renderQuotaEntry(storageProvider) { + render( + <ChakraProvider value={system}> + <QueuePanel + entries={[ + { + id: "q1", + filename: "sub-101_data.csv", + status: "failed", + providerErrorCode: "QUOTA_EXCEEDED", + storageProvider, + failureReason: "Provider error 400: Uploading selected files will result in exceeding the max amount per record.", + createdAt: new Date(), + }, + ]} + experimentId="exp1" + /> + </ChakraProvider> + ); + } + + it("names Zenodo's file cap rather than claiming the account is out of space", () => { + renderQuotaEntry("zenodo"); + expect(screen.getByText(/100 files, or 50 GB/i)).toBeInTheDocument(); + expect(screen.queryByText(/out of space/i)).not.toBeInTheDocument(); + }); + + it("keeps the generic copy for a provider with no override", () => { + renderQuotaEntry("dataverse"); + expect( + screen.getByText(/out of space, or this file is larger than it allows/i) + ).toBeInTheDocument(); + }); + + // Legacy OSF queue docs carry no storageProvider field at all. + it("keeps the generic copy when the entry has no storageProvider", () => { + renderQuotaEntry(undefined); + expect( + screen.getByText(/out of space, or this file is larger than it allows/i) + ).toBeInTheDocument(); + }); + }); + it("the taxonomy code wins over the status embedded in failureReason", () => { // c1's failureReason carries a 400 that the legacy path would have shown // verbatim; the code is what decides the copy now. diff --git a/components/dashboard/QueuePanel.js b/components/dashboard/QueuePanel.js index 498ba80..a16f453 100644 --- a/components/dashboard/QueuePanel.js +++ b/components/dashboard/QueuePanel.js @@ -23,10 +23,9 @@ import { auth } from "../../lib/firebase"; const PROVIDER_ERROR_COPY = { // Contention is routine and self-resolving: some providers (Dataverse) // accept only one write per container at a time, so simultaneous - // submissions collide. The retry lands within a couple of minutes, so this - // copy is deliberately reassuring rather than alarming. + // submissions collide. CONTENTION: - "Your storage provider was busy with another upload from this experiment. This is normal when several participants finish at once, and it is being retried automatically.", + "Your storage provider was busy with another upload from this experiment. This is normal when several participants finish at once.", RATE_LIMITED: "Your storage provider rate-limited the request.", AUTH_EXPIRED: "Authentication error. Your storage provider connection may need to be refreshed.", @@ -37,17 +36,81 @@ const PROVIDER_ERROR_COPY = { UNAVAILABLE: "Your storage provider was temporarily unavailable.", }; -// Fallback for queue docs written before providerErrorCode was stored on them, -// and for failures that never reached the provider at all (interrupted -// uploads, collision-cache and metadata problems), which carry no taxonomy -// code. -function legacyFriendlyReason(reason) { +// Overrides for the cases where one taxonomy code covers genuinely different +// provider behavior and the generic wording above would send the researcher +// looking in the wrong place. Keyed [code][storageProvider]; anything absent +// falls back to the generic copy, which stays the default rather than the +// exception. `storageProvider` is undefined on legacy OSF queue docs, which +// simply misses here and falls back. +// +// Deliberately small. A per-provider string for every code would be six +// entries times five adapters of copy to keep true, and most of it would just +// restate the generic line -- the whole point of the taxonomy is that the +// researcher's next action is usually the same whoever is storing the data. +const PROVIDER_SPECIFIC_COPY = { + QUOTA_EXCEEDED: { + // Zenodo maps BOTH of its hard caps to QUOTA_EXCEEDED: the 50 GB + // per-file/per-record size limits, and the 100-files-per-record cap. The + // generic "out of space, or this file is larger than it allows" is + // actively wrong for the second one -- the record has room and the file is + // fine, it just cannot hold another entry -- and that is the EXPECTED + // failure at session 101, not an edge case, since the compaction that + // would keep a study under the cap is not built yet (see zenodo.ts's + // setupWarnings, which warns about this before collection starts). + zenodo: + "This Zenodo record has reached one of its limits: 100 files, or 50 GB. DataPipe does not yet combine sessions into archives, so further submissions will keep failing. Download these files and add them to the record yourself.", + }, +}; + +// Copy for failures that carry NO taxonomy code, matched against the prose in +// failureReason. Two populations land here: queue docs written before +// providerErrorCode existed, and — the larger group — every failure that never +// reached the provider at all, since only a provider WriteResult produces a +// code. Those are written in six places across api-data.ts, api-base64.ts and +// scheduled-upload-retry.ts; each distinct prefix they emit has an entry here. +// +// ORDER MATTERS. The interpolated `detail` on a cache or cached-data failure +// can itself contain "fetch failed", so the specific prefixes must be tested +// before the generic network match below, or a rehydration failure would be +// reported as a connection problem. +// +// Matching on prose is the same fragility PROVIDER_ERROR_COPY was introduced +// to escape, and it stays fragile: change a string on the writing side and the +// copy here silently reverts to showing that raw string. The durable fix is a +// structured stage field on the queue doc alongside providerErrorCode; this is +// deliberately the cheaper version, and it is also the only thing that can +// work for docs already in Firestore. +const REASON_COPY = [ + [ + /Token resolution (failed|exception)/, + "DataPipe could not authenticate with your storage provider. Reconnect it from your account page, then upload this file manually.", + ], + [ + /Collision cache rehydration failed/, + "DataPipe could not read the existing files in your storage provider, so it could not safely check whether this filename was already used.", + ], + [ + /Collision cache rehydrating/, + "DataPipe was still checking this experiment's existing filenames when this submission arrived.", + ], + [ + /(Owner user not found|Experiment not found)/, + "The experiment or account this upload belonged to no longer exists. Download the file now if you still need it.", + ], + [ + // The saved copy is what the download button serves, so if it cannot be + // read the researcher must not be told to just download it. + /Failed to read cached data/, + "DataPipe could not read its own saved copy of this submission, so it cannot be uploaded or downloaded. Please report this.", + ], + [/(interrupted upload|memory limit)/, "Upload was interrupted by a server restart or memory limit."], + [/(Upload exception|fetch failed)/, "Could not connect to your storage provider."], +]; + +function reasonCopy(reason) { if (!reason) return null; - if (reason.includes("interrupted upload") || reason.includes("memory limit")) { - return "Upload was interrupted by a server restart or memory limit."; - } - if (reason.includes("Upload exception") || reason.includes("fetch failed")) { - return "Could not connect to your storage provider."; + for (const [pattern, copy] of REASON_COPY) { + if (pattern.test(reason)) return copy; } // Older queue docs say "OSF error <status>"; current writes say // "Provider error <status>". Both must keep mapping. @@ -64,10 +127,25 @@ function legacyFriendlyReason(reason) { return reason; } +// Reassurance that is only true while retries are still running. Appended to +// the copy above for pending/processing entries and withheld once an entry has +// exhausted its retries -- a failed row used to sit under a "Failed" badge +// still telling the researcher the upload "is being retried automatically", +// which reads as "no action needed" at the exact moment manual recovery is the +// only thing that will save the file. +const STILL_RETRYING_SUFFIX = { + CONTENTION: " It is being retried automatically.", +}; + function friendlyReason(entry) { - const copy = PROVIDER_ERROR_COPY[entry?.providerErrorCode]; - if (copy) return copy; - return legacyFriendlyReason(entry?.failureReason); + const code = entry?.providerErrorCode; + const copy = + PROVIDER_SPECIFIC_COPY[code]?.[entry?.storageProvider] ?? PROVIDER_ERROR_COPY[code]; + if (copy) { + const stillRetrying = entry?.status === "pending" || entry?.status === "processing"; + return stillRetrying ? `${copy}${STILL_RETRYING_SUFFIX[code] ?? ""}` : copy; + } + return reasonCopy(entry?.failureReason); } function statusBadge(status) { diff --git a/functions/src/__tests__/metadata-ref-refresh.test.js b/functions/src/__tests__/metadata-ref-refresh.test.js new file mode 100644 index 0000000..07ff8fe --- /dev/null +++ b/functions/src/__tests__/metadata-ref-refresh.test.js @@ -0,0 +1,224 @@ +/** + * @jest-environment node + */ + +// metadata-block.ts's performUpdate used to DISCARD the WriteResult its +// provider.updateFile call returned, so the metadataFileRef stored on the +// metadata doc was written once and never refreshed. That is invisible on OSF +// and Drive, whose updateFile edits in place and echoes the same ref back -- +// but Dataverse's is DELETE + re-add, and the re-added file gets a brand-new +// id. Firestore was left pointing at a file that had just been deleted, so +// every later submission 404'd on update and self-healed by creating another +// dataset_description.json, which Dataverse silently renames rather than +// rejecting: one orphaned dataset_description-N.json per submission, with the +// canonical file never updated again. +// +// The only mock-provider harness in this suite (metadata-ref-emulator.test.js) +// speaks OSF, whose adapter returns `existingFileRef` unconditionally and so +// cannot express a ref change at all. This drives blockMetadata directly +// instead, against a fake adapter registered under the one StorageProviderId +// with no real adapter behind it ("figshare"), which lets updateFile return +// whatever ref the case under test needs. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +// Every adapter imports node-fetch at module scope, and it is ESM-only. +// Nothing here makes a provider HTTP call -- the fake adapter below stands in +// for all of them -- so a bare stub is enough. Same convention as +// providers-*.test.js. +jest.mock("node-fetch", () => ({ __esModule: true, default: jest.fn() })); + +jest.setTimeout(30000); + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +// updateMetadata (real, unmocked) reads `variableMeasured` and throws +// "Invalid metadata format" on anything else, so seeded metadata must have +// this shape or these tests would fail for an unrelated reason. +const existingMetadata = { variableMeasured: [{ name: "existing_var" }] }; + +let db; +let blockMetadata; +let fake; + +// Mutable per test: what the fake adapter's updateFile hands back. +let updateResult; +const updateCalls = []; + +beforeAll(async () => { + const app = initializeApp({ projectId: "datapipe-test" }, "metadata-ref-refresh-test"); + db = getFirestore(app); + + // Dynamic imports, deferred until after the process.env assignments above. + // These are the COMPILED modules (functions/lib/), so `npm run build` must + // run first -- same seam as upload-queue.test.js. + const { registerProvider } = await import("../../lib/providers/index.js"); + ({ default: blockMetadata } = await import("../../lib/metadata-block.js")); + + fake = { + id: "figshare", + authMethod: "static-token", + capabilities: { nativeSubfolders: false, supportsRegion: false, maxFileSizeBytes: null, quotaNote: null }, + containerInput: [], + async resolveToken() { + return { success: true, token: "t" }; + }, + async createDataContainer() { + throw new Error("not used"); + }, + async writeSessionFile(_auth, _container, filename) { + return { success: true, fileRef: { name: filename, id: `created-${randomUUID()}` }, storedFilename: filename }; + }, + async updateFile(_auth, _container, existingFileRef) { + updateCalls.push(existingFileRef); + return updateResult; + }, + async listFiles() { + return []; + }, + async downloadFile() { + return { success: true, content: "{}" }; + }, + }; + registerProvider(fake); +}); + +beforeEach(() => { + updateCalls.length = 0; +}); + +// blockMetadata reads and writes through app.js's default Firestore instance, +// so the doc ref handed to it has to come from that same instance. Reading it +// back through this suite's own named app is fine -- both point at the same +// emulator. +async function seedAndRun({ storedRef }) { + const experimentID = `metadata-ref-refresh-${randomUUID()}`; + const { db: prodDb } = await import("../../lib/app.js"); + const metadataDocRef = prodDb.collection("metadata").doc(experimentID); + + await metadataDocRef.set({ metadata: existingMetadata, metadataFileRef: storedRef }); + + const expData = { + active: true, + metadataActive: true, + owner: "metadata-ref-refresh-owner", + storageProvider: "figshare", + providerContainer: { provider: "figshare", articleId: 1 }, + }; + + const result = await blockMetadata( + expData, + { token: "t" }, + metadataDocRef, + sampleData, + "session-1.json", + {} + ); + + const after = (await db.collection("metadata").doc(experimentID).get()).data(); + return { result, after, experimentID }; +} + +describe("performUpdate persists a ref the provider replaced", () => { + it("stores the NEW file id when updateFile returns a different one (Dataverse's delete + re-add)", async () => { + updateResult = { + success: true, + fileRef: { name: "dataset_description.json", id: "55" }, + storedFilename: "dataset_description.json", + }; + + const { result, after } = await seedAndRun({ + storedRef: { id: "42", name: "dataset_description.json" }, + }); + + expect(result.success).toBe(true); + // The update was attempted against the ref that was stored... + expect(updateCalls).toEqual([{ id: "42", name: "dataset_description.json" }]); + // ...and the doc now points at the file that actually exists. + expect(after.metadataFileRef).toEqual({ name: "dataset_description.json", id: "55" }); + }); + + it("leaves the stored ref alone when updateFile echoes it back unchanged (OSF/Drive)", async () => { + const sameRef = { id: "42", name: "dataset_description.json" }; + updateResult = { success: true, fileRef: sameRef, storedFilename: "dataset_description.json" }; + + const { result, after } = await seedAndRun({ storedRef: sameRef }); + + expect(result.success).toBe(true); + expect(after.metadataFileRef).toEqual(sameRef); + }); + + // Same guard as createMetadataFile's: a ref with no usable id is something + // no future update could address, so it must not overwrite a good one. + it("does not overwrite the stored ref with one that carries no id", async () => { + updateResult = { + success: true, + fileRef: { name: "dataset_description.json" }, + storedFilename: "dataset_description.json", + }; + + const { after } = await seedAndRun({ storedRef: { id: "42", name: "dataset_description.json" } }); + + expect(after.metadataFileRef).toEqual({ id: "42", name: "dataset_description.json" }); + }); +}); + +describe("CONTENTION is not self-healed", () => { + // Dataverse is both the provider that emits CONTENTION and the one whose + // updateFile is delete-then-re-add, so a collision means the re-add lost a + // race. Re-creating the file immediately is a THIRD write into the same + // still-contended container, which loses the same race and takes the + // submission down with it. Failing straight out leaves the stale ref for a + // later submission to self-heal from, once the container is quiet. + it("fails the block instead of immediately re-creating the metadata file", async () => { + updateResult = { + success: false, + error: "CONTENTION", + providerStatus: 400, + providerMessage: "Failed to add file to dataset.", + }; + + const writeSpy = jest.spyOn(fake, "writeSessionFile"); + + const { result } = await seedAndRun({ storedRef: { id: "42", name: "dataset_description.json" } }); + + expect(result.success).toBe(false); + // No re-create attempt -- the self-heal branch was not taken. + expect(writeSpy).not.toHaveBeenCalled(); + + writeSpy.mockRestore(); + }); + + it("still self-heals a stale ref, which is what the branch is for", async () => { + updateResult = { + success: false, + error: "UNAVAILABLE", + providerStatus: 404, + providerMessage: "File not found", + }; + + const writeSpy = jest.spyOn(fake, "writeSessionFile"); + + const { result } = await seedAndRun({ storedRef: { id: "gone", name: "dataset_description.json" } }); + + expect(result.success).toBe(true); + expect(writeSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "dataset_description.json", + expect.any(String), + expect.anything() + ); + + writeSpy.mockRestore(); + }); +}); diff --git a/functions/src/__tests__/providers-dataverse.test.js b/functions/src/__tests__/providers-dataverse.test.js index 9819530..9d0ac06 100644 --- a/functions/src/__tests__/providers-dataverse.test.js +++ b/functions/src/__tests__/providers-dataverse.test.js @@ -243,6 +243,127 @@ describe("2. writeSessionFile", () => { const body = callArgs(0).options.body.toString(); expect(body).not.toContain("directoryLabel"); }); + + // The filename arrives from the participant's POST body with no character + // validation. Interpolated raw, a quote closed the Content-Disposition + // parameter and a CRLF ended the header block, which was enough to forge a + // second jsonData part and pick the directoryLabel the file landed in. + it("escapes quotes and strips CRLF from the filename in Content-Disposition", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { files: [{ label: "evil.json", dataFile: { id: 9 } }] } }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + // No "/" anywhere: writeSessionFile would split a slashed name into + // directoryLabel + label, and the point here is the leaf that actually + // reaches the Content-Disposition header. + const hostile = + 'evil";\r\n\r\n--x\r\nContent-Disposition: form-data; name="jsonData"\r\n\r\n{"directoryLabel":"pwned"}\r\n.json'; + + await dataverseProvider.writeSessionFile(auth, container, hostile, "x", { + size: 1, + contentType: "application/json", + }); + + const body = callArgs(0).options.body.toString(); + // Exactly one jsonData part survives -- the one this adapter built. The + // forged one is inert: its quotes are escaped, so it is header TEXT. + expect(body.match(/name="jsonData"/g)).toHaveLength(1); + expect(body).not.toContain('"directoryLabel":"pwned"'); + // The quote is escaped rather than closing the parameter, and the CRLFs + // that would have ended the header block are gone. + expect(body).toContain('filename="evil\\";'); + expect(body.split("Content-Type: application/json")).toHaveLength(2); + }); + + it("uses an unguessable per-request boundary that matches the Content-Type header", async () => { + const respond = () => + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { files: [{ label: "a.json", dataFile: { id: 1 } }] } }, + }); + mockFetch.mockResolvedValueOnce(respond()).mockResolvedValueOnce(respond()); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const meta = { size: 1, contentType: "application/json" }; + await dataverseProvider.writeSessionFile(auth, container, "a.json", "x", meta); + await dataverseProvider.writeSessionFile(auth, container, "a.json", "x", meta); + + const boundaryOf = (i) => { + const { options } = callArgs(i); + const boundary = header(options.headers, "Content-Type").replace( + /^multipart\/form-data; boundary=/, + "" + ); + // The header's boundary is the one the body was actually built with. + expect(options.body.toString()).toContain(`--${boundary}\r\n`); + expect(options.body.toString().endsWith(`--${boundary}--`)).toBe(true); + return boundary; + }; + + // Unguessable: not a fixed constant a participant could embed in their + // own submission to close the file part early. + expect(boundaryOf(0)).not.toBe(boundaryOf(1)); + }); + + // String(undefined) yields the truthy string "undefined", which sails + // through metadata-block.ts's `if (response.fileRef.id)` guard and gets + // persisted as a ref addressing /api/files/undefined. + it("omits fileRef.id rather than stringifying it when the response carries no dataFile id", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { status: "OK", data: { files: [{ label: "data.json" }] } }, + }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.writeSessionFile(auth, container, "data.json", "x", { + size: 1, + contentType: "application/json", + }); + + expect(result.success).toBe(true); + expect(result.fileRef).toEqual({ name: "data.json" }); + expect(result.fileRef.id).toBeUndefined(); + }); + + it("falls back to the requested name when the response carries no label", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { status: "OK", data: {} } }) + ); + + const container = { provider: "dataverse", datasetId: 7, persistentId: "doi:10/abc", serverUrl: SERVER_URL }; + const result = await dataverseProvider.writeSessionFile(auth, container, "data/raw/x.json", "x", { + size: 1, + contentType: "application/json", + }); + + // The write succeeded; an unreadable body only costs us rename detection. + expect(result).toEqual({ + success: true, + fileRef: { name: "x.json" }, + storedFilename: "x.json", + }); + }); +}); + +// The collision cache hashes a name before the write and rehydrates a cold +// cache from listFiles, so the two must share a namespace. Dataverse splits a +// path into directoryLabel + label on write and listFiles re-joins them, so +// the requested path round-trips and this is identity -- unlike Zenodo, which +// flattens, and Drive, which keeps only the leaf. +describe("2b. storedNameFor (collision-cache namespace)", () => { + it("is identity, matching what listFiles re-joins", () => { + expect(dataverseProvider.storedNameFor("data/raw/abc123.json")).toBe("data/raw/abc123.json"); + expect(dataverseProvider.storedNameFor("flat.json")).toBe("flat.json"); + }); }); describe("3. error mapping", () => { @@ -803,6 +924,28 @@ describe("7. createDataContainer", () => { }) ).rejects.toThrow(/dataset creation failed/i); }); + + // Casting the optionality away instead returned a ContainerRef with + // undefined fields; create-experiment.ts handed that to Firestore, which + // rejects undefined values, so the batch commit threw and the researcher + // got a generic 500 with an orphaned draft dataset already created. + it.each([ + ["no id", { status: "OK", data: { persistentId: "doi:10/abc" } }], + ["no persistentId", { status: "OK", data: { id: 456 } }], + ["no data at all", { status: "OK" }], + ])("throws on a 2xx whose body has %s, rather than returning undefined fields", async (_label, jsonBody) => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, statusText: "Created", jsonBody })); + + await expect( + dataverseProvider.createDataContainer(auth, { + collectionAlias: "my-collection", + title: "My Study", + authorName: "Ada Lovelace", + contactEmail: "ada@example.com", + description: "A study about things.", + }) + ).rejects.toThrow(/no id or persistent id/i); + }); }); describe("8. federated serverUrl resolution", () => { diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js index 30b8692..122a937 100644 --- a/functions/src/__tests__/providers-gdrive.test.js +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -667,3 +667,23 @@ describe("7. downloadFile", () => { expect(result).toEqual({ success: true, content: "osf file content" }); }); }); + +// The collision cache hashes a name before the write and rehydrates a cold +// cache from listFiles, so the two must share a namespace. Drive stores a +// path prefix as real nested FOLDERS and the file under its bare leaf name, +// and listFiles collects every file it finds under that leaf regardless of +// which folder it came from -- so the leaf is what the cache must hash. +describe("storedNameFor (collision-cache namespace)", () => { + it("keeps only the leaf, matching what listFiles reports", () => { + expect(gdriveProvider.storedNameFor("data/raw/abc123.json")).toBe("abc123.json"); + expect(gdriveProvider.storedNameFor("flat.json")).toBe("flat.json"); + }); + + // OSF is the opposite: it keeps the path as real nested folders AND answers + // a duplicate write with 409, so distinct paths stay distinct claims and + // collapsing them would falsely reject a second submission to a different + // subfolder. + it("is NOT what osf does -- osf keeps the whole path", () => { + expect(osfProvider.storedNameFor("data/raw/abc123.json")).toBe("data/raw/abc123.json"); + }); +}); diff --git a/functions/src/__tests__/providers-zenodo.test.js b/functions/src/__tests__/providers-zenodo.test.js index 6080ba9..632b0a1 100644 --- a/functions/src/__tests__/providers-zenodo.test.js +++ b/functions/src/__tests__/providers-zenodo.test.js @@ -456,6 +456,40 @@ describe("7. listFiles", () => { }); }); +// The collision cache hashes a name before the write and rehydrates a cold +// cache from listFiles, so the two must share a namespace. Zenodo's keyspace +// is flat: "data/raw/x.json" is stored, and listed, as "data_raw_x.json". +// Claiming the un-flattened name meant no rehydrated claim ever matched -- +// and writeSessionFile is an OVERWRITING PUT with no NAME_CONFLICT backstop, +// so the duplicate that slipped through destroyed the earlier session's data +// silently. +describe("7b. storedNameFor (collision-cache namespace)", () => { + it("flattens slashes exactly the way the stored key does", () => { + expect(zenodoProvider.storedNameFor("data/raw/abc123.json")).toBe("data_raw_abc123.json"); + expect(zenodoProvider.storedNameFor("flat.json")).toBe("flat.json"); + }); + + it("agrees with the key writeSessionFile PUTs to and reports back", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 201, jsonBody: { key: "data_raw_abc123.json", size: 1 } }) + ); + + const result = await zenodoProvider.writeSessionFile(auth, container, "data/raw/abc123.json", "x", { + size: 1, + contentType: "application/json", + }); + + const claimName = zenodoProvider.storedNameFor("data/raw/abc123.json"); + expect(result.storedFilename).toBe(claimName); + expect(callArgs(0).url).toBe(`${BUCKET_URL}/${encodeURIComponent(claimName)}`); + }); + + it("is idempotent, so a name read back from Zenodo maps to itself", () => { + const once = zenodoProvider.storedNameFor("data/raw/abc123.json"); + expect(zenodoProvider.storedNameFor(once)).toBe(once); + }); +}); + describe("8. downloadFile", () => { it("GETs the key from the bucket and returns its text", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, textBody: '{"a":1}' })); diff --git a/functions/src/__tests__/upload-queue.test.js b/functions/src/__tests__/upload-queue.test.js index 6f9a932..df679db 100644 --- a/functions/src/__tests__/upload-queue.test.js +++ b/functions/src/__tests__/upload-queue.test.js @@ -26,9 +26,15 @@ process.env.FIREBASE_CONFIG = JSON.stringify({ // suite never makes a provider HTTP call (its queue items fail at token // resolution, before any network use), so a bare stub is enough -- same // convention as providers-*.test.js. +// Configurable rather than a bare stub: the "exercised through the real retry +// worker" block below needs the worker's provider write to come back with a +// specific mapped error code, which is the whole point of the tier it then +// picks. Every other test in this suite makes no provider call at all. +const mockFetch = jest.fn(); + jest.mock("node-fetch", () => ({ __esModule: true, - default: jest.fn(), + default: (...args) => mockFetch(...args), })); const config = { projectId: "datapipe-test" }; @@ -38,6 +44,7 @@ jest.setTimeout(30000); let db; let app; let queueUpload; +let isFastRetry; beforeAll(async () => { try { @@ -54,7 +61,7 @@ beforeAll(async () => { // first), and its app.js does a bare, unnamed initializeApp() -- distinct // from this suite's own NAMED "upload-queue-test" app above, so the two // don't collide. - ({ default: queueUpload } = await import("../../lib/queue-upload.js")); + ({ default: queueUpload, isFastRetry } = await import("../../lib/queue-upload.js")); }); // Only the docs THIS suite created. A collection-wide wipe here used to @@ -195,7 +202,7 @@ describe("scheduled-upload-retry tiered backoff arithmetic", () => { const FAST_MAX_BACKOFF_MS = 30 * 60 * 1000; // 30 minutes const SLOW_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24 hours, unchanged - test("fast tier (CONTENTION/RATE_LIMITED) produces ~2, 4, 8, 16, 30 minutes", () => { + test("fast tier (CONTENTION) produces ~2, 4, 8, 16, 30 minutes", () => { const expectedMinutes = [2, 4, 8, 16, 30]; for (let retryCount = 1; retryCount <= 5; retryCount++) { const backoffMs = Math.min(Math.pow(2, retryCount) * 60 * 1000, FAST_MAX_BACKOFF_MS); @@ -203,6 +210,26 @@ describe("scheduled-upload-retry tiered backoff arithmetic", () => { } }); + // CONTENTION is the only member. RATE_LIMITED looks like it belongs but is + // deliberately excluded: five fast-tier attempts are spent inside ~31 + // minutes, far short of a provider's rate-limit window, after which the + // item is marked permanently failed and its cached payload is deleted a + // week later. See FAST_RETRY_CODES in queue-upload.ts. + test("only CONTENTION is on the fast tier", () => { + expect(isFastRetry("CONTENTION")).toBe(true); + for (const code of ["RATE_LIMITED", "AUTH_EXPIRED", "QUOTA_EXCEEDED", "UNAVAILABLE", "NAME_CONFLICT"]) { + expect(isFastRetry(code)).toBe(false); + } + }); + + // handleRetryFailure clears providerErrorCode to null for a failure that + // never reached the provider, so null must read as slow-tier rather than + // throwing or being treated as a code. + test("a missing or cleared providerErrorCode is slow tier", () => { + expect(isFastRetry(undefined)).toBe(false); + expect(isFastRetry(null)).toBe(false); + }); + test("slow tier (everything else) is unchanged: ~2, 4, 8, 16, 24 hours", () => { const expectedHours = [2, 4, 8, 16, 24]; for (let retryCount = 1; retryCount <= 5; retryCount++) { @@ -211,12 +238,15 @@ describe("scheduled-upload-retry tiered backoff arithmetic", () => { } }); - test("a Retry-After header is clamped to the fast tier's shorter cap, not the slow tier's", () => { + // A Retry-After is the provider stating how long it will keep refusing, so + // it is clamped to MAX_BACKOFF_MS and NEVER to the item's tier cap. + // Clamping a fast-tier item's `Retry-After: 3600` down to 30 minutes + // scheduled a retry the provider had already said would fail. + test("a Retry-After header is clamped to the absolute cap, not the item's tier cap", () => { const retryAfterSeconds = 3600; // 1 hour — larger than the fast cap, smaller than the slow cap - const fastBackoffMs = Math.min(retryAfterSeconds * 1000, FAST_MAX_BACKOFF_MS); - const slowBackoffMs = Math.min(retryAfterSeconds * 1000, SLOW_MAX_BACKOFF_MS); - expect(fastBackoffMs).toBe(FAST_MAX_BACKOFF_MS); - expect(slowBackoffMs).toBe(retryAfterSeconds * 1000); + const backoffMs = Math.min(retryAfterSeconds * 1000, SLOW_MAX_BACKOFF_MS); + expect(backoffMs).toBe(retryAfterSeconds * 1000); + expect(backoffMs).toBeGreaterThan(FAST_MAX_BACKOFF_MS); }); }); @@ -279,6 +309,36 @@ describe("queueUpload tiers the first nextRetryAt by providerErrorCode", () => { expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); }); + // Regression guard: RATE_LIMITED was briefly fast-tiered, which cut the + // whole retry budget for an OSF/Drive 429 from ~31 hours to ~2. + test("a RATE_LIMITED providerErrorCode sets nextRetryAt ~1 hour out (slow tier)", async () => { + const experimentID = `queue-slow-tier-ratelimited-${randomUUID()}`; + const filename = `file-${randomUUID()}.json`; + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + queueDoc(docId); + + const before = Date.now(); + await queueUpload({ + experimentID, + owner: "upload-queue-test-owner", + filename, + data: "[]", + dataType: "data", + osfFilesLink: "https://osf.io/files/", + errorCode: 429, + providerErrorCode: "RATE_LIMITED", + sessionIncremented: true, + }); + + const doc = await db.collection("uploadQueue").doc(docId).get(); + expect(doc.exists).toBe(true); + expect(doc.data().providerErrorCode).toBe("RATE_LIMITED"); + + const deltaMs = doc.data().nextRetryAt.toMillis() - before; + expect(deltaMs).toBeGreaterThan(55 * 60 * 1000); + expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); + }); + test("an UNAVAILABLE providerErrorCode also sets nextRetryAt ~1 hour out (slow tier)", async () => { const experimentID = `queue-slow-tier-unavailable-${randomUUID()}`; const filename = `file-${randomUUID()}.json`; @@ -387,10 +447,14 @@ describe("queue entry lifecycle in Firestore", () => { // The tiered backoff that MATTERS lives inside scheduled-upload-retry.ts's // handleRetryFailure, which is not exported. The arithmetic blocks above // replicate its formula and so cannot catch a bug in the real function (a -// mis-read field name, an inverted tier test). These drive the REAL worker -// instead, via a queue item whose token resolution fails -- a dataverse -// experiment whose owner has no dataverse connection -- which routes straight -// to handleRetryFailure without any network call. +// mis-read field name, an inverted tier test). These drive the REAL worker. +// +// The tier comes from the code of the attempt that JUST failed, not the one +// the doc was queued with, so seedDueItem takes both: what is already stored, +// and what this attempt will fail with. `attemptOutcome: "token-failure"` +// gives the owner no dataverse connection, so resolveToken fails and the +// worker never reaches the provider; otherwise the owner is connected and the +// mocked fetch returns a response this adapter maps to the requested code. // // retryPendingUploads is scoped to this suite's own owner id. Unscoped it // sweeps and mutates every pending uploadQueue doc in the shared emulator, @@ -398,19 +462,51 @@ describe("queue entry lifecycle in Firestore", () => { // worker; re-introducing it here would make other suites flaky again. describe("tiered backoff, exercised through the real retry worker", () => { let retryPendingUploads; + let bucket; beforeAll(async () => { ({ retryPendingUploads } = await import("../../lib/scheduled-upload-retry.js")); + const { getStorage } = await import("firebase-admin/storage"); + bucket = getStorage(app).bucket("datapipe-test.appspot.com"); }); - async function seedDueItem(providerErrorCode) { + // Dataverse's one-write-per-dataset rejection: a generic 400 whose message + // mapDataverseError turns into CONTENTION (see providers-dataverse.test.js). + const DATAVERSE_CONTENTION = { + status: 400, + statusText: "Bad Request", + json: () => Promise.resolve({ status: "ERROR", message: "Failed to add file to dataset." }), + }; + const DATAVERSE_UNAVAILABLE = { + status: 503, + statusText: "Service Unavailable", + json: () => Promise.resolve({ status: "ERROR", message: "Installation down for maintenance" }), + }; + + async function seedDueItem({ storedCode, attemptOutcome }) { const owner = `retry-tier-owner-${randomUUID()}`; const experimentID = `retry-tier-exp-${randomUUID()}`; const docId = `${experimentID}:data.json`.replace(/[/\\]/g, "_"); - - // Owner exists but has NO connectedAccounts.dataverse -> resolveToken - // returns PROVIDER_NOT_CONNECTED -> handleRetryFailure. - await db.collection("users").doc(owner).set({ email: `${owner}@example.test` }); + const storagePath = `upload-queue/${docId}`; + + await db.collection("users").doc(owner).set({ + email: `${owner}@example.test`, + // Omitted for "token-failure": resolveToken then returns + // PROVIDER_NOT_CONNECTED and routes straight to handleRetryFailure with + // no provider code at all. decrypt() passes a non-"v1:" value through + // unchanged, so a plaintext token needs no encryption key here. + ...(attemptOutcome === "token-failure" + ? {} + : { + connectedAccounts: { + dataverse: { + authMethod: "static-token", + encryptedToken: "plaintext-token", + serverUrl: "https://example.test", + }, + }, + }), + }); await db.collection("experiments").doc(experimentID).set({ active: true, owner, @@ -418,11 +514,17 @@ describe("tiered backoff, exercised through the real retry worker", () => { providerContainer: { provider: "dataverse", datasetId: 1, persistentId: "doi:x/y", serverUrl: "https://example.test" }, }); + // The worker downloads the cached payload before it attempts the write — + // without it the item short-circuits to "Failed to read cached data". + if (attemptOutcome !== "token-failure") { + await bucket.file(storagePath).save("[]", { contentType: "text/plain" }); + } + const doc = { experimentID, owner, filename: "data.json", - storagePath: `upload-queue/${docId}`, + storagePath, dataType: "data", status: "pending", errorCode: 400, @@ -436,21 +538,31 @@ describe("tiered backoff, exercised through the real retry worker", () => { failureReason: null, deduplicationKey: `${experimentID}:data.json`, sessionIncremented: true, + // The worker dispatches off the QUEUE doc's provider fields, not the + // experiment's — omit these and it falls back to the legacy OSF shape. + storageProvider: "dataverse", + providerContainer: { provider: "dataverse", datasetId: 1, persistentId: "doi:x/y", serverUrl: "https://example.test" }, }; - if (providerErrorCode) doc.providerErrorCode = providerErrorCode; + if (storedCode) doc.providerErrorCode = storedCode; await queueDoc(docId).set(doc); return { owner, docId }; } - it("CONTENTION reschedules in MINUTES, not hours", async () => { - const { owner, docId } = await seedDueItem("CONTENTION"); + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("a CONTENTION provider failure reschedules in MINUTES, not hours", async () => { + mockFetch.mockResolvedValue(DATAVERSE_CONTENTION); + const { owner, docId } = await seedDueItem({ storedCode: "CONTENTION", attemptOutcome: "contention" }); await retryPendingUploads(owner); const after = (await db.collection("uploadQueue").doc(docId).get()).data(); expect(after.status).toBe("pending"); expect(after.retryCount).toBe(1); + expect(after.providerErrorCode).toBe("CONTENTION"); const delayMs = after.nextRetryAt.toMillis() - Date.now(); // retryCount 1 on the fast tier => 2^1 * 60s = 2 minutes. @@ -458,8 +570,82 @@ describe("tiered backoff, exercised through the real retry worker", () => { expect(delayMs).toBeLessThan(10 * 60 * 1000); }); + // The finding this guards: an item queued on a one-off CONTENTION whose + // provider then went down stayed pinned to the fast tier for the rest of + // its life, burning all five attempts in ~31 minutes against an + // installation that was still hours from returning. + it("a later failure with a DIFFERENT code re-tiers the item and is stored", async () => { + mockFetch.mockResolvedValue(DATAVERSE_UNAVAILABLE); + const { owner, docId } = await seedDueItem({ storedCode: "CONTENTION", attemptOutcome: "unavailable" }); + + await retryPendingUploads(owner); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + // The stored code now describes the attempt that just failed... + expect(after.providerErrorCode).toBe("UNAVAILABLE"); + // ...and the backoff followed it onto the slow tier: 2^1 * 1h. + const delayMs = after.nextRetryAt.toMillis() - Date.now(); + expect(delayMs).toBeGreaterThan(60 * 60 * 1000); + }); + + // Nothing that fails BEFORE reaching the provider is "the container is busy + // for a few seconds", so it must not inherit the fast tier from whatever + // the item was originally queued with. + it("a failure that never reached the provider clears the code and drops to the slow tier", async () => { + const { owner, docId } = await seedDueItem({ storedCode: "CONTENTION", attemptOutcome: "token-failure" }); + + await retryPendingUploads(owner); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(after.providerErrorCode).toBeNull(); + const delayMs = after.nextRetryAt.toMillis() - Date.now(); + expect(delayMs).toBeGreaterThan(60 * 60 * 1000); + }); + + // The three terminal paths that bypass handleRetryFailure entirely + // (missing owner, missing experiment, unreadable cached payload) have to + // clear the stored code themselves. The taxonomy code outranks + // failureReason in QueuePanel, so a leftover one keeps describing the + // original provider failure while the real, permanent problem is that the + // experiment or the payload is gone. + it("clears the stored code on a terminal path that never calls handleRetryFailure", async () => { + const owner = `retry-tier-owner-${randomUUID()}`; + const experimentID = `retry-tier-missing-exp-${randomUUID()}`; + const docId = `${experimentID}:data.json`.replace(/[/\\]/g, "_"); + + await db.collection("users").doc(owner).set({ email: `${owner}@example.test` }); + // Deliberately no experiments/{experimentID} doc. + + await queueDoc(docId).set({ + experimentID, + owner, + filename: "data.json", + storagePath: `upload-queue/${docId}`, + dataType: "data", + status: "pending", + errorCode: 400, + providerErrorCode: "CONTENTION", + retryCount: 0, + maxRetries: 5, + createdAt: Timestamp.now(), + lastAttemptAt: null, + nextRetryAt: Timestamp.fromMillis(Date.now() - 1000), + completedAt: null, + failureReason: null, + deduplicationKey: `${experimentID}:data.json`, + sessionIncremented: true, + }); + + await retryPendingUploads(owner); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(after.status).toBe("failed"); + expect(after.failureReason).toBe("Experiment not found"); + expect(after.providerErrorCode).toBeNull(); + }); + it("a slow-tier code still reschedules in HOURS (regression)", async () => { - const { owner, docId } = await seedDueItem("UNAVAILABLE"); + const { owner, docId } = await seedDueItem({ storedCode: "UNAVAILABLE", attemptOutcome: "token-failure" }); await retryPendingUploads(owner); @@ -470,7 +656,7 @@ describe("tiered backoff, exercised through the real retry worker", () => { }); it("no providerErrorCode at all keeps the original hours-scale behavior", async () => { - const { owner, docId } = await seedDueItem(undefined); + const { owner, docId } = await seedDueItem({ storedCode: undefined, attemptOutcome: "token-failure" }); await retryPendingUploads(owner); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index 38a6216..daa2770 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -8,7 +8,7 @@ import MESSAGES from "./api-messages.js"; import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { getProviderForExperiment } from "./providers/index.js"; +import { getProviderForExperiment, claimNameFor } from "./providers/index.js"; import { WriteResult, ResolvedAuth } from "./providers/types.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from './interfaces'; @@ -120,10 +120,17 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: // Collision detection: claim the filename in the Firestore cache // immediately before the provider write. The provider's own conflict // response (NAME_CONFLICT) stays wired up below as a dual-run backstop. + // Claimed on the name the PROVIDER will store this file under (see the + // matching comment in api-data.ts). base64 uploads are not laid out under + // data/raw/, so the requested path is `filename` itself -- but it still has + // to go through the adapter's storedNameFor, since Zenodo flattens slashes + // and Drive keeps only the leaf, and a claim in either of those namespaces + // has to match what listFiles reports when the cache rehydrates. const claimToken = randomUUID(); + const claimName = claimNameFor(provider, filename); let claimResult: Awaited<ReturnType<typeof claimFilename>>; try { - claimResult = await claimFilename(experimentID, filename, claimToken, () => + claimResult = await claimFilename(experimentID, claimName, claimToken, () => provider.listFiles(auth, container) ); } catch (e) { @@ -219,7 +226,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: // Dual-run disagreement: the cache thought the name was free but OSF // says it's taken. OSF is still the backstop — record the // disagreement and confirm the claim (the name is now provably taken). - await confirmClaim(experimentID, filename, claimToken); + await confirmClaim(experimentID, claimName, claimToken); // Logs before response — see the matching comment in api-data.ts: // responding first races observers of the log against the write. await writeLog(experimentID, "logError", MESSAGES.OSF_FILE_EXISTS); @@ -255,7 +262,7 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: // Successful write — confirm the claim (best-effort; a confirm failure // must not fail a request that already succeeded against the provider). - await confirmClaim(experimentID, filename, claimToken); + await confirmClaim(experimentID, claimName, claimToken); // Data successfully uploaded to OSF — clean up the pending copy. await cleanupPending(pendingPath); diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 18e9f80..4a3edc2 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -12,7 +12,7 @@ import { uploadDerivedFiles, queueDerivedFiles } from "./metadata-derived-upload import resolveToken from "./resolve-token.js"; import queueUpload from "./queue-upload.js"; import { persistPending, cleanupPending } from "./persist-pending.js"; -import { getProviderForExperiment } from "./providers/index.js"; +import { getProviderForExperiment, claimNameFor } from "./providers/index.js"; import { WriteResult, ResolvedAuth } from "./providers/types.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData, RequestBody } from './interfaces'; @@ -176,12 +176,21 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // Collision detection: claim the filename in the Firestore cache // immediately before the provider write. The provider's own conflict // response (NAME_CONFLICT) stays wired up below as a dual-run backstop. - // Claimed on the RAW leaf filename (unchanged by the Psych-DS layout), not - // uploadFilename, so dedup keys off what the participant actually submitted. + // + // Claimed on the name the PROVIDER will store this file under, derived from + // uploadFilename by that adapter's own storedNameFor. It used to be claimed + // on the raw leaf filename, which put claims in a different namespace from + // the listFiles results a cold cache rehydrates from -- so on a rehydrated + // cache no claim ever matched, and the providers with no NAME_CONFLICT to + // fall back on silently overwrote (Zenodo) or duplicated (Dataverse) a + // participant's data. It also disagreed with the retry worker, which claims + // on the queued upload path, so a queued retry never re-entered its own + // pending claim. const claimToken = randomUUID(); + const claimName = claimNameFor(provider, uploadFilename); let claimResult: Awaited<ReturnType<typeof claimFilename>>; try { - claimResult = await claimFilename(experimentID, filename, claimToken, () => + claimResult = await claimFilename(experimentID, claimName, claimToken, () => provider.listFiles(auth, container) ); } catch (e) { @@ -189,7 +198,11 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 const detail = e.message; try { await queueUpload({ - experimentID, owner: exp_data.owner, filename, data, + // uploadFilename, not the raw filename: the retry worker writes + // whatever it finds here, so queueing the raw leaf would drop a + // metadataActive submission at the container root instead of under + // data/raw/ (and claim it in the wrong namespace on the way). + experimentID, owner: exp_data.owner, filename: uploadFilename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, @@ -222,7 +235,9 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // lease; queue this upload and let the retry land after it expires. try { await queueUpload({ - experimentID, owner: exp_data.owner, filename, data, + // uploadFilename for the same reason as the rehydration-failure path + // above -- the retry worker writes exactly what is queued here. + experimentID, owner: exp_data.owner, filename: uploadFilename, data, dataType: "data", osfFilesLink: exp_data.osfFilesLink, storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, errorCode: 0, sessionIncremented: true, @@ -282,7 +297,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // Dual-run disagreement: the cache thought the name was free but OSF // says it's taken. OSF is still the backstop — record the // disagreement and confirm the claim (the name is now provably taken). - await confirmClaim(experimentID, filename, claimToken); + await confirmClaim(experimentID, claimName, claimToken); // Logs are written BEFORE the response here (unlike other branches): // the disagreement entry is the dual-run's whole audit trail, and // responding first races observers of the log against the write. @@ -323,7 +338,7 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 // Successful write — confirm the claim (best-effort; a confirm failure // must not fail a request that already succeeded against the provider). - await confirmClaim(experimentID, filename, claimToken); + await confirmClaim(experimentID, claimName, claimToken); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); diff --git a/functions/src/api-messages.ts b/functions/src/api-messages.ts index c8d889a..4bf818d 100644 --- a/functions/src/api-messages.ts +++ b/functions/src/api-messages.ts @@ -43,9 +43,16 @@ const MESSAGES = { error: "PROVIDER_NOT_CONNECTED", message: "The experiment owner has not connected an account for this experiment's storage provider", }, + // Named no provider. Both static-token adapters emit this code (dataverse.ts + // and zenodo.ts), so hardcoding "Dataverse" told a Zenodo owner to go fix a + // token on a service they may not even use. The wording still carries what + // makes this code distinct from AUTH_EXPIRED -- a static token cannot be + // refreshed, so the researcher has to CREATE a new one and reconnect, not + // just re-authorize. PROVIDER_TOKEN_EXPIRED: { error: "PROVIDER_TOKEN_EXPIRED", - message: "The Dataverse API token for this experiment's owner has expired and must be reconnected", + message: + "The API token for this experiment's storage provider has expired. A new token must be created on that provider and reconnected to DataPipe", }, INVALID_BASE64_DATA: { error: "INVALID_BASE64_DATA", diff --git a/functions/src/metadata-block.ts b/functions/src/metadata-block.ts index 71fae02..28e6928 100644 --- a/functions/src/metadata-block.ts +++ b/functions/src/metadata-block.ts @@ -33,7 +33,21 @@ class ProviderUpdateError extends Error { // auth or quota problem isn't fixed by writing a new file, and self-healing // on RATE_LIMITED would double the write load exactly when the provider is // telling us to back off. -const NON_HEALABLE_CODES: ProviderErrorCode[] = ["AUTH_EXPIRED", "RATE_LIMITED", "QUOTA_EXCEEDED"]; +// +// CONTENTION belongs here for the same reason as RATE_LIMITED, and its +// absence was a real hazard on Dataverse, which is both the provider that +// emits it and the one whose updateFile is delete-then-re-add. A collision +// there means the re-add lost a race against another participant's write, so +// the immediate re-create the self-heal branch attempts is a THIRD write into +// the same still-contended dataset — which loses the same race, throws, and +// takes the submission down with it. Failing straight out leaves the stale +// ref for the next submission to self-heal from, once the container is quiet. +const NON_HEALABLE_CODES: ProviderErrorCode[] = [ + "AUTH_EXPIRED", + "RATE_LIMITED", + "QUOTA_EXCEEDED", + "CONTENTION", +]; export default async function blockMetadata( exp_data: ExperimentData, @@ -157,6 +171,25 @@ try { result.error ); } + + // An update can hand back a DIFFERENT ref than it was given, and the + // stored one has to follow it. OSF and gdrive update in place and + // echo the ref back unchanged, but Dataverse's updateFile is DELETE + + // re-add and the re-added file gets a brand-new dataFile id — so + // discarding this result left Firestore pointing at an id that was + // deleted moments ago. Every later submission then tried to DELETE + // it, got a 404, and self-healed by creating another + // dataset_description.json, which Dataverse silently renames rather + // than rejecting: the dataset accumulated one orphaned + // dataset_description-N.json per submission while the canonical file + // was never updated again. + // + // Guarded on a defined id for the same reason createMetadataFile is: + // storing a ref with no usable id would persist something no future + // update can address. + if (result.fileRef.id && result.fileRef.id !== fileRef.id) { + t.set(metadata_doc_ref, { metadataFileRef: result.fileRef }, { merge: true }); + } } //When a ref and firestore metadata both exist, updating is done with respect to firestore. diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index deecadd..20662ce 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -1,4 +1,5 @@ import fetch from "node-fetch"; +import { randomBytes } from "crypto"; import { decrypt } from "../crypto-utils.js"; import { UserData } from "../interfaces.js"; import { @@ -30,10 +31,28 @@ export interface DataverseContainerRef extends ContainerRef { // Paginate LIST DRAFT FILES in chunks of this size (see listFiles below). const LIST_PAGE_LIMIT = 1000; -// A fixed boundary is fine here — the request body is built and sent in one -// shot, never streamed/concatenated across requests, so there's no need for -// per-call uniqueness. Mirrors gdrive.ts's MULTIPART_BOUNDARY convention. -const MULTIPART_BOUNDARY = "datapipe-dataverse-multipart-boundary"; +// The boundary is generated per request rather than being a fixed constant. +// Uniqueness per call is not the point — the body is built and sent in one +// shot — but UNGUESSABILITY is: one of the two parts below is the +// participant's raw submission, copied in verbatim, and a fixed boundary is a +// string an attacker can simply include in their data to close the part early +// and append parts of their own. A random boundary cannot be written into a +// payload that was composed before it existed. +function newMultipartBoundary(): string { + return `datapipe-dataverse-${randomBytes(16).toString("hex")}`; +} + +// Quotes a value for a Content-Disposition parameter. The filename reaches +// here straight from the participant's POST body with no character +// validation, and interpolating it raw let a quote close the parameter and a +// CRLF end the header block entirely — enough to forge a second `jsonData` +// part and choose the directoryLabel the file lands in. CR/LF are dropped +// outright (no escape for them exists inside a quoted-string) and backslashes +// and quotes are escaped per RFC 2616's quoted-string rules. +function quoteHeaderParam(value: string): string { + const sanitized = value.replace(/[\r\n]+/g, "").replace(/([\\"])/g, "\\$1"); + return `"${sanitized}"`; +} function authHeaders(auth: ResolvedAuth): Record<string, string> { return { "X-Dataverse-key": auth.token }; @@ -146,24 +165,33 @@ async function mapErrorResponse(response: { // Hand-built multipart/form-data body with exactly two parts, field names // "file" and "jsonData" -- mirrors gdrive.ts's buildMultipartBody convention. +// Returns the boundary alongside the bytes because the caller has to put the +// same one in the request's Content-Type header. function buildMultipartBody( jsonData: object, filename: string, data: string | Buffer, contentType: string -): Buffer { +): { body: Buffer; boundary: string } { + const boundary = newMultipartBoundary(); const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data); const preamble = - `--${MULTIPART_BOUNDARY}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename=${quoteHeaderParam(filename)}\r\n` + `Content-Type: ${contentType}\r\n\r\n`; const middle = - `\r\n--${MULTIPART_BOUNDARY}\r\n` + + `\r\n--${boundary}\r\n` + `Content-Disposition: form-data; name="jsonData"\r\n\r\n` + `${JSON.stringify(jsonData)}\r\n`; - const epilogue = `--${MULTIPART_BOUNDARY}--`; - - return Buffer.concat([Buffer.from(preamble), dataBuffer, Buffer.from(middle), Buffer.from(epilogue)]); + const epilogue = `--${boundary}--`; + + const body = Buffer.concat([ + Buffer.from(preamble), + dataBuffer, + Buffer.from(middle), + Buffer.from(epilogue), + ]); + return { body, boundary }; } // tabIngest (writeSessionFile's suppression of Dataverse's CSV->.tab @@ -429,12 +457,37 @@ export const dataverseProvider: StorageProvider = { } const responseBody = (await response.json()) as { data?: { id?: number; persistentId?: string } }; - const datasetId = responseBody.data?.id as number; - const persistentId = responseBody.data?.persistentId as string; + const datasetId = responseBody.data?.id; + const persistentId = responseBody.data?.persistentId; + + // Both are load-bearing for every subsequent write, so a 2xx whose body + // doesn't carry them is a hard failure here rather than a confusing one + // later — same check, for the same reason, as zenodo.ts's. Casting the + // optionality away instead produced a ContainerRef with undefined fields, + // which create-experiment.ts then handed to Firestore; Firestore rejects + // undefined values, so the batch commit threw and the researcher got a + // generic 500 with an orphaned draft dataset already created on their + // installation and no hint that either had happened. + if (typeof datasetId !== "number" || !persistentId) { + throw new Error("Dataverse dataset creation returned no id or persistent id"); + } return { provider: "dataverse", datasetId, persistentId, serverUrl }; }, + // Identity, and deliberately explicit rather than omitted. Dataverse splits + // a path into directoryLabel + label on write and listFiles re-joins them + // (see below), so the requested path IS the name the listing reports and the + // collision cache can hash it unchanged. Stated here so the round-trip is + // asserted by the interface rather than inferred by a reader comparing two + // functions 100 lines apart -- and because getting it wrong is not + // self-correcting on this provider: Dataverse SILENTLY RENAMES a duplicate + // instead of returning NAME_CONFLICT, so a cache miss becomes a duplicate + // file, not an error. + storedNameFor(filename: string): string { + return filename; + }, + async writeSessionFile( auth: ResolvedAuth, container: ContainerRef, @@ -464,13 +517,13 @@ export const dataverseProvider: StorageProvider = { jsonData.directoryLabel = directoryLabel; } - const body = buildMultipartBody(jsonData, uploadFilename, data, meta.contentType); + const { body, boundary } = buildMultipartBody(jsonData, uploadFilename, data, meta.contentType); const response = await fetch(`${serverUrl}/api/datasets/${dataverseContainer.datasetId}/add`, { method: "POST", headers: { ...authHeaders(auth), - "Content-Type": `multipart/form-data; boundary=${MULTIPART_BOUNDARY}`, + "Content-Type": `multipart/form-data; boundary=${boundary}`, }, body, }); @@ -488,11 +541,27 @@ export const dataverseProvider: StorageProvider = { // response's `label`, never assumed to equal the requested name. This is // exactly the case WriteResult.storedFilename exists to detect. Dataverse // can never produce a NAME_CONFLICT, so there's no error path for it. - const storedFilename = uploaded?.label as string; + // + // Falling back to the requested name when the body carries no label + // matches osf.ts (`result.fileName ?? filename`): the write itself + // already succeeded, so an unreadable body must not turn it into a + // failure -- it only costs us the ability to notice a rename. + const storedFilename = uploaded?.label ?? uploadFilename; + + // The id is OMITTED, not stringified, when the body doesn't carry one. + // `String(undefined)` produces the truthy string "undefined", which sails + // through metadata-block.ts's `if (response.fileRef.id)` guard -- the + // guard that exists specifically to keep an unusable ref out of + // Firestore -- and gets persisted as a metadataFileRef addressing + // /api/files/undefined, which every later update then fails against. + const fileId = uploaded?.dataFile?.id; return { success: true, - fileRef: { name: storedFilename, id: String(uploaded?.dataFile?.id) }, + fileRef: { + name: storedFilename, + ...(fileId !== undefined ? { id: String(fileId) } : {}), + }, storedFilename, }; }, @@ -574,15 +643,19 @@ export const dataverseProvider: StorageProvider = { // -> directoryLabel "data/raw" + label "x.json"). Returning the bare // label would break two things: updateFile re-adds under // existingFileRef.name and would drop the file back to the dataset - // root, and the collision cache claims prefixed filenames, so - // rehydration would fail to match an existing file and let a - // duplicate through -- which Dataverse then silently renames rather - // than rejecting. Two files sharing a label in different directories - // would also collapse together here. + // root, and the collision cache hashes these names as-is (this + // adapter's storedNameFor is identity, which is what makes the + // round-trip hold), so rehydration would fail to match an existing + // file and let a duplicate through -- which Dataverse then silently + // renames rather than rejecting. Two files sharing a label in + // different directories would also collapse together here. const name = file.directoryLabel ? `${file.directoryLabel}/${file.label}` : (file.label as string); - results.push({ name, id: String(file.dataFile?.id) }); + // Same reason as writeSessionFile above: omit a missing id rather + // than storing the truthy string "undefined". + const fileId = file.dataFile?.id; + results.push({ name, ...(fileId !== undefined ? { id: String(fileId) } : {}) }); } offset += page.length; diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index 15b4ebb..f8d4c2f 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -317,6 +317,18 @@ export const gdriveProvider: StorageProvider = { return { provider: "gdrive", folderId }; }, + // Drive stores a path prefix as real nested FOLDERS and the file itself + // under its bare leaf name, and listFiles below collects every file it finds + // under that leaf regardless of which folder it came from -- so the leaf is + // what the collision cache must hash. Two submissions whose paths differ + // only in their folder prefix therefore collide by design here; that is the + // pre-existing behavior listFiles was written for, and it is the safe + // direction, since Drive returns no NAME_CONFLICT for the cache to fall back + // on. See claimNameFor. + storedNameFor(filename: string): string { + return filename.split("/").pop() as string; + }, + async writeSessionFile( auth: ResolvedAuth, container: ContainerRef, diff --git a/functions/src/providers/index.ts b/functions/src/providers/index.ts index a94a310..9bbefc7 100644 --- a/functions/src/providers/index.ts +++ b/functions/src/providers/index.ts @@ -33,6 +33,16 @@ export function getOAuthConfig(provider: string): OAuthConfig { return storageProvider.oauthConfig(); } +// The name the collision cache must hash for a file being written to +// `filename` on this provider -- i.e. the name that provider's listFiles will +// report for it once written (see StorageProvider.storedNameFor). Every +// claimFilename/confirmClaim call site goes through this rather than hashing a +// raw request filename, so claims made before a write and claims rehydrated +// from a listing can never fall into different namespaces. +export function claimNameFor(provider: StorageProvider, filename: string): string { + return provider.storedNameFor ? provider.storedNameFor(filename) : filename; +} + export function getProviderForExperiment(exp_data: ExperimentData): { provider: StorageProvider; container: ContainerRef; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index fb9e19c..75a6781 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -152,6 +152,22 @@ export const osfProvider: StorageProvider = { throw new Error("osfProvider.createDataContainer is not implemented"); }, + // Identity: OSF keeps the path as real nested folders, so distinct paths + // are distinct files and the collision cache must keep them distinct too -- + // collapsing to the leaf (as gdrive does, because its listing collapses the + // same way) would falsely reject a second submission to a different + // subfolder. + // + // The known gap this leaves, unchanged from before: listFiles below reads + // only the component ROOT, so rehydrating a cold cache never sees files + // under data/raw/ or a researcher's subfolder and a duplicate can slip past + // the cache. OSF is the one provider where that is survivable -- it answers + // a duplicate write with 409, which api-data maps to NAME_CONFLICT and + // treats as the authoritative backstop. + storedNameFor(filename: string): string { + return filename; + }, + async writeSessionFile( auth: ResolvedAuth, container: ContainerRef, diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 65fd9a8..a4e1fa7 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -210,6 +210,24 @@ export interface StorageProvider { // rehydration and dashboard file counts. listFiles(auth: ResolvedAuth, container: ContainerRef): Promise<FileRef[]>; + // Pure, synchronous, deterministic: given the path writeSessionFile will be + // asked to write, returns the `name` this adapter's own listFiles reports + // for the resulting file. Omitting it means "identity". + // + // THIS IS THE COLLISION CACHE'S IDENTITY FUNCTION, and it exists because + // the two sides silently disagreed. The cache hashes a name at claim time + // and rehydrates a cold cache from listFiles, so the two MUST live in the + // same namespace -- but adapters legitimately transform the requested path: + // Zenodo flattens slashes into "_", Drive stores only the leaf under a + // nested folder. A claim made on the raw leaf name therefore never matched + // a rehydrated one, and the providers that cannot return NAME_CONFLICT + // (Zenodo's PUT overwrites in place, Dataverse silently renames) had no + // backstop to catch what slipped through. + // + // Any new adapter whose writeSessionFile does not store the requested path + // verbatim MUST implement this. See claimNameFor in providers/index.ts. + storedNameFor?(filename: string): string; + // Fetches a file's contents as text. Used by metadata-block.ts to read // back an existing dataset_description.json. Never throws — failures come // back as a DownloadResult, same shape convention as WriteResult. diff --git a/functions/src/providers/zenodo.ts b/functions/src/providers/zenodo.ts index 4214b8b..41fdbb9 100644 --- a/functions/src/providers/zenodo.ts +++ b/functions/src/providers/zenodo.ts @@ -385,6 +385,18 @@ export const zenodoProvider: StorageProvider = { return { provider: "zenodo", depositionId, bucketUrl, serverUrl }; }, + // Zenodo's keyspace is flat, so "data/raw/x.json" is stored -- and reported + // back by listFiles -- as "data_raw_x.json". The collision cache must hash + // that, not the requested path: it rehydrates a cold cache straight from + // listFiles, so a claim in any other namespace can never match a rehydrated + // one. That matters more here than on any other provider, because + // writeSessionFile below is an OVERWRITING PUT with no NAME_CONFLICT to fall + // back on -- a claim the cache fails to recognize destroys the earlier + // session's data silently. See toZenodoKey and claimNameFor. + storedNameFor(filename: string): string { + return toZenodoKey(filename); + }, + async writeSessionFile( auth: ResolvedAuth, container: ContainerRef, diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index 987bdb2..eaf133e 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -32,15 +32,25 @@ interface QueueUploadParams { const MAX_RETRIES = 5; -// CONTENTION and RATE_LIMITED both mean "the provider is busy right now" and -// clear in seconds, unlike AUTH_EXPIRED / QUOTA_EXCEEDED / UNAVAILABLE (or no -// code at all), which need human action or an outage to end. Exported so +// CONTENTION means "another write to this same container is in flight right +// now" -- verified live against demo.dataverse.org to clear in seconds -- so +// it is the one code worth retrying on a minutes-scale schedule. Exported so // scheduled-upload-retry.ts reads the exact same tier boundary rather than // keeping its own copy that could drift out of sync with this one. -export const FAST_RETRY_CODES: ReadonlySet<string> = new Set(["CONTENTION", "RATE_LIMITED"]); +// +// RATE_LIMITED is deliberately NOT here, though it looks like it belongs. A +// 429 is the provider telling us to back off for as long as ITS window lasts, +// which for OSF and Drive is a scale of hours, not seconds. On the fast tier +// its five attempts (60s base, 30-minute cap) are all spent inside ~31 +// minutes, every one of them re-hitting the endpoint that just rate-limited +// us; the item is then marked permanently failed and cleanupOldEntries +// deletes its Cloud Storage payload seven days later. The slow tier's ~31 +// hours is what a rate-limit window actually needs to outlast, so 429 stays +// there -- exactly where it was before the fast tier existed. +export const FAST_RETRY_CODES: ReadonlySet<string> = new Set(["CONTENTION"]); -export function isFastRetry(code?: string): boolean { - return code !== undefined && FAST_RETRY_CODES.has(code); +export function isFastRetry(code?: string | null): boolean { + return !!code && FAST_RETRY_CODES.has(code); } export default async function queueUpload(params: QueueUploadParams): Promise<string> { @@ -50,7 +60,7 @@ export default async function queueUpload(params: QueueUploadParams): Promise<st const docRef = db.collection("uploadQueue").doc(docId); const now = Timestamp.now(); - // Fast tier (CONTENTION/RATE_LIMITED): 60 seconds — the cadence of + // Fast tier (CONTENTION): 60 seconds — the cadence of // scheduled-upload-retry.ts is what makes this meaningful (see that file). // Everything else, including no providerErrorCode at all: 1 hour, exactly // as before this change. diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index 444050b..073cd94 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -1,8 +1,8 @@ import { onSchedule } from "firebase-functions/v2/scheduler"; import { Timestamp } from "firebase-admin/firestore"; import { db, storage } from "./app.js"; -import { getProvider } from "./providers/index.js"; -import { ContainerRef, StorageProviderId, ResolvedAuth } from "./providers/types.js"; +import { getProvider, claimNameFor } from "./providers/index.js"; +import { ContainerRef, StorageProviderId, ResolvedAuth, ProviderErrorCode } from "./providers/types.js"; import resolveToken from "./resolve-token.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from "./interfaces.js"; @@ -10,9 +10,9 @@ import { isFastRetry } from "./queue-upload.js"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24 hours (slow tier cap, unchanged) -// Fast tier (CONTENTION/RATE_LIMITED, see queue-upload.ts's isFastRetry): a -// much shorter cap, since these clear in seconds/minutes rather than needing -// an outage to end. +// Fast tier (CONTENTION, see queue-upload.ts's isFastRetry): a much shorter +// cap, since write contention clears in seconds rather than needing an outage +// to end. const FAST_MAX_BACKOFF_MS = 30 * 60 * 1000; // 30 minutes /** @@ -115,16 +115,24 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho return; } + // These three terminal paths bypass handleRetryFailure, so they clear + // providerErrorCode themselves. Without that, a doc queued on a provider + // error keeps that code forever and QueuePanel goes on describing the + // original provider failure — the taxonomy code outranks failureReason — + // while the real, and now permanent, problem is that the account, the + // experiment, or the cached payload is gone. + const CLEARED_CODE = { providerErrorCode: null }; + // Re-resolve the OSF token const userDoc = await db.doc(`users/${data.owner}`).get(); if (!userDoc.exists) { - await docRef.update({ status: "failed", failureReason: "Owner user not found" }); + await docRef.update({ status: "failed", failureReason: "Owner user not found", ...CLEARED_CODE }); return; } const expDoc = await db.doc(`experiments/${data.experimentID}`).get(); if (!expDoc.exists) { - await docRef.update({ status: "failed", failureReason: "Experiment not found" }); + await docRef.update({ status: "failed", failureReason: "Experiment not found", ...CLEARED_CODE }); return; } @@ -160,7 +168,11 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho } } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; - await docRef.update({ status: "failed", failureReason: `Failed to read cached data: ${detail}` }); + await docRef.update({ + status: "failed", + failureReason: `Failed to read cached data: ${detail}`, + ...CLEARED_CODE, + }); return; } @@ -176,10 +188,17 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho // Collision cache: only entries queued after the cache existed carry a // claimToken. Entries queued before it skip the cache entirely — legacy // behavior, the provider's own conflict backstop still applies to them. + // + // data.filename is the UPLOAD path (what writeSessionFile is handed below), + // so the claim goes through the same adapter-supplied storedNameFor the + // request path uses. That is what makes re-entry work: the pending claim + // api-data left behind is under this exact hash, so claimFilename's + // same-ownerToken branch recognizes it instead of opening a second one. + const claimName = claimNameFor(provider, data.filename); if (data.claimToken) { let claimResult: Awaited<ReturnType<typeof claimFilename>>; try { - claimResult = await claimFilename(data.experimentID, data.filename, data.claimToken, () => + claimResult = await claimFilename(data.experimentID, claimName, data.claimToken, () => provider.listFiles(auth, container) ); } catch (e) { @@ -216,7 +235,7 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho if (result.success) { if (data.claimToken) { - await confirmClaim(data.experimentID, data.filename, data.claimToken); + await confirmClaim(data.experimentID, claimName, data.claimToken); } await markCompleted(docRef, data); console.log(`Successfully retried upload ${queueDoc.id} (${data.filename})`); @@ -225,7 +244,7 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho if (result.error === "NAME_CONFLICT") { if (data.claimToken) { - await confirmClaim(data.experimentID, data.filename, data.claimToken); + await confirmClaim(data.experimentID, claimName, data.claimToken); } // File already exists — treat as success (original upload may have worked) await markCompleted(docRef, data); @@ -233,7 +252,13 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho return; } - await handleRetryFailure(docRef, data, `Provider error ${result.providerStatus}: ${result.providerMessage}`, result.retryAfter); + await handleRetryFailure( + docRef, + data, + `Provider error ${result.providerStatus}: ${result.providerMessage}`, + result.retryAfter, + result.error + ); } catch (e) { const detail = e instanceof Error ? e.message : "Unknown error"; await handleRetryFailure(docRef, data, `Upload exception: ${detail}`); @@ -259,35 +284,54 @@ async function handleRetryFailure( docRef: FirebaseFirestore.DocumentReference, data: FirebaseFirestore.DocumentData, reason: string, - retryAfterSeconds?: number | null + retryAfterSeconds?: number | null, + providerErrorCode?: ProviderErrorCode | null ) { const newRetryCount = (data.retryCount || 0) + 1; + // The code from THIS attempt, not the one the doc was queued with. Written + // back on every path below so both the tier chosen next time and the copy + // QueuePanel shows describe the failure that actually just happened. A + // failure that never reached the provider (token resolution, a network + // exception, cache rehydration) passes nothing and clears the field, which + // drops the item onto the slow tier — correct, since none of those clear in + // seconds — and lets QueuePanel fall back to reading failureReason. + const currentErrorCode = providerErrorCode ?? null; + if (newRetryCount >= data.maxRetries) { console.error(`Upload ${docRef.id} permanently failed after ${newRetryCount} retries: ${reason}`); await docRef.update({ status: "failed", retryCount: newRetryCount, failureReason: reason, + providerErrorCode: currentErrorCode, }); return; } - // Tier the backoff by the provider error code stored on the queue doc (set - // by queue-upload.ts at initial queue time). CONTENTION/RATE_LIMITED are - // "the provider is busy right now" and resolve in seconds, unlike - // AUTH_EXPIRED / QUOTA_EXCEEDED / UNAVAILABLE (or no code at all), which - // need human action or an outage to end — so the fast tier gets a - // minutes-scale base/cap (~2, 4, 8, 16, 30 minutes) instead of the - // hours-scale one (~2, 4, 8, 16, 24 hours, unchanged). - const fastTier = isFastRetry(data.providerErrorCode); + // Tier the backoff by the provider error code from the attempt that just + // failed. CONTENTION is "another write to this container is in flight" and + // resolves in seconds, unlike AUTH_EXPIRED / QUOTA_EXCEEDED / RATE_LIMITED / + // UNAVAILABLE (or no code at all), which need human action, a rate-limit + // window, or an outage to end — so the fast tier gets a minutes-scale + // base/cap (~2, 4, 8, 16, 30 minutes) instead of the hours-scale one (~2, 4, + // 8, 16, 24 hours, unchanged). + // + // Reading the CURRENT code rather than the stored one is load-bearing: an + // item queued on a one-off CONTENTION whose provider then went down for + // maintenance used to stay pinned to the fast tier for the rest of its life, + // burning all five attempts in ~31 minutes against an installation that was + // still hours from coming back. + const fastTier = isFastRetry(currentErrorCode); const baseMs = fastTier ? 60 * 1000 : 60 * 60 * 1000; const capMs = fastTier ? FAST_MAX_BACKOFF_MS : MAX_BACKOFF_MS; - // Honor Retry-After header if provided, otherwise use exponential backoff. - // A Retry-After still wins where present, clamped to this item's tier cap. + // Honor Retry-After where the provider sent one. Clamped to MAX_BACKOFF_MS, + // never to the item's tier cap: the header is the provider stating how long + // it will keep refusing, so clamping it DOWN to the fast tier's 30 minutes + // would schedule a retry the provider already told us would fail. const backoffMs = retryAfterSeconds - ? Math.min(retryAfterSeconds * 1000, capMs) + ? Math.min(retryAfterSeconds * 1000, MAX_BACKOFF_MS) : Math.min(Math.pow(2, newRetryCount) * baseMs, capMs); const nextRetryAt = Timestamp.fromMillis(Date.now() + backoffMs); @@ -297,6 +341,7 @@ async function handleRetryFailure( status: "pending", retryCount: newRetryCount, nextRetryAt, + providerErrorCode: currentErrorCode, }); } From 9d8dc6b00dba55072e04f81fc60128924b088e5e Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 18:39:13 -0400 Subject: [PATCH 069/181] build: migrate lint to ESLint flat config `npm run lint` ran `next lint`, which Next.js 16 removed, so the argument was read as a project DIRECTORY: "Invalid project directory provided, no such directory: <repo>/lint". Linting had in fact been inert for longer than that -- ESLint 9 uses flat config by default and only falls back to .eslintrc when ESLINT_USE_FLAT_CONFIG=false, so .eslintrc.json had already stopped being read. Replaces it with eslint.config.mjs built on eslint-config-next@16's flat exports, keeping the same rule set (next/core-web-vitals, then eslint-config-prettier, then react/no-unescaped-entities off) and the same lint surface `next lint` covered by default. Verified against the resolved config: 61 active rules, Next and rules-of-hooks at error, Prettier-owned stylistic rules off. Two rules are set to "warn" rather than the error they default to: react-hooks/set-state-in-effect and react-hooks/purity, both new in eslint-plugin-react-hooks v7, which arrived with eslint-config-next@16 and did not exist under the config this replaces. They flag seven real findings across Navbar, ChangePassword, the admin pages, reset-password and OAuthTokenStatus. Each needs a behavioral look at the effect involved, so they belong in their own change rather than riding along with a build-tooling fix -- the config comment lists them and says to promote the rules back to error once they are addressed. No CI workflow referenced the lint script, so nothing was failing on this; the command was simply unusable locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .eslintrc.json | 6 ----- eslint.config.mjs | 58 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 59 insertions(+), 7 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 eslint.config.mjs diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 8693844..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "prettier"], - "rules": { - "react/no-unescaped-entities": "off" - } -} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ed08757 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,58 @@ +// ESLint flat config, replacing .eslintrc.json + `next lint`. +// +// Next.js 16 removed the `next lint` command, so `npm run lint` was invoking +// `next lint`, which read "lint" as a project DIRECTORY and died with +// "Invalid project directory provided, no such directory: <repo>/lint". The +// legacy .eslintrc.json had stopped being read even before that: ESLint 9 uses +// flat config by default and only falls back to .eslintrc when +// ESLINT_USE_FLAT_CONFIG=false. So linting had been silently doing nothing. +// +// This reproduces the old config -- next/core-web-vitals, then +// eslint-config-prettier to switch off the stylistic rules Prettier owns, then +// the one project rule override -- against eslint-config-next@16's flat +// exports, which are already arrays of flat config objects. + +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import prettier from "eslint-config-prettier"; + +export default [ + { + // Flat config has no .eslintignore; ignores live here. Build output and + // vendored bundles only -- linting functions/lib would lint the compiled + // copy of functions/src and report everything twice. + ignores: [ + ".next/**", + "out/**", + "coverage/**", + "functions/lib/**", + "functions/metadata/dist/**", + "**/node_modules/**", + ], + }, + ...nextCoreWebVitals, + prettier, + { + rules: { + "react/no-unescaped-entities": "off", + + // Downgraded from the error they default to, DELIBERATELY and + // temporarily. Both are new in eslint-plugin-react-hooks v7, which + // arrived with eslint-config-next@16 -- they did not exist under the + // config this file replaces. Restoring a working `npm run lint` should + // not also silently adopt a stricter standard than the project has ever + // been held to and fail on seven findings nobody has looked at. + // + // They are real findings, not noise, and they are worth fixing: + // set-state-in-effect components/Navbar.js:27 + // components/account/ChangePassword.js:29,37 + // pages/admin/[experiment_id].js:63 + // pages/admin/index.js:43 + // pages/reset-password.js:29 + // purity components/account/OAuthTokenStatus.js:37 + // Each needs a behavioral look at the effect in question, so they belong + // in their own change. Promote these back to "error" once they are. + "react-hooks/set-state-in-effect": "warn", + "react-hooks/purity": "warn", + }, + }, +]; diff --git a/package.json b/package.json index 5884b5f..6206c0d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint pages components lib", "test": "jest --watch", "test-ci": "jest --ci", "emulators": "firebase emulators:start --import=./.emulator-data --export-on-exit", From 1df5082c0ab337ee86bd1e5c5022b938452e3d1b Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Tue, 11 Aug 2026 21:56:13 -0400 Subject: [PATCH 070/181] fix: clear the react-hooks findings and enforce the rules Fixes the seven findings the previous commit parked as warnings, then removes the downgrade so react-hooks/set-state-in-effect and react-hooks/purity are enforced at their default error severity. Derived state that was being mirrored into useState by an effect is now computed during render: ChangePassword's passwordMatch and passwordLengthSatisfied, and reset-password's view state, which is derived from the URL with only the page's own "email sent" transition kept in state. Both had a visible symptom. ChangePassword initialised passwordLengthSatisfied to true, so an empty field rendered as valid until the effect ran; reset-password rendered the "forgot password" form and swapped to the token form a beat later, so a user arriving from a reset link saw the wrong form flash by. Client-only values that cannot be read during SSR now go through useSyncExternalStore rather than a setState-in-effect flag: Navbar's hydration guard, and the admin OAuth banner's localStorage dismissal, which needs a listener set so dismissing it re-renders. The banner had the same wrong-for-one-render problem -- it defaulted to dismissed, so a researcher who had never dismissed it got no banner until the effect corrected it. The queue-resolved notice on the experiment page compares against the previous queue length during render, React's documented way to adjust state when a value changes, with the 8-second auto-hide left in an effect keyed off the flag. This also fixes a latent bug: the old effect returned its timer cleanup BEFORE recording the new count, so prevQueueCount kept a stale non-zero value after a transition fired. The two exhaustive-deps warnings turned out to be load-bearing, and the rule's suggestion is wrong here. Adding `router` to the provider-connect effect hangs its test suite outright: the useRouter mock returns a new object per call, the effect re-runs on every render, and because it dispatches, every run schedules the next -- an unbounded loop re-running the OAuth token exchange. Both callback pages now depend on `push` alone, destructured, since the query parameters are already listed individually. pages/oauth2/callback.js keeps `user?.uid` over `user` behind a documented eslint-disable: useAuthState hands back a fresh User object on every token refresh, and re-running a completed OAuth exchange because an object identity churned is not what the dependency is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- components/Navbar.js | 29 ++++++++++++--- components/account/ChangePassword.js | 27 +++++--------- components/account/OAuthTokenStatus.js | 17 +++++++-- eslint.config.mjs | 19 ---------- pages/admin/[experiment_id].js | 37 +++++++++++++------ pages/admin/index.js | 50 ++++++++++++++++++++------ pages/oauth2/callback.js | 25 +++++++++++-- pages/oauth2/connect.js | 12 +++++-- pages/reset-password.js | 23 ++++++------ 9 files changed, 160 insertions(+), 79 deletions(-) diff --git a/components/Navbar.js b/components/Navbar.js index e5baf0d..ff728f7 100644 --- a/components/Navbar.js +++ b/components/Navbar.js @@ -1,5 +1,5 @@ import NextLink from "next/link"; -import { useContext } from "react"; +import { useContext, useSyncExternalStore } from "react"; import { UserContext } from "../lib/context"; import { Box, @@ -17,15 +17,34 @@ import { Menu } from "@chakra-ui/react"; import { auth } from "../lib/firebase"; import { Rubik } from "next/font/google"; -import { useState, useEffect } from "react"; const rubik = Rubik({ subsets: ["latin"] }); +// `user` comes from an auth listener, so it is null in the server-rendered +// HTML and may be populated by the time the client hydrates -- rendering it +// straight away is a hydration mismatch. The fix is to render the server's +// answer on the hydration pass and the client's answer after, which is what +// useSyncExternalStore's third argument (getServerSnapshot) is for. This +// replaces a setState-in-effect "mounted" flag that did the same thing by +// scheduling an extra render. +// +// The store never changes, so subscribe is a no-op returning a no-op +// unsubscribe; the false -> true transition comes from React switching off +// getServerSnapshot once hydration completes. +const subscribeToNothing = () => () => {}; + +function useHydrated() { + return useSyncExternalStore( + subscribeToNothing, + () => true, + () => false + ); +} + export default function Navbar() { const { user } = useContext(UserContext); - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - const showUser = mounted ? user : null; + const hydrated = useHydrated(); + const showUser = hydrated ? user : null; return ( <Box as="nav" flexShrink={0}> diff --git a/components/account/ChangePassword.js b/components/account/ChangePassword.js index 93ee45b..5065ea8 100644 --- a/components/account/ChangePassword.js +++ b/components/account/ChangePassword.js @@ -1,4 +1,4 @@ -import { useState, useContext, useEffect } from "react"; +import { useState, useContext } from "react"; import { UserContext } from "../../lib/context"; import { @@ -20,25 +20,16 @@ export default function ChangePassword() { const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordMatch, setPasswordMatch] = useState(true); - const [passwordLengthSatisfied, setPasswordLengthSatisfied] = useState(true); const [submitStatus, setSubmitStatus] = useState(null); // "success" | "failure" | null - useEffect(() => { - if (password !== confirmPassword) { - setPasswordMatch(false); - } else { - setPasswordMatch(true); - } - }, [password, confirmPassword]); - - useEffect(() => { - if (password.length < 12) { - setPasswordLengthSatisfied(false); - } else { - setPasswordLengthSatisfied(true); - } - }, [password]); + // Derived during render, not mirrored into state by an effect. Both are pure + // functions of the two fields above, so storing them separately only created + // a window -- the render between a keystroke and the effect that followed it + // -- where the validation message on screen disagreed with the input beside + // it. On first open that window was visible: passwordLengthSatisfied was + // initialised true, so an empty field rendered as valid until the effect ran. + const passwordMatch = password === confirmPassword; + const passwordLengthSatisfied = password.length >= 12; return ( <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> diff --git a/components/account/OAuthTokenStatus.js b/components/account/OAuthTokenStatus.js index d54863c..0fd79cd 100644 --- a/components/account/OAuthTokenStatus.js +++ b/components/account/OAuthTokenStatus.js @@ -1,4 +1,4 @@ -import { useContext } from "react"; +import { useContext, useState } from "react"; import { UserContext } from "../../lib/context"; import { useDocumentData } from "react-firebase-hooks/firestore"; import { doc } from "firebase/firestore"; @@ -17,6 +17,18 @@ import { CircleCheck, TriangleAlert } from "lucide-react"; export default function OAuthTokenStatus() { const { user } = useContext(UserContext); + // Read once, at mount, instead of calling Date.now() in the render body. + // The clock is external mutable state: reading it during render makes this + // component impure, so two renders with identical props could disagree, and + // in a concurrent render React is free to discard and retry the work. A + // fixed instant makes the comparison below stable and reproducible. + // + // The trade-off, accepted deliberately: a page left open across the exact + // moment the refresh token expires keeps showing "Connected" until something + // re-mounts it. That is a coarse, day-scale badge -- a ticking clock to + // catch the boundary would be more machinery than the signal is worth. + const [mountedAt] = useState(() => Date.now()); + const [data, loading, error] = useDocumentData( user?.uid ? doc(db, "users", user.uid) : null ); @@ -34,7 +46,8 @@ export default function OAuthTokenStatus() { ); } - const isRefreshTokenExpired = data.refreshTokenExpires && Date.now() > data.refreshTokenExpires; + const isRefreshTokenExpired = + data.refreshTokenExpires && mountedAt > data.refreshTokenExpires; const getStatusIcon = () => { diff --git a/eslint.config.mjs b/eslint.config.mjs index ed08757..e519777 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,25 +34,6 @@ export default [ { rules: { "react/no-unescaped-entities": "off", - - // Downgraded from the error they default to, DELIBERATELY and - // temporarily. Both are new in eslint-plugin-react-hooks v7, which - // arrived with eslint-config-next@16 -- they did not exist under the - // config this file replaces. Restoring a working `npm run lint` should - // not also silently adopt a stricter standard than the project has ever - // been held to and fail on seven findings nobody has looked at. - // - // They are real findings, not noise, and they are worth fixing: - // set-state-in-effect components/Navbar.js:27 - // components/account/ChangePassword.js:29,37 - // pages/admin/[experiment_id].js:63 - // pages/admin/index.js:43 - // pages/reset-password.js:29 - // purity components/account/OAuthTokenStatus.js:37 - // Each needs a behavioral look at the effect in question, so they belong - // in their own change. Promote these back to "error" once they are. - "react-hooks/set-state-in-effect": "warn", - "react-hooks/purity": "warn", }, }, ]; diff --git a/pages/admin/[experiment_id].js b/pages/admin/[experiment_id].js index 9aed7be..a8a8ab5 100644 --- a/pages/admin/[experiment_id].js +++ b/pages/admin/[experiment_id].js @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import AuthCheck from "../../components/AuthCheck"; import { useRouter } from "next/router"; import { useDocumentData, useCollectionData } from "react-firebase-hooks/firestore"; @@ -53,19 +53,36 @@ function ExperimentPageDashboard({ experiment_id }) { const uploadError = logs?.logError; const errorLog = logs?.errors; - // Track resolved state: show success notice when queue goes from non-empty to empty + // Track resolved state: show the success notice when the queue goes from + // non-empty to empty. + // + // The comparison happens DURING RENDER -- React's documented way to adjust + // state in response to a changed value -- rather than in an effect that + // called setState, which forced a second render pass for every change in + // queue length. React re-runs this component immediately on the setState + // below, before touching the DOM, so no intermediate state is ever painted. + // + // This also fixes a latent bug in the effect version: it returned the timer + // cleanup BEFORE recording the new count, so prevQueueCount kept its stale + // non-zero value after a transition fired. const [showResolved, setShowResolved] = useState(false); - const prevQueueCount = useRef(0); + const [prevQueueCount, setPrevQueueCount] = useState(queueEntries.length); - useEffect(() => { - const currentCount = queueEntries.length; - if (prevQueueCount.current > 0 && currentCount === 0) { + if (prevQueueCount !== queueEntries.length) { + setPrevQueueCount(queueEntries.length); + if (prevQueueCount > 0 && queueEntries.length === 0) { setShowResolved(true); - const timer = setTimeout(() => setShowResolved(false), 8000); - return () => clearTimeout(timer); } - prevQueueCount.current = currentCount; - }, [queueEntries.length]); + } + + // Auto-hide, keyed off the flag rather than off the queue length, so the + // 8-second window starts when the notice appears and is cancelled if it is + // dismissed early by another transition. + useEffect(() => { + if (!showResolved) return undefined; + const timer = setTimeout(() => setShowResolved(false), 8000); + return () => clearTimeout(timer); + }, [showResolved]); return ( <> diff --git a/pages/admin/index.js b/pages/admin/index.js index 5930b66..aee4cc8 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -2,7 +2,7 @@ import AuthCheck from "../../components/AuthCheck"; import { collection, query, where, doc, deleteDoc } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; import { useCollectionData, useDocumentData } from "react-firebase-hooks/firestore"; -import { useRef, useState, useEffect } from "react"; +import { useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { Heading, @@ -34,16 +34,47 @@ export default function AdminPage({}) { ); } +// localStorage is external mutable state that does not exist during SSR, so +// it cannot be read in the render body or in a useState initializer. It used +// to be read in an effect that then called setState, which meant the banner's +// dismissed flag was wrong for one render: it defaulted to `true`, so a +// researcher who had NOT dismissed it got no banner until the effect ran and +// re-rendered. +// +// useSyncExternalStore reads the value during render on the client and falls +// back to getServerSnapshot on the server, with no intermediate wrong state. +// The listener set makes the banner disappear the instant it is dismissed -- +// a plain read would not re-render, since writing to localStorage is invisible +// to React. +const DISMISS_KEY = "datapipe-oauth-banner-dismissed"; +const dismissListeners = new Set(); + +function subscribeToDismissal(onStoreChange) { + dismissListeners.add(onStoreChange); + return () => dismissListeners.delete(onStoreChange); +} + +function dismissBanner() { + localStorage.setItem(DISMISS_KEY, "true"); + for (const listener of dismissListeners) listener(); +} + +// Booleans compare by value, so returning a fresh one each call is safe here; +// useSyncExternalStore only loops on a getSnapshot that returns a new OBJECT +// identity every time. +const getDismissed = () => localStorage.getItem(DISMISS_KEY) === "true"; +// Server-rendered markup omits the banner. Rendering it and then pulling it +// away from someone who had already dismissed it is the worse of the two. +const getDismissedOnServer = () => true; + function OAuthBanner() { const user = auth.currentUser; const [userData] = useDocumentData(doc(db, "users", user.uid)); - const [dismissed, setDismissed] = useState(true); - - useEffect(() => { - setDismissed( - localStorage.getItem("datapipe-oauth-banner-dismissed") === "true" - ); - }, []); + const dismissed = useSyncExternalStore( + subscribeToDismissal, + getDismissed, + getDismissedOnServer + ); const hasOAuthConnection = userData?.refreshToken && userData?.authToken; @@ -52,8 +83,7 @@ function OAuthBanner() { } const handleDismiss = () => { - localStorage.setItem("datapipe-oauth-banner-dismissed", "true"); - setDismissed(true); + dismissBanner(); }; return ( diff --git a/pages/oauth2/callback.js b/pages/oauth2/callback.js index 7e45123..fe1a5e0 100644 --- a/pages/oauth2/callback.js +++ b/pages/oauth2/callback.js @@ -25,6 +25,7 @@ function callbackReducer(state, action) { function useOAuthCallback() { const { user } = useContext(UserContext); const router = useRouter(); + const { push } = router; const searchParams = useSearchParams(); const [state, dispatch] = useReducer(callbackReducer, initialState); const processingRef = useRef(false); @@ -114,11 +115,11 @@ function useOAuthCallback() { localStorage.removeItem('osfEntryUserId'); localStorage.removeItem('osfEntryComponentId'); const entryQuery = entryParams.toString(); - router.push('/osf-entry' + (entryQuery ? '?' + entryQuery : '')); + push('/osf-entry' + (entryQuery ? '?' + entryQuery : '')); } else { localStorage.removeItem('osfAuthFlow'); const redirectPath = json.customToken ? '/admin' : (process.env.NEXT_PUBLIC_OAUTH_FINAL || '/admin'); - router.push(redirectPath); + push(redirectPath); } } else { throw new Error('OAuth authentication failed'); @@ -131,7 +132,25 @@ function useOAuthCallback() { }; processCallback(); - }, [searchParams, user?.uid, router]); + // `user?.uid` and `push`, deliberately narrower than what this effect + // closes over, so the disable below is a decision rather than an + // oversight. + // + // The rule wants `user` and `router` in full. Both have identities nothing + // guarantees is stable -- useAuthState hands back a fresh User object on + // every token refresh, and useRouter is free to return a new object per + // render -- while this effect performs a one-time OAuth code exchange and + // dispatches on completion. Listing either means every render re-runs the + // exchange and schedules the next render: an unbounded loop. (The sibling + // flow in connect.js hangs its test suite outright when `router` is + // listed, which is how this was caught.) + // + // The narrow deps are also the semantically right ones: the effect must + // re-run when WHICH user is signed in changes, which is what `uid` + // expresses. Later identity churn on the same account is not a reason to + // redo an OAuth exchange. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, user?.uid, push]); return state; } diff --git a/pages/oauth2/connect.js b/pages/oauth2/connect.js index 690811a..b692d45 100644 --- a/pages/oauth2/connect.js +++ b/pages/oauth2/connect.js @@ -30,6 +30,7 @@ function connectReducer(state, action) { function useProviderConnectCallback() { const { user } = useContext(UserContext); const router = useRouter(); + const { push } = router; const [state, dispatch] = useReducer(connectReducer, initialState); const processingRef = useRef(false); @@ -93,7 +94,7 @@ function useProviderConnectCallback() { localStorage.removeItem("latestCSRFToken"); localStorage.removeItem("providerConnectFlow"); - router.push("/admin/account"); + push("/admin/account"); } catch (err) { console.error("Provider connect callback error:", err); dispatch({ type: "ERROR", error: err.message }); @@ -102,7 +103,14 @@ function useProviderConnectCallback() { }; processCallback(); - }, [urlCode, urlState, urlError, user?.uid]); + // `push`, NOT the whole `router`. The effect reads nothing else off it -- + // the query parameters are destructured into urlCode/urlState/urlError + // above and listed here individually -- and depending on the router object + // means depending on an identity nothing guarantees is stable. Where it is + // not, this effect re-runs on every render, and since it dispatches, every + // run schedules the next one: an unbounded loop that performs the OAuth + // token exchange over and over. + }, [urlCode, urlState, urlError, user?.uid, push]); return state; } diff --git a/pages/reset-password.js b/pages/reset-password.js index 59dc2c4..cbecfa3 100644 --- a/pages/reset-password.js +++ b/pages/reset-password.js @@ -11,32 +11,35 @@ import { import { auth } from "../lib/firebase"; import { sendPasswordResetEmail, confirmPasswordReset } from "firebase/auth"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/router"; import { getError } from "../lib/utils"; export default function ResetPassword() { const router = useRouter(); - const [state, setState] = useState("forgot"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); - const [token, setToken] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + // Only the transition this page drives itself: "send", after a reset email + // goes out. null means "whatever the URL says". + const [submittedState, setSubmittedState] = useState(null); - useEffect(() => { - if (router.query?.token) { - setState("token"); - setToken(router?.query?.token); - } - }, [router, router.query]); + // Derived from the URL rather than copied into state by an effect. The old + // version rendered the "forgot password" form first and swapped to the + // token form once the effect ran, so a user arriving from a reset link saw + // the wrong form flash by -- and on Next's first client render, where + // router.query is still empty, it was the only form they saw until the + // query populated. + const token = typeof router.query?.token === "string" ? router.query.token : ""; + const state = submittedState ?? (token ? "token" : "forgot"); const resetPassword = async () => { setIsSubmitting(true); try { await sendPasswordResetEmail(auth, email); setIsSubmitting(false); - setState("send"); + setSubmittedState("send"); } catch (error) { setIsSubmitting(false); setError(getError(error.code)); From a391cb139b2611285f129a64eacc08bb1cd07128 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 11:07:51 -0400 Subject: [PATCH 071/181] feat: close OSF to new experiments OSF is shutting down its projects feature. New experiments can no longer be created against it; experiments already collecting are untouched and keep writing until the sunset date. The gate that matters is in firestore.rules, not the UI. OSF experiments were created browser-side -- lib/experiment-creation.js wrote the document with the client SDK -- so removing the option from pages/admin/new.js only hides it. `allow create` now requires a storageProvider that is not 'osf'. It also requires the field to be PRESENT: absent used to mean OSF by default (see getProviderForExperiment in functions/src/providers/index.ts), which would otherwise have left a second, quieter way onto OSF. Updates stay legacy-tolerant so in-flight studies remain editable. firestore.rules also carries a relaxation of isAccountCreation() here rather than in its own commit: the two changes are one file and one deploy unit. It stops requiring osfToken == '' when the field is absent, which a new account no longer has. Experiment creation is now server-side for every provider. pages/osf-entry.js and lib/osf-utils.js go with the browser-driven path -- the entry point only ever created NEW OSF experiments, so it is dead the moment the rule lands. The pinned OSF-form regression test is inverted rather than deleted, so a reintroduction is caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/firestore-rules.test.js | 111 ++++- __tests__/new-experiment-page.test.jsx | 56 ++- firestore.rules | 29 +- .../create-experiment-emulator.test.js | 7 +- functions/src/create-experiment.ts | 43 +- functions/src/providers/osf.ts | 9 +- lib/experiment-creation.js | 241 +--------- lib/osf-utils.js | 89 ---- pages/admin/new.js | 187 +------ pages/osf-entry.js | 455 ------------------ 10 files changed, 229 insertions(+), 998 deletions(-) delete mode 100644 lib/osf-utils.js delete mode 100644 pages/osf-entry.js diff --git a/__tests__/firestore-rules.test.js b/__tests__/firestore-rules.test.js index 00a97f1..b69171c 100644 --- a/__tests__/firestore-rules.test.js +++ b/__tests__/firestore-rules.test.js @@ -172,12 +172,18 @@ describe('/experiments — provider-migration generalization (step 7a)', () => { }; } - describe('1. legacy OSF experiment create (regression guard)', () => { - it('succeeds with all OSF fields present -- expected to PASS today and after generalization', async () => { - const docId = 'exp-7a-legacy-create-1'; + // NOTE: this block previously asserted that a legacy OSF-shaped CREATE + // succeeds. OSF is shutting down its projects feature, so that guarantee is + // now inverted -- creating an experiment against OSF must be impossible. + // The rule, not the UI, is what enforces it: OSF experiments were created + // browser-side with the client SDK, so removing the option from + // pages/admin/new.js only hid it. + describe('1. OSF is closed to new experiments', () => { + it('DENIES a create carrying the legacy OSF field trio and no storageProvider', async () => { + const docId = 'exp-osf-create-legacy-shape'; const user123 = testEnv.authenticatedContext('user123'); - await assertSucceeds(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + await assertFails(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ id: docId, owner: 'user123', osfRepo: 'abc12', @@ -186,18 +192,63 @@ describe('/experiments — provider-migration generalization (step 7a)', () => { }))); }); - it('fails when osfFilesLink is missing and no storageProvider is present -- pinned contract', async () => { - const docId = 'exp-7a-legacy-create-2'; + it('DENIES a create with an explicit storageProvider of osf', async () => { + const docId = 'exp-osf-create-explicit'; const user123 = testEnv.authenticatedContext('user123'); await assertFails(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ id: docId, owner: 'user123', - osfRepo: 'abc12', - osfComponent: 'def34', - // osfFilesLink deliberately omitted; no storageProvider either. + storageProvider: 'osf', + providerContainer: { provider: 'osf', filesLink: 'https://files.osf.io/v1/x/' }, }))); }); + + it('DENIES a create with NO storageProvider at all -- absent used to mean OSF', async () => { + // getProviderForExperiment in functions/src/providers/index.ts treats a + // missing storageProvider as OSF, so leaving that branch creatable + // would be a second, quieter way onto OSF. + const docId = 'exp-osf-create-absent'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + id: docId, + owner: 'user123', + }))); + }); + + it('ALLOWS a create against a non-OSF provider', async () => { + const docId = 'exp-gdrive-create'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertSucceeds(setDoc(doc(user123.firestore(), `experiments/${docId}`), baseFields({ + id: docId, + owner: 'user123', + storageProvider: 'gdrive', + providerContainer: { provider: 'gdrive', folderId: 'folder-abc' }, + }))); + }); + + it('still ALLOWS the owner to update an EXISTING legacy OSF experiment', async () => { + // This is the whole point of closing creates rather than deleting the + // OSF path: studies already collecting must keep working through the + // wind-down, which means their documents stay editable. + const docId = 'exp-osf-update-existing'; + await seedDB({ + [`experiments/${docId}`]: baseFields({ + id: docId, + owner: 'user123', + osfRepo: 'abc12', + osfComponent: 'def34', + osfFilesLink: 'https://files.osf.io/v1/resources/abc12/providers/osfstorage/', + }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertSucceeds( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { active: true }) + ); + }); }); describe('2. gdrive-shaped experiment update by owner', () => { @@ -259,12 +310,52 @@ describe('/experiments — provider-migration generalization (step 7a)', () => { it('rejects account-creation writes that include connectedAccounts -- clients can never write it', async () => { const user123 = testEnv.authenticatedContext('user123'); + // uid/email/experiments are all present, so connectedAccounts is the + // ONLY reason this is denied. await assertFails(setDoc(doc(user123.firestore(), 'users/user123'), { + uid: 'user123', email: 'john@doe.com', experiments: ['exp1'], - osfToken: '', connectedAccounts: { gdrive: { authMethod: 'oauth2' } }, })); }); }); + + describe('5. slim account creation (federated sign-in)', () => { + // Federated sign-in (Google/ORCID/GitHub) goes through neither the old + // signup form nor the OSF callback, so ensureUserDocument in + // lib/user-bootstrap.js writes just { uid, email, experiments }. The rule + // used to require osfToken == '' unconditionally, which rejected exactly + // this shape because the field is absent rather than empty. + it('ALLOWS the slim { uid, email, experiments } shape with no OSF fields', async () => { + const user123 = testEnv.authenticatedContext('user123'); + + await assertSucceeds(setDoc(doc(user123.firestore(), 'users/user123'), { + uid: 'user123', + email: 'researcher@example.edu', + experiments: [], + })); + }); + + it('ALLOWS an empty email -- ORCID users often have no address to record', async () => { + const user789 = testEnv.authenticatedContext('user789'); + + await assertSucceeds(setDoc(doc(user789.firestore(), 'users/user789'), { + uid: 'user789', + email: '', + experiments: [], + })); + }); + + it('still DENIES a non-empty osfToken when the field IS present', async () => { + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails(setDoc(doc(user123.firestore(), 'users/user123'), { + uid: 'user123', + email: 'researcher@example.edu', + experiments: [], + osfToken: 'a-real-looking-token', + })); + }); + }); }); \ No newline at end of file diff --git a/__tests__/new-experiment-page.test.jsx b/__tests__/new-experiment-page.test.jsx index 2936f65..5f63061 100644 --- a/__tests__/new-experiment-page.test.jsx +++ b/__tests__/new-experiment-page.test.jsx @@ -19,25 +19,12 @@ jest.mock("../lib/context", () => ({ }), })); -// lib/experiment-creation.js (imported transitively by pages/admin/new.js) -// pulls in `nanoid`, which ships ESM-only and isn't transformed by Jest by -// default (`Cannot use import statement outside a module`). Mock it out -// rather than touching jest.config.js's transformIgnorePatterns. -jest.mock("nanoid", () => ({ - customAlphabet: () => () => "mocked-id", -})); - -// firebase/firestore's `doc` (and friends used transitively by -// lib/experiment-creation.js) must not touch a real Firestore instance. +// firebase/firestore's `doc` must not touch a real Firestore instance. The +// batch-write mocks that used to sit here (writeBatch/arrayUnion/setDoc) went +// with the client-side OSF creation path -- experiment documents are now +// written server-side by /api/createexperiment for every provider. jest.mock("firebase/firestore", () => ({ doc: jest.fn(() => ({})), - writeBatch: jest.fn(() => ({ - set: jest.fn(), - update: jest.fn(), - commit: jest.fn(() => Promise.resolve()), - })), - arrayUnion: jest.fn((v) => v), - setDoc: jest.fn(() => Promise.resolve()), })); // The page navigates via the `Router` singleton default export (see @@ -79,8 +66,12 @@ beforeEach(() => { global.fetch = jest.fn(); }); -describe("NewExperimentPage — OSF path (pinned regression)", () => { - it("2. default render shows the OSF form exactly as today", () => { +// This block used to pin the OSF form as a regression guard. OSF is shutting +// down its projects feature, so the guarantee is now the opposite one: OSF +// must be unreachable from this page entirely. The assertions are inverted +// rather than deleted so a reintroduction gets caught. +describe("NewExperimentPage — OSF is closed to new experiments", () => { + it("2. offers no OSF option and no OSF form, even for a researcher with a live OSF connection", () => { useDocumentData.mockReturnValue([ { refreshToken: "osf-refresh-token", usingPersonalToken: false }, false, @@ -89,12 +80,29 @@ describe("NewExperimentPage — OSF path (pinned regression)", () => { renderPage(); - expect(screen.getByText("Existing OSF Project")).toBeInTheDocument(); + expect(screen.queryByLabelText(/^OSF$/i)).not.toBeInTheDocument(); + expect(screen.queryByText("Existing OSF Project")).not.toBeInTheDocument(); expect( - screen.getByText("New OSF Data Component Name") - ).toBeInTheDocument(); - expect(screen.getByText("Storage Location")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument(); + screen.queryByText("New OSF Data Component Name") + ).not.toBeInTheDocument(); + // The region picker was OSF-specific (its four options were OSF storage + // regions), so it goes with the form. + expect(screen.queryByText("Storage Location")).not.toBeInTheDocument(); + }); + + it("2b. defaults to the first registered storage provider instead of OSF", () => { + useDocumentData.mockReturnValue([ + { connectedAccounts: { gdrive: true } }, + false, + undefined, + ]); + + renderPage(); + + // gdrive is connected, so a gdrive default renders the create form. If + // the page still defaulted to OSF this would show a connect CTA instead. + expect(screen.getByLabelText(/^Title$/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Create$/i })).toBeInTheDocument(); }); }); diff --git a/firestore.rules b/firestore.rules index aebdb59..313a31b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,9 +2,16 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /users/{userId} { + // New accounts (federated or email/password) write only the slim + // { uid, email, experiments } shape -- see ensureUserDocument in + // lib/user-bootstrap.js. The OSF-era keys stay on the whitelist so + // existing documents remain writable, but they are no longer required: + // the osfToken == '' assertion below only applies when the field is + // actually present, because a new account never has one. function isAccountCreation() { return request.resource.data.keys().hasOnly(['email', 'experiments', 'osfToken', 'osfTokenValid', 'uid', 'usingPersonalToken', 'refreshToken', 'refreshTokenExpires', 'authToken', 'authTokenExpires', 'osfUserId', 'displayName', 'authMethod', 'createdAt']) - && request.resource.data.osfToken == ''; + && request.resource.data.keys().hasAll(['uid', 'email', 'experiments']) + && (!('osfToken' in request.resource.data) || request.resource.data.osfToken == ''); } function isTokenMethodUpdate() { return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['usingPersonalToken']); @@ -20,16 +27,34 @@ service cloud.firestore { function baseFields() { return request.resource.data.keys().hasAll(['active', 'activeBase64', 'activeConditionAssignment', 'id', 'owner', 'title', 'sessions', 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', 'requiredFields', 'maxSessions', 'limitSessions']) } + // UPDATE shape: legacy-tolerant. An experiment created before the + // provider-migration schema carries the OSF triple and no + // storageProvider, and must stay editable through the OSF wind-down. function verifyFields() { return baseFields() && (('storageProvider' in request.resource.data) ? request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']) : request.resource.data.keys().hasAll(['osfRepo', 'osfComponent', 'osfFilesLink'])); } + // CREATE shape: strictly non-OSF. OSF is shutting down its projects + // feature, so no new experiment may be pointed at it. + // + // This rule -- not the UI -- is the gate that matters. OSF experiment + // creation was browser-driven: lib/experiment-creation.js wrote this + // document with the client SDK, so removing the option from + // pages/admin/new.js only hides it. Requiring storageProvider on create + // also closes the legacy no-provider branch above, which meant OSF by + // default (see getProviderForExperiment in + // functions/src/providers/index.ts). + function isCreatableProvider() { + return ('storageProvider' in request.resource.data) && + request.resource.data.storageProvider != 'osf' && + request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']); + } allow read: if(request.auth.uid != null) && resource.data.owner == request.auth.uid; allow create: if(request.auth.uid != null) && - verifyFields() && + baseFields() && isCreatableProvider() && request.resource.data.owner == request.auth.uid; allow update: if(request.auth.uid == resource.data.owner) && verifyFields() && diff --git a/functions/src/__tests__/create-experiment-emulator.test.js b/functions/src/__tests__/create-experiment-emulator.test.js index 5d54b8d..8ca1857 100644 --- a/functions/src/__tests__/create-experiment-emulator.test.js +++ b/functions/src/__tests__/create-experiment-emulator.test.js @@ -253,13 +253,14 @@ describe("5. createExperiment happy path (gdrive)", () => { expect(expDoc.exists).toBe(true); const expData = expDoc.data(); - // Exact default-field parity with createExperimentDocument in - // lib/experiment-creation.js (verified by reading that file): title, + // Exact default-field parity with the retired client-side OSF creation + // path, so documents written before and after it was removed agree: title, // active:false, activeBase64:false, activeConditionAssignment:false, // sessions:0, id, owner, nConditions:1, currentCondition:0, // useValidation:true, allowJSON:true, allowCSV:true, // requiredFields:["trial_type"] (NOT [] -- the client hardcodes - // ["trial_type"], it is not a parameterized default), limitSessions:false, + // ["trial_type"], it was hardcoded there, not a parameterized default), + // limitSessions:false, // maxSessions:1 -- PLUS storageProvider/providerContainer instead of // osfRepo/osfComponent/osfFilesLink. expect(expData).toEqual({ diff --git a/functions/src/create-experiment.ts b/functions/src/create-experiment.ts index efbb3f9..4b017c8 100644 --- a/functions/src/create-experiment.ts +++ b/functions/src/create-experiment.ts @@ -1,20 +1,22 @@ -// Server-side experiment creation for non-OSF storage providers +// Server-side experiment creation // (scratchpad/step7a-create-endpoint-spec.md, docs/provider-migration-design.md). // -// OSF experiment creation stays entirely browser-driven (see -// lib/experiment-creation.js) -- the browser calls the OSF API directly and -// batch-writes Firestore with a Firebase client SDK, which is fine because -// the OSF token flow already lives client-side. New providers (starting with -// gdrive) need a server-side path instead: createDataContainer is -// server-only (it needs the decrypted, possibly-refreshed provider token -// that only resolve-token.ts can produce), and the resulting container ref +// This is now the ONLY way an experiment gets created. It used to be one of +// two: OSF experiments were built in the browser (which was viable only +// because the OSF token flow lived client-side), and everything else came +// through here. OSF is shutting down its projects feature, so that path is +// gone along with the option to create an OSF experiment at all -- see the +// provider check below, backed by firestore.rules. +// +// A server-side path is what the design wanted regardless: +// createDataContainer needs the decrypted, possibly-refreshed provider token +// that only resolve-token.ts can produce, and the resulting container ref // must be folded into the experiment doc atomically with its creation. // -// This endpoint intentionally mirrors createExperimentDocument in -// lib/experiment-creation.js field-for-field (including the -// requiredFields: ["trial_type"] default, which the client hardcodes rather -// than parameterizes) so that gdrive- and OSF-created experiment docs stay -// uniform for every other consumer (api-data.ts, the dashboard, etc.). +// The field defaults below (including requiredFields: ["trial_type"]) are the +// ones the removed client-side path also wrote, so documents created before +// and after the change stay uniform for every other consumer (api-data.ts, +// the dashboard, etc.). import { onRequest } from "firebase-functions/v2/https"; import { FieldValue } from "firebase-admin/firestore"; @@ -27,10 +29,9 @@ import { ContainerRef, StorageProviderId, ResolvedAuth } from "./providers/types import { ExperimentData, UserData } from "./interfaces.js"; import MESSAGES from "./api-messages.js"; -// Same alphabet/length as lib/experiment-creation.js's -// customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12) -// -- experiment ids must stay uniform whether created client-side (OSF) or -// server-side (gdrive and later providers). +// Same alphabet/length the removed client-side OSF path used, so experiment +// ids stay uniform across everything created before and after that path was +// retired. const generateExperimentId = customAlphabet( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12 @@ -97,8 +98,12 @@ export const createExperiment = onRequest({ cors: true }, async (req, res) => { return; } - // OSF creation stays browser-driven; only registered NON-osf providers - // may be created through this endpoint. + // OSF is closed to new experiments -- it is shutting down its projects + // feature. The osfProvider adapter stays registered (in-flight + // experiments still write through it) and its createDataContainer throws + // "not implemented", but this rejects the request up front rather than + // relying on that. firestore.rules enforces the same thing on the + // document itself. if (provider === "osf" || !listProviders().includes(provider as StorageProviderId)) { res.status(400).json({ error: "Unsupported provider" }); return; diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index 75a6781..cc5cb20 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -55,9 +55,12 @@ export const osfProvider: StorageProvider = { quotaNote: null, }, - // OSF creation stays entirely browser-driven (see lib/experiment-creation.js) - // and its createDataContainer below throws "not implemented" -- there is no - // researcher input for create-experiment to collect or validate. + // Empty because OSF is closed to NEW experiments -- create-experiment.ts + // rejects it outright and firestore.rules refuses the document -- so + // createDataContainer below is unreachable and throws "not implemented". + // Nothing collects or validates researcher input for OSF any more. The rest + // of this adapter is very much alive: it serves every experiment that was + // already collecting when OSF was closed off. containerInput: [], async resolveToken(user_data: UserData, owner: string): Promise<TokenResult> { diff --git a/lib/experiment-creation.js b/lib/experiment-creation.js index a4700a4..6ae2987 100644 --- a/lib/experiment-creation.js +++ b/lib/experiment-creation.js @@ -1,166 +1,20 @@ -import { customAlphabet } from "nanoid"; -import { doc, writeBatch, arrayUnion } from "firebase/firestore"; -import { db, auth } from "./firebase"; - -export async function getUserOsfToken(user) { - const idToken = await user.getIdToken(); - const response = await fetch("/api/getosftoken", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${idToken}`, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - - if (response.status === 400) { - try { - const errorData = JSON.parse(errorText); - if (errorData.details && errorData.details.includes("invalid_request")) { - throw new Error( - "Your OSF authentication has expired. Please sign out of DataPipe and sign in again with OSF." - ); - } - } catch (parseError) { - if (parseError instanceof SyntaxError) { - // JSON parse failed — fall through to generic message - } else { - throw parseError; - } - } - throw new Error( - "Your OSF authentication has expired. Please sign out of DataPipe and sign in again with OSF." - ); - } - - throw new Error(`Failed to get OSF token: ${response.status}`); - } - - const data = await response.json(); - return data.token || null; -} - -export async function createOsfChildComponent(osfToken, osfRepo, osfComponentName, region = 'us') { - // Clean OSF repo URL if needed (handles both production and test environments) - osfRepo = osfRepo.replace(/^https?:\/\/(?:test\.)?osf\.io\//, ""); - - - const osfResult = await fetch( - `https://api.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/v2/nodes/${osfRepo}/children/?region=${region}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - }, - body: JSON.stringify({ - data: { - type: "nodes", - attributes: { - title: osfComponentName, - category: "data", - description: - "This node was automatically generated by DataPipe (https://pipe.jspsych.org/)", - }, - }, - }), - } - ); - - if (!osfResult.ok) { - const errorText = await osfResult.text(); - throw new Error(`Failed to create OSF component: ${osfResult.status}`); - } - - const nodeData = await osfResult.json(); - - if (nodeData.errors) { - throw new Error(nodeData.errors[0]?.detail || 'OSF API error'); - } - - const filesLink = nodeData.data.relationships.files.links.related.href; - - const filesResult = await fetch(filesLink, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${osfToken}`, - }, - }); - - if (!filesResult.ok) { - const errorText = await filesResult.text(); - throw new Error(`Failed to get OSF upload link: ${filesResult.status}`); - } - - const filesData = await filesResult.json(); - const uploadLink = filesData.data[0].links.upload; - - return { - componentId: nodeData.data.id, - uploadLink: uploadLink, - osfRepo: osfRepo - }; -} - -export async function createExperimentDocument(experimentData) { - const { - id, - title, - osfRepo, - osfComponent, - osfFilesLink, - owner, - nConditions = 1, - useValidation = true, - allowJSON = true, - allowCSV = true, - limitSessions = false, - maxSessions = 1 - } = experimentData; - - const batch = writeBatch(db); - - const experimentDoc = doc(db, "experiments", id); - batch.set(experimentDoc, { - title: title, - osfRepo: osfRepo, - osfComponent: osfComponent, - osfFilesLink: osfFilesLink, - active: false, - activeBase64: false, - activeConditionAssignment: false, - sessions: 0, - limitSessions: limitSessions, - maxSessions: maxSessions, - id: id, - owner: owner, - nConditions: nConditions, - currentCondition: 0, - useValidation: useValidation, - allowJSON: allowJSON, - allowCSV: allowCSV, - requiredFields: ["trial_type"], - }); - - const userDoc = doc(db, `users/${owner}`); - batch.update(userDoc, { - experiments: arrayUnion(id), - }); - - await batch.commit(); - - return id; -} - -// Non-OSF providers (gdrive, and later figshare/dataverse) are created -// server-side via /api/createexperiment (see functions/src/create-experiment.ts) -// rather than the browser-driven OSF path above -- the server needs the -// decrypted provider token that only resolve-token.ts can produce. This -// helper just calls that endpoint and normalizes the response shape to -// match createExperiment()'s { experimentId } contract. +import { auth } from "./firebase"; + +// Experiment creation is now entirely server-side, for every provider. +// +// It used to be split: OSF experiments were built in the browser (fetch the +// researcher's OSF token, POST a child component to the OSF API, then +// batch-write Firestore with the client SDK) while every other provider went +// through /api/createexperiment. That OSF path is gone -- OSF is shutting +// down its projects feature and firestore.rules now refuses to create an +// experiment whose storageProvider is 'osf' (or absent, which meant OSF by +// default). Existing OSF experiments are untouched and keep collecting; see +// lib/osf-sunset.js. +// +// The server needs to own creation anyway: createDataContainer requires the +// decrypted, possibly-refreshed provider token that only resolve-token.ts can +// produce, and the resulting container ref must land in the experiment +// document atomically with its creation. export async function createProviderExperiment(provider, title, parentFolderId, researcherInput) { const user = auth.currentUser; if (!user) { @@ -196,64 +50,3 @@ export async function createProviderExperiment(provider, title, parentFolderId, experimentId: data.experimentID, }; } - -export async function createExperiment(params) { - const { - title, - osfRepo, - osfComponentName, - region = 'us', - uid, - nConditions = 1, - useValidation = true, - allowJSON = true, - allowCSV = true, - useSessionLimit = false, - maxSessions = 1 - } = params; - - // Generate experiment ID - const nanoid = customAlphabet( - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", - 12 - ); - const id = nanoid(); - - // Get current user - const user = auth.currentUser; - if (!user) { - throw new Error('User not authenticated'); - } - - // Get OSF token - const osfToken = await getUserOsfToken(user); - if (!osfToken) { - throw new Error('No valid OSF token found'); - } - - // Create OSF child component - const osfData = await createOsfChildComponent(osfToken, osfRepo, osfComponentName, region); - - // Create experiment document - const experimentId = await createExperimentDocument({ - id: id, - title: title, - osfRepo: osfData.osfRepo, - osfComponent: osfData.componentId, - osfFilesLink: osfData.uploadLink, - owner: uid, - nConditions: nConditions, - useValidation: useValidation, - allowJSON: allowJSON, - allowCSV: allowCSV, - limitSessions: useSessionLimit, - maxSessions: maxSessions - }); - - return { - experimentId: experimentId, - title: title, - osfComponent: osfData.componentId, - osfProject: osfData.osfRepo - }; -} \ No newline at end of file diff --git a/lib/osf-utils.js b/lib/osf-utils.js deleted file mode 100644 index 335fb4b..0000000 --- a/lib/osf-utils.js +++ /dev/null @@ -1,89 +0,0 @@ -export async function validateOsfAccess(osfToken, componentId) { - try { - const response = await fetch(`https://api.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/v2/nodes/${componentId}/`, { - headers: { - 'Authorization': `Bearer ${osfToken}`, - 'Accept': 'application/vnd.api+json' - } - }); - - if (!response.ok) { - const errorText = await response.text(); - - if (response.status === 403) { - throw new Error('You do not have access to this OSF project.'); - } else if (response.status === 404) { - throw new Error('OSF project not found.'); - } else { - throw new Error(`Failed to validate OSF project access: ${response.status}`); - } - } - - return true; - } catch (error) { - throw error; - } -} - -export async function getOsfComponentInfo(osfToken, componentId) { - try { - const response = await fetch(`https://api.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/v2/nodes/${componentId}/`, { - headers: { - 'Authorization': `Bearer ${osfToken}`, - 'Accept': 'application/vnd.api+json' - } - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch OSF component information: ${response.status}`); - } - - const data = await response.json(); - return { - id: data.data.id, - title: data.data.attributes.title, - description: data.data.attributes.description, - category: data.data.attributes.category, - region: data.data.relationships?.region?.data?.id || 'us' - }; - } catch (error) { - throw error; - } -} - -export function cleanOsfUrl(osfUrl) { - if (typeof osfUrl !== 'string') { - return osfUrl; - } - - // Remove OSF URL prefix if present (handles both production and test environments) - osfUrl = osfUrl.replace(/^https?:\/\/(?:test\.)?osf\.io\//, ""); - - // Remove trailing slash if present - if (osfUrl.endsWith("/")) { - osfUrl = osfUrl.slice(0, -1); - } - - return osfUrl; -} - -export function generateOsfComponentName(baseName) { - const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format - return `${baseName} - ${date}`; -} - -export async function checkOsfTokenValidity(osfToken) { - try { - const response = await fetch(`https://api.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/v2/users/me/`, { - headers: { - 'Authorization': `Bearer ${osfToken}`, - 'Accept': 'application/vnd.api+json' - } - }); - - return response.ok; - } catch (error) { - return false; - } -} \ No newline at end of file diff --git a/pages/admin/new.js b/pages/admin/new.js index 78c2002..83f6ff9 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -6,7 +6,7 @@ import { UserContext } from "../../lib/context"; import { useDocumentData } from "react-firebase-hooks/firestore"; import Link from "next/link"; import Router from "next/router"; -import { createExperiment, createProviderExperiment } from "../../lib/experiment-creation"; +import { createProviderExperiment } from "../../lib/experiment-creation"; import { pickDriveFolder } from "../../lib/google-picker"; import { STORAGE_PROVIDERS } from "../../lib/provider-config"; import { @@ -18,14 +18,18 @@ import { Input, Textarea, Spinner, - Group, - InputAddon, VStack, Text, - NativeSelect, Alert, } from "@chakra-ui/react"; +// OSF is deliberately absent from this form. It is shutting down its projects +// feature, so no new experiment may be created against it -- a rule enforced +// in firestore.rules (the experiments `allow create` requires a +// storageProvider that is not 'osf'), not merely by the options offered here. +// Existing OSF experiments keep collecting; see lib/osf-sunset.js. +const DEFAULT_PROVIDER = Object.keys(STORAGE_PROVIDERS)[0]; + export default function NewExperimentPage({}) { return ( <AuthCheck> @@ -36,17 +40,8 @@ export default function NewExperimentPage({}) { function NewExperimentForm() { const { user } = useContext(UserContext); - const [isSubmitting, setIsSubmitting] = useState(false); - const [osfError, setOsfError] = useState(false); - const [titleError, setTitleError] = useState(false); - const [dataComponentError, setDataComponentError] = useState(false); - - const [title, setTitle] = useState(""); - const [osfRepo, setOsfRepo] = useState(""); - const [osfComponentName, setOsfComponentName] = useState(""); - const [region, setRegion] = useState("us"); - const [provider, setProvider] = useState("osf"); + const [provider, setProvider] = useState(DEFAULT_PROVIDER); const [providerTitle, setProviderTitle] = useState(""); const [providerTitleError, setProviderTitleError] = useState(false); const [providerSubmitting, setProviderSubmitting] = useState(false); @@ -66,9 +61,8 @@ function NewExperimentForm() { // provider never carries over and is momentarily shown against another. const [providerWarnings, setProviderWarnings] = useState([]); - const [data, loading, error] = useDocumentData(doc(db, "users", user.uid)); + const [data, loading] = useDocumentData(doc(db, "users", user.uid)); - const isValid = data && (data.usingPersonalToken ? data.osfTokenValid : data.refreshToken !== ""); const providerConnected = STORAGE_PROVIDERS[provider]?.isConnected(data); const handleProviderChange = (newProvider) => { @@ -87,14 +81,13 @@ function NewExperimentForm() { }; // Fetch setup warnings for the selected provider: on every provider - // change, and on initial mount when a non-osf, already-connected provider - // is preselected. Never fires for osf (it has no StorageProvider adapter, - // hence no setupWarnings) and never fires while the provider is not yet - // connected (there is no resolvable token to check against). A failed - // fetch is silent (console only) -- this is advisory only and must never - // block or error the form. + // change, and on initial mount when an already-connected provider is + // preselected. Never fires while the provider is not yet connected (there + // is no resolvable token to check against). A failed fetch is silent + // (console only) -- this is advisory only and must never block or error + // the form. useEffect(() => { - if (provider === "osf" || !providerConnected) { + if (!providerConnected) { setProviderWarnings([]); return; } @@ -135,45 +128,6 @@ function NewExperimentForm() { setContainerFieldErrors((prev) => ({ ...prev, [name]: false })); }; - const handleSubmit = async () => { - setIsSubmitting(true); - setOsfError(false); - - if (title.length === 0) { - setTitleError(true); - setIsSubmitting(false); - return; - } - - if (osfComponentName.length === 0) { - setDataComponentError(true); - setIsSubmitting(false); - return; - } - - try { - const result = await createExperiment({ - title, - osfRepo, - osfComponentName, - region, - uid: auth.currentUser.uid, - nConditions: 1, - useValidation: true, - allowJSON: true, - allowCSV: true, - useSessionLimit: false, - maxSessions: 1, - }); - - Router.push(`/admin/${result.experimentId}`); - } catch (err) { - console.error(err); - setIsSubmitting(false); - setOsfError(true); - } - }; - const handleProviderSubmit = async () => { setProviderSubmitting(true); setProviderError(null); @@ -290,20 +244,6 @@ function NewExperimentForm() { <Field.Root> <Field.Label>Where should data be stored?</Field.Label> <HStack gap={6} mt={2} role="radiogroup" aria-label="Where should data be stored?"> - {/* OSF is deliberately hardcoded here and absent from - STORAGE_PROVIDERS -- it keeps its bespoke legacy UI (identity - OAuth flow, existing form below) rather than becoming a - generic provider option. */} - <HStack as="label" gap={2} cursor="pointer"> - <input - type="radio" - name="storage-provider" - value="osf" - checked={provider === "osf"} - onChange={() => handleProviderChange("osf")} - /> - <Text>OSF</Text> - </HStack> {Object.values(STORAGE_PROVIDERS).map((p) => ( <HStack as="label" gap={2} cursor="pointer" key={p.id}> <input @@ -319,98 +259,7 @@ function NewExperimentForm() { </HStack> </Field.Root> - {provider === "osf" && isValid && ( - <> - <Field.Root invalid={titleError}> - <Field.Label>Title</Field.Label> - <Input - type="text" - value={title} - onChange={(e) => { - setTitle(e.target.value); - setTitleError(false); - }} - /> - <Field.ErrorText color="red.400"> - This field is required - </Field.ErrorText> - </Field.Root> - <Field.Root invalid={osfError}> - <Field.Label>Existing OSF Project</Field.Label> - <Group attached> - <InputAddon bgColor={"greyBackground"}> - {`https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/`} - </InputAddon> - <Input - type="text" - value={osfRepo} - onChange={(e) => setOsfRepo(e.target.value)} - /> - </Group> - <Field.ErrorText color="red.400"> - Cannot connect to this OSF component - </Field.ErrorText> - </Field.Root> - <Field.Root invalid={dataComponentError}> - <Field.Label>New OSF Data Component Name</Field.Label> - <Input - type="text" - value={osfComponentName} - onChange={(e) => { - setOsfComponentName(e.target.value); - setDataComponentError(false); - }} - /> - <Field.ErrorText color="red.400"> - This field is required - </Field.ErrorText> - <Field.HelperText color="gray"> - DataPipe will create a new component with this name in the OSF - project and store all data in it. - </Field.HelperText> - </Field.Root> - <Field.Root> - <Field.Label>Storage Location</Field.Label> - <NativeSelect.Root> - <NativeSelect.Field - value={region} - onChange={(e) => setRegion(e.target.value)} - > - <option value="us">United States</option> - <option value="de-1">Germany - Frankfurt</option> - <option value="au-1">Australia - Sydney</option> - <option value="ca-1">Canada - Montreal</option> - </NativeSelect.Field> - </NativeSelect.Root> - <Field.HelperText color="gray"> - Choose the region where the data will be stored. - </Field.HelperText> - </Field.Root> - <Button - onClick={handleSubmit} - loading={isSubmitting} - colorPalette={"brandTeal"} - > - Create - </Button> - </> - )} - - {provider === "osf" && !isValid && ( - <VStack gap={3}> - <Text color="gray.400" textAlign="center"> - DataPipe sends experiment data directly to your OSF project. - Connect your OSF account to get started. - </Text> - <Link href="/admin/account"> - <Button variant={"solid"} colorPalette={"brandTeal"} size={"lg"}> - Connect OSF Account - </Button> - </Link> - </VStack> - )} - - {provider !== "osf" && !providerConnected && ( + {!providerConnected && ( <VStack gap={3}> <Text color="gray.400" textAlign="center"> DataPipe sends experiment data directly to your{" "} @@ -425,7 +274,7 @@ function NewExperimentForm() { </VStack> )} - {provider !== "osf" && providerConnected && ( + {providerConnected && ( <> {providerError && ( <Text color="red.400" fontSize="sm"> diff --git a/pages/osf-entry.js b/pages/osf-entry.js deleted file mode 100644 index 9c23ab8..0000000 --- a/pages/osf-entry.js +++ /dev/null @@ -1,455 +0,0 @@ -import { VStack, Heading, Text, Button, Alert, Card, Spinner, Center, Box, Input, Field } from "@chakra-ui/react"; -import { useSearchParams } from 'next/navigation'; -import { useRouter } from "next/router"; -import { useEffect, useContext, useState, useRef } from "react"; -import { UserContext } from "../lib/context"; -import { createExperiment, getUserOsfToken } from "../lib/experiment-creation"; -import { validateOsfAccess, getOsfComponentInfo, generateOsfComponentName, cleanOsfUrl } from "../lib/osf-utils"; -import { useDocumentData } from "react-firebase-hooks/firestore"; -import { doc } from "firebase/firestore"; -import { db, auth } from "../lib/firebase"; - -function useOSFEntry() { - const { user, loading: userLoading } = useContext(UserContext); - const router = useRouter(); - const searchParams = useSearchParams(); - const [state, setState] = useState({ - status: 'loading', - error: null, - projectInfo: null - }); - const processingRef = useRef(false); - const titleRef = useRef(null); - - const osfUserId = cleanOsfUrl(searchParams?.get('userIri')); - const osfComponentId = cleanOsfUrl(searchParams?.get('nodeIri')); - - const [userData, userDataLoading] = useDocumentData( - user?.uid ? doc(db, "users", user.uid) : null - ); - - useEffect(() => { - if (userLoading || userDataLoading) return; - - // Don't override active or terminal states - if (['creating', 'success', 'authenticating'].includes(state.status)) return; - - if (!osfUserId) { - setState({ - status: 'error', - error: 'Missing required parameters. This page must be accessed with a valid OSF user.', - projectInfo: null - }); - return; - } - - if (!osfComponentId) { - if (user?.uid && userData?.osfUserId === osfUserId) { - setState(prev => ({ ...prev, status: 'link-already-done' })); - return; - } - - if (user?.uid && userData?.osfUserId && userData?.osfUserId !== osfUserId) { - setState({ - status: 'link-error', - error: `You are signed in with OSF account ${userData.osfUserId}, but this link is for ${osfUserId}. Please sign out and try again.`, - projectInfo: null - }); - return; - } - - setState(prev => ({ ...prev, status: 'link-ready' })); - return; - } - - if (!user?.uid) { - setState(prev => ({ ...prev, status: 'needs-auth' })); - return; - } - - if (userData?.osfUserId === osfUserId) { - setState(prev => ({ ...prev, status: 'ready' })); - return; - } - - if (userData?.osfUserId && userData?.osfUserId !== osfUserId) { - setState({ - status: 'error', - error: `You are signed in with OSF account ${userData.osfUserId}, but this link is for ${osfUserId}. Please sign out and try again.`, - projectInfo: null - }); - return; - } - - setState(prev => ({ ...prev, status: 'needs-auth' })); - }, [user, userLoading, userData, userDataLoading, osfUserId, osfComponentId, state.status]); - - const handleAuthenticate = async () => { - if (processingRef.current) return; - processingRef.current = true; - - setState(prev => ({ ...prev, status: 'authenticating' })); - - try { - const stateRes = await fetch(process.env.NEXT_PUBLIC_GENERATE_STATE, { method: 'POST' }); - if (!stateRes.ok) throw new Error('Failed to generate state'); - const { state } = await stateRes.json(); - - localStorage.setItem('osfAuthFlow', 'osf-entry'); - localStorage.setItem('osfEntryComponentId', osfComponentId); - localStorage.setItem('osfEntryUserId', osfUserId); - localStorage.setItem('latestCSRFToken', state); - - const clientId = process.env.NEXT_PUBLIC_CLIENT_ID; - const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URI; - const scope = "osf.full_write"; - const base_url = `https://accounts.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/oauth2/authorize`; - const url = `${base_url}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&state=${state}&scope=${scope}&access_type=offline`; - - window.location.href = url; - } catch (err) { - setState(prev => ({ - ...prev, - status: 'error', - error: 'Failed to initiate authentication. Please try again.' - })); - processingRef.current = false; - } - }; - - const handleCreateExperiment = async () => { - if (processingRef.current) return; - processingRef.current = true; - - const experimentTitle = titleRef.current?.value || 'DataPipe Data'; - - setState(prev => ({ ...prev, status: 'creating' })); - - try { - if (!userData?.uid) { - throw new Error('You are not signed in to DataPipe. Please sign in first.'); - } - - if (!userData?.osfUserId) { - throw new Error('Your OSF account is not linked. Please sign in with OSF first.'); - } - - const osfToken = await getUserOsfToken(auth.currentUser); - if (!osfToken) { - throw new Error('Your OSF authentication has expired. Please sign out and sign in again with OSF.'); - } - - await validateOsfAccess(osfToken, osfComponentId); - - const componentInfo = await getOsfComponentInfo(osfToken, osfComponentId); - - const dataComponentName = generateOsfComponentName(experimentTitle); - - const result = await createExperiment({ - title: experimentTitle, - osfRepo: osfComponentId, - osfComponentName: dataComponentName, - region: componentInfo.region, - uid: userData.uid, - nConditions: 1, - useValidation: true, - allowJSON: true, - allowCSV: true, - useSessionLimit: false, - maxSessions: 1 - }); - - setState(prev => ({ - ...prev, - status: 'success', - projectInfo: { - experimentId: result.experimentId, - title: result.title, - osfComponent: result.osfComponent, - osfProject: result.osfProject - } - })); - } catch (error) { - console.error('Experiment creation error:', error); - setState(prev => ({ - ...prev, - status: 'error', - error: error.message || 'Failed to create experiment' - })); - - } finally { - processingRef.current = false; - } - }; - - return { - state, - osfUserId, - osfComponentId, - handleAuthenticate, - handleCreateExperiment, - titleRef, - router, - isAuthenticated: user?.uid && userData?.osfUserId === osfUserId, - user: userData - }; -} - -function OSFEntryPage() { - const { state, osfUserId, osfComponentId, handleAuthenticate, handleCreateExperiment, titleRef, router, isAuthenticated, user: userData } = useOSFEntry(); - - const renderContent = () => { - switch (state.status) { - case 'loading': - return ( - <VStack gap={6}> - <Center> - <Spinner size="xl" color="blue.500" borderWidth="4px" /> - </Center> - <Heading size="md" textAlign="center"> - Loading... - </Heading> - </VStack> - ); - - case 'needs-auth': - return ( - <VStack gap={6}> - <Heading size="lg" textAlign="center"> - Create DataPipe Experiment - </Heading> - <Text textAlign="center" color="gray.300"> - Sign in with your OSF account to create a DataPipe experiment linked to your OSF project. - </Text> - <Box bg="greyBackground" p={4} borderRadius="md" w="full" border="1px solid" borderColor="gray.600"> - <Text fontSize="sm" color="white" mb={2}> - <strong>OSF Component:</strong> {osfComponentId} - </Text> - <Text fontSize="sm" color="white"> - <strong>OSF User:</strong> {osfUserId} - </Text> - </Box> - <Button - colorPalette="brandTeal" - onClick={handleAuthenticate} - size="lg" - w="full" - > - Sign in with OSF - </Button> - </VStack> - ); - - case 'ready': - return ( - <VStack gap={6}> - <Heading size="lg" textAlign="center"> - Create DataPipe Experiment - </Heading> - <Text textAlign="center" color="gray.300"> - Create a new DataPipe experiment linked to your OSF project. - </Text> - <Box bg="greyBackground" p={4} borderRadius="md" w="full" border="1px solid" borderColor="gray.600"> - <Text fontSize="sm" color="white" mb={2}> - <strong>OSF Component:</strong> {osfComponentId} - </Text> - <Text fontSize="sm" color="white"> - <strong>OSF User:</strong> {osfUserId} - </Text> - </Box> - <Box w="full"> - <Field.Root> - <Field.Label mb={1}>Experiment Name</Field.Label> - <Input - ref={titleRef} - placeholder="DataPipe Data" - size="md" - w="full" - /> - </Field.Root> - </Box> - <Button - colorPalette="brandTeal" - onClick={handleCreateExperiment} - size="lg" - w="full" - > - Create Experiment - </Button> - </VStack> - ); - - case 'authenticating': - return ( - <VStack gap={6}> - <Center> - <Spinner size="xl" color="blue.500" borderWidth="4px" /> - </Center> - <Heading size="md" textAlign="center"> - Redirecting to OSF... - </Heading> - <Text color="gray.300" textAlign="center"> - Please complete authentication with OSF. - </Text> - </VStack> - ); - - case 'creating': - return ( - <VStack gap={6}> - <Center> - <Spinner size="xl" color="green.500" borderWidth="4px" /> - </Center> - <Heading size="md" textAlign="center"> - Creating Experiment... - </Heading> - <Text color="gray.300" textAlign="center"> - Setting up your DataPipe experiment and OSF integration. - </Text> - </VStack> - ); - - case 'success': - return ( - <VStack gap={6}> - <Alert.Root status="success" borderRadius="md" bg="green.800" borderColor="green.600" borderWidth={1}> - <Alert.Indicator color="green.300" /> - <VStack gap={2} align="start"> - <Text fontWeight="medium" color="white">Experiment Created Successfully!</Text> - {state.projectInfo && ( - <Text fontSize="sm" color="gray.100"> - Experiment “{state.projectInfo.title}” is ready to collect data. - </Text> - )} - </VStack> - </Alert.Root> - <VStack gap={3} w="full"> - <Text fontSize="sm" color="gray.300" textAlign="center"> - Your DataPipe experiment is now ready to collect data. - </Text> - <VStack gap={2} w="full"> - <Button - colorPalette="brandTeal" - size="md" - w="full" - onClick={() => router.push(`/admin/${state.projectInfo.experimentId}`)} - > - Open Experiment in DataPipe - </Button> - </VStack> - <Text fontSize="sm" color="gray.400" textAlign="center"> - You can safely close this tab and return to OSF. - </Text> - </VStack> - </VStack> - ); - - case 'error': - return ( - <VStack gap={6}> - <Alert.Root status="error" borderRadius="md" bg="red.800" borderColor="red.600" borderWidth={1}> - <Alert.Indicator color="red.300" /> - <VStack gap={2} align="start"> - <Text fontWeight="medium" color="white">Error</Text> - <Text fontSize="sm" color="gray.100">{state.error}</Text> - </VStack> - </Alert.Root> - - <VStack gap={3}> - {state.error.includes('authentication has expired') || state.error.includes('sign out') ? ( - <Button - colorPalette="brandTeal" - onClick={handleAuthenticate} - size="sm" - > - Re-authenticate with OSF - </Button> - ) : null} - <Text fontSize="xs" color="gray.400" textAlign="center"> - You can safely close this tab and try again from OSF. - </Text> - </VStack> - </VStack> - ); - - case 'link-ready': - return ( - <VStack gap={6}> - <Heading size="lg" textAlign="center"> - Link Your OSF Account - </Heading> - <Text textAlign="center" color="gray.300"> - To proceed, please link your OSF account to DataPipe. - </Text> - <Button - colorPalette="brandTeal" - onClick={handleAuthenticate} - size="lg" - w="full" - > - Sign in with OSF - </Button> - </VStack> - ); - - case 'link-already-done': - return ( - <VStack gap={6}> - <Alert.Root status="success" borderRadius="md" bg="green.800" borderColor="green.600" borderWidth={1}> - <Alert.Indicator color="green.300" /> - <VStack gap={2} align="start"> - <Text fontWeight="medium" color="white">Your OSF account is linked!</Text> - <Text fontSize="sm" color="gray.100"> - You can now close this window and return to the OSF. - </Text> - </VStack> - </Alert.Root> - <Text fontSize="sm" color="gray.400" textAlign="center"> - You can safely close this tab and return to OSF. - </Text> - </VStack> - ); - - case 'link-error': - return ( - <VStack gap={6}> - <Alert.Root status="error" borderRadius="md" bg="red.800" borderColor="red.600" borderWidth={1}> - <Alert.Indicator color="red.300" /> - <VStack gap={2} align="start"> - <Text fontWeight="medium" color="white">Linking Error</Text> - <Text fontSize="sm" color="gray.100">{state.error}</Text> - </VStack> - </Alert.Root> - <Text fontSize="sm" color="gray.400" textAlign="center"> - You can safely close this tab and try again from OSF. - </Text> - </VStack> - ); - - default: - return ( - <VStack gap={6}> - <Heading size="md" textAlign="center"> - Loading... - </Heading> - </VStack> - ); - } - }; - - return ( - <Box minH="100vh" bg="greyBackground" py={8}> - <Card.Root w="100%" maxW={400} mx="auto" mt={8} px={4} variant="unstyled" color="white"> - <Card.Body p={8}> - {renderContent()} - </Card.Body> - </Card.Root> - </Box> - ); -} - -OSFEntryPage.getLayout = function getLayout(page) { - return page; -}; - -export default OSFEntryPage; From 697546e5469a0d23e0f5a9caa859ef32ef658a6a Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 11:08:04 -0400 Subject: [PATCH 072/181] feat: pluggable sign-in providers (Google, ORCID, GitHub) Adds lib/auth-providers.js, a registry shaped like the existing STORAGE_PROVIDERS map so the two read alike. Every entry resolves to a Firebase AuthProvider, so all call sites are uniform regardless of provider: signInWithPopup / linkWithPopup / unlink. Adding a fourth provider later is one registry entry, one icon, and console config. No server code participates in sign-in at all. ORCID needs Identity Platform's generic OIDC support (Firebase has no native ORCID provider) and is registered as `oidc.orcid` against issuer https://orcid.org, code flow. Two details come straight from ORCID's discovery document and are load-bearing: - scopes_supported is ONLY "openid", and ORCID will not issue an id_token unless it is requested, so the adapter asks for it and nothing else. - claims_supported has NO email claim. An ORCID sign-in therefore always yields user.email === null -- not just when a researcher marks their address private. Hence providesEmail: false, and ensureUserDocument writing email: "" rather than assuming. GitHub asks for user:email, which it otherwise withholds unless the researcher has made the address public. ensureUserDocument (lib/user-bootstrap.js) creates users/{uid} on a first federated sign-in. Nothing else would: that document was only ever written by the signup form or the OSF callback, and federated sign-in goes through neither. The read-before-write is required by firestore.rules, not an optimization -- an unconditional merge over an existing document would put fields like connectedAccounts into request.resource.data and be denied. OSF sign-in is deliberately LEFT IN PLACE on the sign-in page and removed only from sign-up. Researchers who signed up through OSF still need a way in so they can link a new provider without losing their uid; no new account should be created against a platform that is closing. Deletes SignUpWithOSF, plus OneClickAuth and OSFToken, which were already unreferenced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/auth-providers.test.js | 122 ++++++++++++++++++++ components/AuthProviderIcons.js | 54 +++++++++ components/SignInForm.js | 16 ++- components/SignUpWithOSF.js | 60 ---------- components/account/OSFToken.js | 149 ------------------------- components/account/OneClickAuth.js | 48 -------- components/auth/AuthProviderButtons.js | 90 +++++++++++++++ lib/auth-errors.js | 52 +++++++++ lib/auth-providers.js | 110 ++++++++++++++++++ lib/user-bootstrap.js | 41 +++++++ pages/signup.js | 28 ++--- 11 files changed, 496 insertions(+), 274 deletions(-) create mode 100644 __tests__/auth-providers.test.js create mode 100644 components/AuthProviderIcons.js delete mode 100644 components/SignUpWithOSF.js delete mode 100644 components/account/OSFToken.js delete mode 100644 components/account/OneClickAuth.js create mode 100644 components/auth/AuthProviderButtons.js create mode 100644 lib/auth-errors.js create mode 100644 lib/auth-providers.js create mode 100644 lib/user-bootstrap.js diff --git a/__tests__/auth-providers.test.js b/__tests__/auth-providers.test.js new file mode 100644 index 0000000..b00e03c --- /dev/null +++ b/__tests__/auth-providers.test.js @@ -0,0 +1,122 @@ +import { + AUTH_PROVIDERS, + AUTH_PROVIDER_LIST, + ORCID_PROVIDER_ID, + PASSWORD_PROVIDER_ID, + canUnlink, + getAuthProviderByProviderId, + linkedProviderIds, +} from "../lib/auth-providers"; +import { AUTH_PROVIDER_ICONS } from "../components/AuthProviderIcons"; + +describe("AUTH_PROVIDERS registry", () => { + it("offers exactly Google, ORCID and GitHub", () => { + expect(Object.keys(AUTH_PROVIDERS).sort()).toEqual([ + "github", + "google", + "orcid", + ]); + }); + + it("does NOT include OSF -- it is being removed as a sign-in method", () => { + expect(AUTH_PROVIDERS.osf).toBeUndefined(); + expect( + AUTH_PROVIDER_LIST.some((entry) => /osf/i.test(entry.providerId)) + ).toBe(false); + }); + + it("maps each entry to the Firebase provider id Firebase itself reports", () => { + expect(AUTH_PROVIDERS.google.providerId).toBe("google.com"); + expect(AUTH_PROVIDERS.github.providerId).toBe("github.com"); + // Firebase requires generic OIDC ids to carry the "oidc." prefix, and + // this string must match the Identity Platform console registration + // exactly or sign-in cannot be routed. + expect(AUTH_PROVIDERS.orcid.providerId).toBe("oidc.orcid"); + expect(ORCID_PROVIDER_ID).toBe("oidc.orcid"); + }); + + it("builds a usable Firebase AuthProvider for every entry", () => { + for (const entry of AUTH_PROVIDER_LIST) { + const provider = entry.makeProvider(); + expect(provider.providerId).toBe(entry.providerId); + } + }); + + it("requests the openid scope for ORCID, which will not issue an id_token without it", () => { + expect(AUTH_PROVIDERS.orcid.makeProvider().getScopes()).toContain("openid"); + }); + + it("requests user:email for GitHub, which otherwise withholds private addresses", () => { + expect(AUTH_PROVIDERS.github.makeProvider().getScopes()).toContain( + "user:email" + ); + }); + + it("flags ORCID as not guaranteeing an email address", () => { + // Researchers routinely keep their ORCID email private, so a successful + // ORCID sign-in can yield user.email === null. Anything that assumes an + // address must consult this rather than assume. + expect(AUTH_PROVIDERS.orcid.providesEmail).toBe(false); + expect(AUTH_PROVIDERS.google.providesEmail).toBe(true); + expect(AUTH_PROVIDERS.github.providesEmail).toBe(true); + }); + + it("has an icon for every entry", () => { + for (const entry of AUTH_PROVIDER_LIST) { + expect(AUTH_PROVIDER_ICONS[entry.id]).toBeDefined(); + } + }); +}); + +describe("getAuthProviderByProviderId", () => { + it("resolves a Firebase provider id back to its registry entry", () => { + expect(getAuthProviderByProviderId("google.com")).toBe( + AUTH_PROVIDERS.google + ); + expect(getAuthProviderByProviderId("oidc.orcid")).toBe(AUTH_PROVIDERS.orcid); + }); + + it("returns null for methods that are not popup providers", () => { + // "password" is a real sign-in method but deliberately not in the + // registry -- mapping over it would render a button that cannot work. + expect(getAuthProviderByProviderId(PASSWORD_PROVIDER_ID)).toBeNull(); + expect(getAuthProviderByProviderId("facebook.com")).toBeNull(); + expect(getAuthProviderByProviderId(undefined)).toBeNull(); + }); +}); + +describe("linkedProviderIds", () => { + it("reads the provider ids off a Firebase user", () => { + expect( + linkedProviderIds({ + providerData: [{ providerId: "google.com" }, { providerId: "password" }], + }) + ).toEqual(["google.com", "password"]); + }); + + it("is empty for an OSF custom-token session and for no user at all", () => { + // This is the exact signal AddSignInMethodBanner keys on: OSF sign-in + // mints a custom token, which carries no federated provider and no + // password, so providerData is empty. Zero linked providers means the + // account is reachable ONLY by the flow that is being removed. + expect(linkedProviderIds({ providerData: [] })).toEqual([]); + expect(linkedProviderIds({})).toEqual([]); + expect(linkedProviderIds(null)).toEqual([]); + }); +}); + +describe("canUnlink", () => { + it("allows unlinking while another method remains", () => { + expect(canUnlink(["google.com", "github.com"], "google.com")).toBe(true); + // A password counts as a way back in, so the federated one can go. + expect(canUnlink(["google.com", "password"], "google.com")).toBe(true); + }); + + it("refuses to unlink the only remaining method", () => { + // Otherwise the researcher is locked out of an account that still owns + // their experiments. + expect(canUnlink(["google.com"], "google.com")).toBe(false); + expect(canUnlink([], "google.com")).toBe(false); + expect(canUnlink(undefined, "google.com")).toBe(false); + }); +}); diff --git a/components/AuthProviderIcons.js b/components/AuthProviderIcons.js new file mode 100644 index 0000000..1c004fb --- /dev/null +++ b/components/AuthProviderIcons.js @@ -0,0 +1,54 @@ +// Brand marks for the sign-in providers in lib/auth-providers.js, keyed by the +// same registry id. Kept out of that module on purpose: lib/ holds plain data +// (see lib/provider-config.js, which likewise carries no icons) so it stays +// importable from plain unit tests without JSX. +// +// Adding a provider means adding an entry here as well as in AUTH_PROVIDERS. +// A missing icon is not fatal -- AuthProviderButtons renders the button +// without one. + +export const GoogleIcon = (props) => ( + <svg viewBox="0 0 18 18" width="1em" height="1em" aria-hidden="true" {...props}> + <path + fill="#4285F4" + d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z" + /> + <path + fill="#34A853" + d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z" + /> + <path + fill="#FBBC05" + d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z" + /> + <path + fill="#EA4335" + d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z" + /> + </svg> +); + +export const OrcidIcon = (props) => ( + <svg viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true" {...props}> + <circle cx="128" cy="128" r="128" fill="#A6CE39" /> + <path + fill="#FFF" + d="M86.3 186.2H70.9V79.1h15.4v107.1zM108.9 79.1h41.6c39.6 0 57 28.3 57 53.6 0 27.5-21.5 53.6-56.8 53.6h-41.8V79.1zm15.4 93.3h24.5c34.9 0 42.9-26.5 42.9-39.7 0-21.5-13.7-39.7-43.7-39.7h-23.7v79.4zM88.7 56.8a9.9 9.9 0 1 1-19.8 0 9.9 9.9 0 0 1 19.8 0z" + /> + </svg> +); + +export const GithubIcon = (props) => ( + <svg viewBox="0 0 16 16" width="1em" height="1em" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.4 7.4 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" + /> + </svg> +); + +export const AUTH_PROVIDER_ICONS = { + google: GoogleIcon, + orcid: OrcidIcon, + github: GithubIcon, +}; diff --git a/components/SignInForm.js b/components/SignInForm.js index b05459f..08c3285 100644 --- a/components/SignInForm.js +++ b/components/SignInForm.js @@ -16,6 +16,7 @@ import { useState } from "react"; import { useRouter } from "next/router"; import NextLink from "next/link"; import { ERROR, getError } from "../lib/utils"; +import AuthProviderButtons from "./auth/AuthProviderButtons"; import SignInWithOSF from "./SignInWithOSF"; export default function SignInForm({ routeAfterSignIn }) { @@ -48,7 +49,10 @@ export default function SignInForm({ routeAfterSignIn }) { <VStack gap={6}> <Heading size="lg" textAlign="center">Sign In</Heading> - <SignInWithOSF /> + <AuthProviderButtons + verb="Sign in" + onSignedIn={() => router.push(routeAfterSignIn)} + /> <HStack w="full" alignItems="center"> <Separator flex="1" /> @@ -92,6 +96,16 @@ export default function SignInForm({ routeAfterSignIn }) { Sign In </Button> + {/* OSF sign-in stays available through the wind-down, and ONLY + here -- it is gone from the sign-up page, because no new + account should be created against a platform that is closing. + Researchers who signed up through OSF can still get in, which + is what lets them link one of the providers above from the + dashboard banner without losing their uid (and with it, their + experiments). Remove this in the same release that removes the + signup branch of functions/src/oauth2-callback.ts, not before. */} + <SignInWithOSF /> + <VStack gap={2} w="full"> <Link asChild fontSize="sm" color="brandOrange.300"> <NextLink href="/reset-password">Forgot password?</NextLink> diff --git a/components/SignUpWithOSF.js b/components/SignUpWithOSF.js deleted file mode 100644 index 86483ba..0000000 --- a/components/SignUpWithOSF.js +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from "react"; -import { - Button, - Text, - VStack, - Alert, -} from "@chakra-ui/react"; -import { OsfIcon } from "./OsfIcon"; - -export default function SignUpWithOSF() { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(""); - - const handleOSFSignup = async () => { - setIsLoading(true); - setError(""); - - try { - const stateRes = await fetch(process.env.NEXT_PUBLIC_GENERATE_STATE, { method: 'POST' }); - if (!stateRes.ok) throw new Error('Failed to generate state'); - const { state } = await stateRes.json(); - - localStorage.setItem('latestCSRFToken', state); - localStorage.setItem('osfAuthFlow', 'signup'); - - const clientId = process.env.NEXT_PUBLIC_CLIENT_ID; - const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URI; - const scope = "osf.full_write"; - const base_url = `https://accounts.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/oauth2/authorize`; - const url = `${base_url}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&state=${state}&scope=${scope}&access_type=offline&approval_prompt=force`; - - window.location.href = url; - } catch (err) { - setError("Failed to initiate OSF signup. Please try again."); - setIsLoading(false); - } - }; - - return ( - <VStack gap={4} w="full"> - {error && ( - <Alert.Root status="error" borderRadius="md"> - <Alert.Indicator /> - <Text fontSize="sm">{error}</Text> - </Alert.Root> - )} - - <Button - colorPalette="blue" - loading={isLoading} - loadingText="Redirecting to OSF..." - onClick={handleOSFSignup} - width="full" - size="lg" - > - <OsfIcon /> Sign Up with OSF - </Button> - </VStack> - ); -} diff --git a/components/account/OSFToken.js b/components/account/OSFToken.js deleted file mode 100644 index e6db7e0..0000000 --- a/components/account/OSFToken.js +++ /dev/null @@ -1,149 +0,0 @@ -import { useState, useContext, useRef } from "react"; -import { UserContext } from "../../lib/context"; - -import { - HStack, - VStack, - Button, - Text, - Dialog, - Field, - Input, - Tooltip, - Link, -} from "@chakra-ui/react"; - -import { useDocumentData } from "react-firebase-hooks/firestore"; -import { doc } from "firebase/firestore"; - -import { db, auth } from "../../lib/firebase"; -import { CircleCheck, TriangleAlert } from "lucide-react"; - -export default function OSFToken() { - const { user } = useContext(UserContext); - const [isSubmitting, setIsSubmitting] = useState(false); - const [open, setOpen] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const tokenRef = useRef(null); - - const [data, loading, error, snapshot, reload] = useDocumentData( - doc(db, "users", user.uid) - ); - - const handleSave = async () => { - const token = tokenRef.current?.value; - setIsSubmitting(true); - try { - const idToken = await auth.currentUser.getIdToken(); - const response = await fetch("/api/saveosftoken", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${idToken}`, - }, - body: JSON.stringify({ token }), - }); - if (!response.ok) { - throw new Error("Failed to save token"); - } - const result = await response.json(); - if (!result.osfTokenValid) { - setErrorMessage("The token could not be verified with OSF. Please check that you copied the full token and that it has the osf.full_write scope."); - setIsSubmitting(false); - return; - } - setIsSubmitting(false); - setOpen(false); - } catch (error) { - setErrorMessage("Failed to save token. Please try again."); - setIsSubmitting(false); - } - }; - - return ( - <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> - <HStack> - <Text fontSize={"lg"}>OSF Token</Text> - {data && data.osfTokenValid ? ( - <Tooltip.Root> - <Tooltip.Trigger asChild> - <span><CircleCheck color="var(--chakra-colors-green-500)" /></span> - </Tooltip.Trigger> - <Tooltip.Positioner> - <Tooltip.Content>Valid OSF Token</Tooltip.Content> - </Tooltip.Positioner> - </Tooltip.Root> - ) : ( - <Tooltip.Root> - <Tooltip.Trigger asChild> - <span><TriangleAlert color="var(--chakra-colors-orange-500)" /></span> - </Tooltip.Trigger> - <Tooltip.Positioner> - <Tooltip.Content>Invalid OSF Token</Tooltip.Content> - </Tooltip.Positioner> - </Tooltip.Root> - )} - </HStack> - <Button loading={isSubmitting} onClick={() => setOpen(true)} colorPalette="brandTeal"> - Set OSF Token - </Button> - <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> - <Dialog.Backdrop /> - <Dialog.Positioner> - <Dialog.Content bg="greyBackground" color="white"> - <Dialog.Header>Change OSF Token</Dialog.Header> - <Dialog.CloseTrigger /> - <Dialog.Body> - <VStack gap={4} w="100%"> - <Text> - To generate an OSF token, go to{" "} - <Link - color="brandOrange.100" - href={`https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/settings/tokens/`} - target="_blank" - rel="noopener noreferrer" - > - https://osf.io/settings/tokens/ - </Link>{" "} - and click "Create Token". - </Text> - <Text> - Select osf.full_write under scopes and click "Create - token". Copy the token and paste it below. - </Text> - - {errorMessage && ( - <Text color="red.400" fontSize="sm">{errorMessage}</Text> - )} - - {data && ( - <VStack gap={4} w="100%"> - <Field.Root> - <Field.Label>OSF Token</Field.Label> - <Input ref={tokenRef} type="text" placeholder="Paste your OSF token here" /> - </Field.Root> - </VStack> - )} - </VStack> - </Dialog.Body> - <Dialog.Footer> - <Button - variant={"solid"} - colorPalette={"brandTeal"} - size={"md"} - mr={4} - onClick={() => { - setErrorMessage(null); - handleSave(); - }} - loading={isSubmitting} - > - Change Token - </Button> - </Dialog.Footer> - </Dialog.Content> - </Dialog.Positioner> - </Dialog.Root> - </HStack> - ); -} diff --git a/components/account/OneClickAuth.js b/components/account/OneClickAuth.js deleted file mode 100644 index 0b522ad..0000000 --- a/components/account/OneClickAuth.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useContext } from "react"; -import { UserContext } from "../../lib/context"; -import { useDocumentData } from "react-firebase-hooks/firestore"; -import { doc } from "firebase/firestore"; -import { db } from "../../lib/firebase"; -import { HStack, Text } from "@chakra-ui/react"; -import { Button } from "@chakra-ui/react"; -import { OsfIcon } from "../OsfIcon"; - - -export default function OneClickAuth() { - const { user } = useContext(UserContext); - - const [data, loading, error, snapshot, reload] = useDocumentData( - doc(db, "users", user.uid) - ); - - const handleAuthClick = async () => { - try { - const stateRes = await fetch(process.env.NEXT_PUBLIC_GENERATE_STATE, { method: 'POST' }); - if (!stateRes.ok) throw new Error('Failed to generate state'); - const { state: redirectState } = await stateRes.json(); - - localStorage.setItem('latestCSRFToken', redirectState); - localStorage.setItem('osfAuthFlow', 'linking'); - - const clientId = process.env.NEXT_PUBLIC_CLIENT_ID; - const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URI; - const scope = "osf.full_write" - const base_url = `https://accounts.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/oauth2/authorize`; - const url = `${base_url}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&state=${redirectState}&scope=${scope}&access_type=offline&approval_prompt=force`; - window.location.href = url; - } catch (err) { - console.error('Failed to initiate OSF auth:', err); - } - } - - return ( - <HStack justifyContent="space-between" w="100%"> - <HStack> - <Text fontSize={"lg"}>One-Click Authentication</Text> - </HStack> - <Button colorPalette="blue" onClick={handleAuthClick}><OsfIcon /> - Link OSF Account - </Button> - </HStack> - ); -} diff --git a/components/auth/AuthProviderButtons.js b/components/auth/AuthProviderButtons.js new file mode 100644 index 0000000..b39637c --- /dev/null +++ b/components/auth/AuthProviderButtons.js @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Button, Alert, Text, VStack } from "@chakra-ui/react"; +import { linkWithPopup, signInWithPopup } from "firebase/auth"; +import { auth } from "../../lib/firebase"; +import { AUTH_PROVIDER_LIST } from "../../lib/auth-providers"; +import { AUTH_PROVIDER_ICONS } from "../AuthProviderIcons"; +import { + isCancelledAuthError, + messageForAuthError, +} from "../../lib/auth-errors"; +import { ensureUserDocument } from "../../lib/user-bootstrap"; + +// The federated provider buttons, rendered from AUTH_PROVIDERS. Used by the +// sign-in page, the sign-up page, and the "add a sign-in method" migration +// banner -- for a federated provider signing in and signing up are the same +// operation, so `verb` only changes the wording. +// +// `mode` picks the Firebase call: +// "signIn" -- signInWithPopup, for a visitor who is not signed in. +// "link" -- linkWithPopup, which attaches the credential to the CURRENT +// user and leaves the uid alone. This is what lets an OSF-era +// account adopt a new sign-in method without being severed from +// the experiments that reference it by `owner: uid`. +// +// Popup rather than redirect: signInWithRedirect needs third-party storage +// access that Safari and Firefox block by default unless the auth handler is +// self-hosted on this domain, and DataPipe uses the default +// <project>.firebaseapp.com handler. +export default function AuthProviderButtons({ + verb = "Sign in", + mode = "signIn", + onSignedIn, +}) { + const [pendingId, setPendingId] = useState(null); + const [error, setError] = useState(""); + + const handleClick = async (entry) => { + setPendingId(entry.id); + setError(""); + try { + if (mode === "link") { + const credential = await linkWithPopup( + auth.currentUser, + entry.makeProvider() + ); + onSignedIn?.(credential.user); + } else { + const credential = await signInWithPopup(auth, entry.makeProvider()); + // A first-time federated sign-in has no users/{uid} doc yet; nothing + // else in the app creates one for these providers. + await ensureUserDocument(credential.user); + onSignedIn?.(credential.user); + } + } catch (err) { + if (!isCancelledAuthError(err?.code)) { + setError(messageForAuthError(err?.code, entry.name)); + } + } finally { + setPendingId(null); + } + }; + + return ( + <VStack gap={3} w="full"> + {error && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{error}</Text> + </Alert.Root> + )} + + {AUTH_PROVIDER_LIST.map((entry) => { + const Icon = AUTH_PROVIDER_ICONS[entry.id]; + return ( + <Button + key={entry.id} + variant="outline" + width="full" + size="lg" + loading={pendingId === entry.id} + disabled={pendingId !== null && pendingId !== entry.id} + onClick={() => handleClick(entry)} + > + {Icon && <Icon />} {verb} with {entry.name} + </Button> + ); + })} + </VStack> + ); +} diff --git a/lib/auth-errors.js b/lib/auth-errors.js new file mode 100644 index 0000000..1251554 --- /dev/null +++ b/lib/auth-errors.js @@ -0,0 +1,52 @@ +// Researcher-facing copy for Firebase Auth error codes, shared by the sign-in +// buttons and the account page's linked-methods section (both run the same +// popup flows, so both hit the same codes). +// +// Modelled on messageForError in components/account/ProviderConnections.js: +// translate the opaque code into something the researcher can act on rather +// than surfacing it verbatim. + +// Codes that mean "the researcher backed out", not "something went wrong". +// Callers reset their loading state and show nothing at all for these. +const CANCELLED = new Set([ + "auth/popup-closed-by-user", + "auth/cancelled-popup-request", + "auth/user-cancelled", +]); + +export function isCancelledAuthError(code) { + return CANCELLED.has(code); +} + +export function messageForAuthError(code, providerName = "that provider") { + switch (code) { + case "auth/account-exists-with-different-credential": + case "auth/email-already-in-use": + // Deliberately does not name the other method. Firebase's email + // enumeration protection makes fetchSignInMethodsForEmail return + // nothing, so any specific claim here would be a guess. + return `An account already exists with this email address. Sign in using the method you set up originally, then add ${providerName} from your account settings.`; + + case "auth/credential-already-in-use": + case "auth/provider-already-linked": + return `That ${providerName} account is already linked to a DataPipe account. Each ${providerName} account can only be linked to one.`; + + case "auth/popup-blocked": + return "Your browser blocked the sign-in window. Allow pop-ups for this site and try again."; + + case "auth/operation-not-allowed": + return `${providerName} sign-in is not enabled for DataPipe yet. Please try another method.`; + + case "auth/unauthorized-domain": + return "This site is not authorized for sign-in. Please report this to the DataPipe maintainers."; + + case "auth/network-request-failed": + return "Could not reach the authentication service. Check your connection and try again."; + + case "auth/requires-recent-login": + return "For security, please sign out and sign back in before changing your sign-in methods."; + + default: + return `Could not complete ${providerName} sign-in. Please try again.`; + } +} diff --git a/lib/auth-providers.js b/lib/auth-providers.js new file mode 100644 index 0000000..dadce0f --- /dev/null +++ b/lib/auth-providers.js @@ -0,0 +1,110 @@ +// Frontend registry of SIGN-IN providers. Deliberately shaped like +// lib/provider-config.js's STORAGE_PROVIDERS so the two read alike -- but the +// two registries are unrelated and must not be conflated: +// +// STORAGE_PROVIDERS (provider-config.js) = where a researcher's DATA goes. +// AUTH_PROVIDERS (this file) = how a researcher SIGNS IN. +// +// OSF used to be both at once -- one `osf.full_write` consent produced both +// the identity and the storage grant (see functions/src/oauth2-callback.ts) -- +// which is exactly the coupling this split removes. +// +// Every entry resolves to a Firebase AuthProvider instance, so every call site +// is uniform regardless of provider: +// +// signInWithPopup(auth, entry.makeProvider()) +// linkWithPopup(auth.currentUser, entry.makeProvider()) +// unlink(auth.currentUser, entry.providerId) +// +// Adding a provider later is one entry here, one icon in +// components/AuthProviderIcons.js, and the console configuration. No server +// code participates in sign-in at all. +// +// NOTE: this module must never import lib/firebase.js. It only needs the +// provider CLASSES from firebase/auth, and staying free of the app +// initialization keeps it importable in tests without a real Firebase config. +import { + GithubAuthProvider, + GoogleAuthProvider, + OAuthProvider, +} from "firebase/auth"; + +// Firebase requires generic OIDC provider ids to be prefixed "oidc.". This +// string must match the provider id registered in the Identity Platform +// console exactly -- Firebase has no other way to route the sign-in. +export const ORCID_PROVIDER_ID = "oidc.orcid"; + +// Email/password is a sign-in method but not a popup provider: it has its own +// form and its own account-page section. It is kept out of AUTH_PROVIDERS so +// that mapping over the registry never renders a button that cannot work, but +// the account page still needs to name it when listing linked methods. +export const PASSWORD_PROVIDER_ID = "password"; + +export const AUTH_PROVIDERS = { + google: { + id: "google", + name: "Google", + providerId: GoogleAuthProvider.PROVIDER_ID, + // Google always returns a verified email address on the credential. + providesEmail: true, + makeProvider: () => new GoogleAuthProvider(), + }, + orcid: { + id: "orcid", + name: "ORCID", + providerId: ORCID_PROVIDER_ID, + // ORCID lets researchers keep their email address private, and most do, + // so a perfectly successful ORCID sign-in can still yield + // `user.email === null`. Everything downstream must tolerate that -- + // see ensureUserDocument in lib/user-bootstrap.js. + providesEmail: false, + makeProvider: () => { + const provider = new OAuthProvider(ORCID_PROVIDER_ID); + // ORCID only returns an id_token when the client explicitly asks for + // the `openid` scope; without it the flow degrades to plain OAuth2 and + // Firebase cannot complete the sign-in. + provider.addScope("openid"); + return provider; + }, + }, + github: { + id: "github", + name: "GitHub", + providerId: GithubAuthProvider.PROVIDER_ID, + providesEmail: true, + makeProvider: () => { + const provider = new GithubAuthProvider(); + // Without this scope GitHub only discloses an email when the user has + // made it public on their profile, which most have not. + provider.addScope("user:email"); + return provider; + }, + }, +}; + +// Stable display order for the sign-in buttons and the account page. +export const AUTH_PROVIDER_LIST = Object.values(AUTH_PROVIDERS); + +// Firebase reports linked methods as provider-id strings on +// `user.providerData`; this maps one back to its registry entry. Returns null +// for anything not in the registry -- notably "password", and any legacy +// provider a user linked before it was removed from the registry. +export function getAuthProviderByProviderId(providerId) { + return ( + AUTH_PROVIDER_LIST.find((entry) => entry.providerId === providerId) || null + ); +} + +// The provider-id strings a Firebase user currently has linked. +export function linkedProviderIds(user) { + return (user?.providerData || []).map((info) => info.providerId); +} + +// True when a user with these linked methods could still sign in after +// `providerId` is unlinked. Unlinking the only remaining method would lock the +// researcher out of an account that still owns their experiments, so the UI +// must refuse it. Takes the id list rather than a user so it can be called +// against local state mid-flow, before the auth object has settled. +export function canUnlink(linkedIds, providerId) { + return (linkedIds || []).some((id) => id !== providerId); +} diff --git a/lib/user-bootstrap.js b/lib/user-bootstrap.js new file mode 100644 index 0000000..37841e8 --- /dev/null +++ b/lib/user-bootstrap.js @@ -0,0 +1,41 @@ +import { doc, getDoc, setDoc } from "firebase/firestore"; +import { db } from "./firebase"; + +// Creates the users/{uid} Firestore document if it does not already exist. +// +// Why this is needed: the only place that doc was ever created client-side is +// pages/signup.js's email/password path, and the only place it was created +// server-side was oauth2-callback.ts's OSF signup branch. Federated sign-in +// (Google/ORCID/GitHub) goes through neither -- Firebase creates the Auth +// record by itself and no DataPipe code runs -- so without this the dashboard +// would load against a missing doc on a researcher's first visit. +// +// Idempotent and safe to call after every sign-in and every account link. +// +// The read-before-write is not an optimization, it is required by +// firestore.rules: a user doc write is only permitted when it matches +// isAccountCreation(), isTokenMethodUpdate() or isExperimentsUpdate(). An +// unconditional merge over an EXISTING doc would put fields like +// connectedAccounts into request.resource.data and fail all three. +export async function ensureUserDocument(user) { + if (!user?.uid) return false; + + const ref = doc(db, "users", user.uid); + const snapshot = await getDoc(ref); + if (snapshot.exists()) return false; + + await setDoc( + ref, + { + uid: user.uid, + // ORCID sign-ins routinely carry no email -- researchers keep it + // private on their ORCID record -- so this is "" rather than absent. + // Nothing downstream may assume a user doc has a usable address. + email: user.email || "", + experiments: [], + }, + { merge: true } + ); + + return true; +} diff --git a/pages/signup.js b/pages/signup.js index 50d6b36..6aa713d 100644 --- a/pages/signup.js +++ b/pages/signup.js @@ -1,9 +1,9 @@ import { createUserWithEmailAndPassword } from "firebase/auth"; -import { doc, setDoc } from "firebase/firestore"; import Link from "next/link"; import { useState } from "react"; import { useRouter } from "next/router"; -import { auth, db } from "../lib/firebase"; +import { auth } from "../lib/firebase"; +import { ensureUserDocument } from "../lib/user-bootstrap"; import { Card, @@ -19,7 +19,7 @@ import { Box, } from "@chakra-ui/react"; import { ERROR, getError } from "../lib/utils"; -import SignUpWithOSF from "../components/SignUpWithOSF"; +import AuthProviderButtons from "../components/auth/AuthProviderButtons"; export default function SignUpPage() { const router = useRouter(); @@ -63,18 +63,11 @@ export default function SignUpPage() { ); const user = userCredential.user; - await setDoc(doc(db, "users", user.uid), { - email: user.email, - uid: user.uid, - osfToken: "", - osfTokenValid: false, - usingPersonalToken: false, - refreshToken: "", - refreshTokenExpires: 0, - authToken: "", - authTokenExpires: 0, - experiments: [], - }); + // Same slim shape as a federated first sign-in. The OSF token fields + // this used to seed (osfToken/usingPersonalToken/refreshToken/...) are + // dead weight on a new account: OSF is closed to new experiments, so + // nothing will ever read them. Existing docs keep theirs untouched. + await ensureUserDocument(user); router.push("/admin"); } catch (error) { @@ -94,7 +87,10 @@ export default function SignUpPage() { <VStack gap={6}> <Heading size="lg" textAlign="center">Create Account</Heading> - <SignUpWithOSF /> + <AuthProviderButtons + verb="Sign up" + onSignedIn={() => router.push("/admin")} + /> <HStack w="full" alignItems="center"> <Separator flex="1" /> From fd5e3894d4fc988338aea983cd354ee807374ebd Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 11:08:14 -0400 Subject: [PATCH 073/181] feat: announce the OSF sunset and keep re-authorization working lib/osf-sunset.js is the single source of truth for the wind-down: OSF_SUNSET_DATE (2026-11-16), a formatted label, and the two predicates that decide who sees the notices. isLegacyOsfExperiment treats an ABSENT storageProvider as OSF, mirroring getProviderForExperiment in functions/src/providers/index.ts -- if the two disagreed, the banner would go missing on exactly the oldest experiments. osfSunsetLabel formats in UTC deliberately. "2026-11-16" parses as UTC midnight, so formatting it in any US timezone -- where most of DataPipe's researchers are -- renders November 15, announcing a deadline a day earlier than the one agreed. Pinned by a test run under TZ=America/Los_Angeles. Adds OsfRelinkButton, extracted so OAuthTokenStatus can offer it too. That component previously told researchers with an expired grant to "sign out and sign back in with OSF" -- advice that stops working the day OSF sign-in is removed, silently killing any study whose token lapsed mid-wind-down. It now re-runs the authorization directly, which is the storage grant (the `linking` branch of oauth2-callback.ts) and never mints a session. Also makes the OSF sign-in button read as the legacy path it now is, and replaces two OSF-specific copy strings with provider-neutral ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/osf-sunset.test.js | 81 ++++++++++++++++++++++++++ components/OsfSunsetNotice.js | 47 +++++++++++++++ components/SignInWithOSF.js | 11 +++- components/account/DeleteAccount.js | 4 +- components/account/OAuthTokenStatus.js | 16 ++++- components/account/OsfRelinkButton.js | 52 +++++++++++++++++ components/account/SelectAuth.js | 28 ++------- components/dashboard/ExperimentInfo.js | 3 + lib/osf-sunset.js | 53 +++++++++++++++++ 9 files changed, 264 insertions(+), 31 deletions(-) create mode 100644 __tests__/osf-sunset.test.js create mode 100644 components/OsfSunsetNotice.js create mode 100644 components/account/OsfRelinkButton.js create mode 100644 lib/osf-sunset.js diff --git a/__tests__/osf-sunset.test.js b/__tests__/osf-sunset.test.js new file mode 100644 index 0000000..03a9531 --- /dev/null +++ b/__tests__/osf-sunset.test.js @@ -0,0 +1,81 @@ +import { + hasLegacyOsfConnection, + isLegacyOsfExperiment, + osfSunsetLabel, + OSF_SUNSET_DATE, +} from "../lib/osf-sunset"; + +describe("isLegacyOsfExperiment", () => { + it("is true for an explicit osf storageProvider", () => { + expect(isLegacyOsfExperiment({ storageProvider: "osf" })).toBe(true); + }); + + it("is true when storageProvider is ABSENT", () => { + // Experiments created before the provider-migration schema have no + // storageProvider field and always meant OSF. This mirrors + // getProviderForExperiment in functions/src/providers/index.ts, which + // applies the same legacy default -- if the two ever disagree, the + // banner would go missing on exactly the oldest experiments. + expect(isLegacyOsfExperiment({ osfFilesLink: "https://osf.io/x/" })).toBe( + true + ); + expect(isLegacyOsfExperiment({})).toBe(true); + }); + + it("is false for every other provider", () => { + for (const storageProvider of ["gdrive", "dataverse", "zenodo"]) { + expect(isLegacyOsfExperiment({ storageProvider })).toBe(false); + } + }); + + it("is false for no experiment at all", () => { + expect(isLegacyOsfExperiment(null)).toBe(false); + expect(isLegacyOsfExperiment(undefined)).toBe(false); + }); +}); + +describe("hasLegacyOsfConnection", () => { + it("detects both routes a researcher could have connected OSF by", () => { + // OAuth grant... + expect(hasLegacyOsfConnection({ authMethod: "osf" })).toBe(true); + expect(hasLegacyOsfConnection({ refreshToken: "rt" })).toBe(true); + expect(hasLegacyOsfConnection({ osfUserId: "abc12" })).toBe(true); + // ...and the pasted personal access token. + expect(hasLegacyOsfConnection({ osfToken: "encrypted" })).toBe(true); + }); + + it("is false for an account that never touched OSF", () => { + // A new researcher must never be shown the legacy OSF surfaces. Note the + // empty-string fields: signup used to seed exactly these. + expect( + hasLegacyOsfConnection({ + uid: "u1", + email: "a@b.edu", + experiments: [], + osfToken: "", + refreshToken: "", + }) + ).toBe(false); + expect(hasLegacyOsfConnection({})).toBe(false); + expect(hasLegacyOsfConnection(null)).toBe(false); + }); +}); + +describe("osfSunsetLabel", () => { + it("is pinned to the announced cutoff", () => { + expect(OSF_SUNSET_DATE).toBe("2026-11-16"); + }); + + it("renders the announced date, and does NOT slip a day west of UTC", () => { + const label = osfSunsetLabel(); + expect(label).toEqual(expect.any(String)); + expect(label).toContain("2026"); + // The day is the point of this assertion. "2026-11-16" parses as UTC + // midnight, so formatting it in a negative-offset zone (any US timezone, + // where most of DataPipe's researchers are) would render November 15 -- + // announcing a deadline one day earlier than the one agreed. osfSunsetLabel + // formats with timeZone: "UTC" precisely to prevent that, and this pins it. + expect(label).toContain("16"); + expect(label).not.toContain("15"); + }); +}); diff --git a/components/OsfSunsetNotice.js b/components/OsfSunsetNotice.js new file mode 100644 index 0000000..e3737e8 --- /dev/null +++ b/components/OsfSunsetNotice.js @@ -0,0 +1,47 @@ +import { Box, Text } from "@chakra-ui/react"; +import Link from "next/link"; +import { Link as ChakraLink } from "@chakra-ui/react"; +import { osfSunsetLabel } from "../lib/osf-sunset"; + +// Researcher-facing notice that an OSF-backed experiment is on borrowed time. +// Rendered on the dashboard (once, if any experiment still writes to OSF) and +// on each legacy experiment's own page. +// +// The deadline sentence appears only when a date has actually been set in +// lib/osf-sunset.js -- the call to migrate is real either way, and naming a +// placeholder date would be worse than naming none. +export default function OsfSunsetNotice({ scope = "experiment" }) { + const deadline = osfSunsetLabel(); + + const subject = + scope === "dashboard" + ? "Some of your experiments still send data to OSF." + : "This experiment sends data to OSF."; + + return ( + <Box + w="100%" + bg="orange.900" + border="1px solid" + borderColor="orange.500" + borderRadius="md" + px={4} + py={3} + > + <Text fontSize="sm"> + <Text as="span" fontWeight="semibold"> + OSF support is ending. + </Text>{" "} + {subject} OSF is shutting down its projects feature, so DataPipe can no + longer create new experiments on it + {deadline ? ` and will stop writing to it after ${deadline}` : ""}. Data + already on OSF is unaffected and stays in your OSF account. To keep + collecting, connect another storage provider in{" "} + <ChakraLink asChild color="brandOrange.300"> + <Link href="/admin/account">Account Settings</Link> + </ChakraLink>{" "} + and create a new experiment there. + </Text> + </Box> + ); +} diff --git a/components/SignInWithOSF.js b/components/SignInWithOSF.js index 1ed1d3c..aaba640 100644 --- a/components/SignInWithOSF.js +++ b/components/SignInWithOSF.js @@ -46,15 +46,20 @@ export default function SignInWithOSF() { )} <Button - colorPalette="blue" + variant="outline" loading={isLoading} loadingText="Redirecting to OSF..." onClick={handleOSFSignin} width="full" - size="lg" + size="sm" > - <OsfIcon /> Sign In with OSF + <OsfIcon /> Sign in with OSF </Button> + + <Text fontSize="xs" color="gray.400" textAlign="center"> + OSF sign-in is being retired. Sign in once more, then add another + provider from your dashboard to keep your account and experiments. + </Text> </VStack> ); } diff --git a/components/account/DeleteAccount.js b/components/account/DeleteAccount.js index df4fc96..053e7a1 100644 --- a/components/account/DeleteAccount.js +++ b/components/account/DeleteAccount.js @@ -65,8 +65,8 @@ export default function DeleteAccount({ setDeleting }) { deletion. </Text> <Text> - Deleting your DataPipe account will not affect any data on the - OSF. + Deleting your DataPipe account will not affect any data already + written to your storage provider. </Text> </Dialog.Body> diff --git a/components/account/OAuthTokenStatus.js b/components/account/OAuthTokenStatus.js index 0fd79cd..ee284de 100644 --- a/components/account/OAuthTokenStatus.js +++ b/components/account/OAuthTokenStatus.js @@ -13,6 +13,7 @@ import { Box } from "@chakra-ui/react"; import { CircleCheck, TriangleAlert } from "lucide-react"; +import OsfRelinkButton from "./OsfRelinkButton"; export default function OAuthTokenStatus() { const { user } = useContext(UserContext); @@ -100,9 +101,20 @@ export default function OAuthTokenStatus() { <Alert.Root status="error" size="sm"> <Alert.Indicator /> <Box> - <Alert.Title>Re-authentication Required</Alert.Title> + <Alert.Title>Re-authorization Required</Alert.Title> <Alert.Description> - Your OSF authorization has expired. Please sign out and sign back in with OSF to restore access. + <VStack align="start" gap={3} mt={1}> + <Text fontSize="sm"> + DataPipe's permission to write to your OSF account has + expired, so any experiment still sending data to OSF has + stopped. Re-authorize to restore it. + </Text> + {/* Deliberately a re-authorization, not "sign out and sign + back in with OSF" as this used to say: that advice depends + on OSF sign-in, which is being removed, and would strand + an in-flight study the day it goes. */} + <OsfRelinkButton>Re-authorize OSF</OsfRelinkButton> + </VStack> </Alert.Description> </Box> </Alert.Root> diff --git a/components/account/OsfRelinkButton.js b/components/account/OsfRelinkButton.js new file mode 100644 index 0000000..73e47f4 --- /dev/null +++ b/components/account/OsfRelinkButton.js @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { Button } from "@chakra-ui/react"; +import { OsfIcon } from "../OsfIcon"; + +// Re-runs the OSF authorization to restore WRITE access for an experiment +// that is still collecting. This is the storage grant, not sign-in: it takes +// the `linking` branch of functions/src/oauth2-callback.ts, which attaches +// fresh OSF tokens to the already-signed-in user and never mints a session. +// +// It has to survive the removal of OSF sign-in. OSF refresh tokens expire, +// and the advice this replaces ("sign out and sign back in with OSF") stops +// working the moment that flow is gone -- which would silently kill any +// in-flight study whose token lapsed during the wind-down. +export default function OsfRelinkButton({ children, ...buttonProps }) { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const stateRes = await fetch(process.env.NEXT_PUBLIC_GENERATE_STATE, { + method: "POST", + }); + if (!stateRes.ok) throw new Error("Failed to generate state"); + const { state } = await stateRes.json(); + + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem("osfAuthFlow", "linking"); + + const url = new URL( + `https://accounts.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/oauth2/authorize` + ); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", process.env.NEXT_PUBLIC_CLIENT_ID); + url.searchParams.set("redirect_uri", process.env.NEXT_PUBLIC_REDIRECT_URI); + url.searchParams.set("state", state); + url.searchParams.set("scope", "osf.full_write"); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("approval_prompt", "force"); + + window.location.href = url.toString(); + } catch (err) { + console.error("Failed to initiate OSF authorization:", err); + setIsLoading(false); + } + }; + + return ( + <Button variant="outline" size="md" loading={isLoading} onClick={handleClick} {...buttonProps}> + <OsfIcon /> {children} + </Button> + ); +} diff --git a/components/account/SelectAuth.js b/components/account/SelectAuth.js index 801efd0..31747f4 100644 --- a/components/account/SelectAuth.js +++ b/components/account/SelectAuth.js @@ -17,7 +17,7 @@ import { doc, setDoc } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; import { CircleCheck, TriangleAlert } from "lucide-react"; -import { OsfIcon } from "../OsfIcon"; +import OsfRelinkButton from "./OsfRelinkButton"; export default function SelectAuth() { const { user } = useContext(UserContext); @@ -42,26 +42,6 @@ export default function SelectAuth() { }, { merge: true }); } - const handleAuthClick = async () => { - try { - const stateRes = await fetch(process.env.NEXT_PUBLIC_GENERATE_STATE, { method: 'POST' }); - if (!stateRes.ok) throw new Error('Failed to generate state'); - const { state: redirectState } = await stateRes.json(); - - localStorage.setItem('latestCSRFToken', redirectState); - localStorage.setItem('osfAuthFlow', 'linking'); - - const clientId = process.env.NEXT_PUBLIC_CLIENT_ID; - const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URI; - const scope = "osf.full_write" - const base_url = `https://accounts.${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/oauth2/authorize`; - const url = `${base_url}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&state=${redirectState}&scope=${scope}&access_type=offline&approval_prompt=force`; - window.location.href = url; - } catch (err) { - console.error('Failed to initiate OSF auth:', err); - } - } - const handleSaveToken = async () => { const token = tokenRef.current?.value; setIsSubmittingToken(true); @@ -101,9 +81,9 @@ export default function SelectAuth() { {hasOAuthToken && <CircleCheck color="var(--chakra-colors-green-500)" size={18} />} {!hasOAuthToken && <TriangleAlert color="var(--chakra-colors-orange-500)" size={18} />} </HStack> - <Button colorPalette="blue" onClick={handleAuthClick} size="md"> - <OsfIcon /> {hasOAuthToken ? "Re-link" : "Link OSF Account"} - </Button> + <OsfRelinkButton> + {hasOAuthToken ? "Re-authorize OSF" : "Authorize OSF"} + </OsfRelinkButton> </HStack> <HStack justifyContent="flex-end" w="100%"> <Link diff --git a/components/dashboard/ExperimentInfo.js b/components/dashboard/ExperimentInfo.js index 3ba6df1..80233fe 100644 --- a/components/dashboard/ExperimentInfo.js +++ b/components/dashboard/ExperimentInfo.js @@ -2,6 +2,8 @@ import { Stack, HStack, Text, Link } from "@chakra-ui/react"; import { ExternalLink } from "lucide-react"; import { STORAGE_PROVIDERS } from "../../lib/provider-config"; +import { isLegacyOsfExperiment } from "../../lib/osf-sunset"; +import OsfSunsetNotice from "../OsfSunsetNotice"; export default function ExperimentInfo({ data }) { const provider = STORAGE_PROVIDERS[data.storageProvider]; @@ -11,6 +13,7 @@ export default function ExperimentInfo({ data }) { w="100%" gap={2} > + {isLegacyOsfExperiment(data) && <OsfSunsetNotice scope="experiment" />} <HStack justify="space-between" flexWrap="wrap" gap={1}> <Text color="gray.400" fontSize="sm">Experiment ID</Text> <Text fontSize="sm">{data.id}</Text> diff --git a/lib/osf-sunset.js b/lib/osf-sunset.js new file mode 100644 index 0000000..26c509a --- /dev/null +++ b/lib/osf-sunset.js @@ -0,0 +1,53 @@ +// Single source of truth for DataPipe's OSF wind-down. +// +// OSF is shutting down its projects feature. New experiments can no longer be +// created against OSF (enforced in firestore.rules, not just in the UI), but +// experiments already collecting data keep writing until the date below. +// Everything researcher-facing about the wind-down reads from here so the +// date is stated in exactly one place. + +// ISO date (YYYY-MM-DD) after which DataPipe stops writing to OSF, or null if +// no date has been announced yet. +// +// The code tolerates null (banners appear but name no deadline), which is how +// this shipped before the cutoff was agreed. It is set now, so every +// researcher-facing surface states the same date. +export const OSF_SUNSET_DATE = "2026-11-16"; + +// Human-readable form of OSF_SUNSET_DATE, or null when unset. +export function osfSunsetLabel() { + if (!OSF_SUNSET_DATE) return null; + // Parsed as UTC (the "YYYY-MM-DD" form always is) and formatted in UTC, so + // the date shown is the date written above regardless of the reader's + // timezone -- otherwise researchers west of UTC would see the day before. + return new Date(`${OSF_SUNSET_DATE}T00:00:00Z`).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); +} + +// True for an experiment that writes to OSF. Experiments created before the +// provider-migration schema have no storageProvider field at all and always +// meant OSF -- mirrors getProviderForExperiment in +// functions/src/providers/index.ts, which applies the same legacy default. +export function isLegacyOsfExperiment(experiment) { + if (!experiment) return false; + return !experiment.storageProvider || experiment.storageProvider === "osf"; +} + +// True when a users/{uid} document shows any OSF credential, by either route: +// the OAuth grant (authMethod/refreshToken) or the pasted personal access +// token (usingPersonalToken/osfToken). Used to decide whether to show the +// legacy OSF surfaces at all -- a researcher who never connected OSF should +// never see them. +export function hasLegacyOsfConnection(userDoc) { + if (!userDoc) return false; + return Boolean( + userDoc.authMethod === "osf" || + userDoc.osfUserId || + userDoc.refreshToken || + userDoc.osfToken + ); +} From 6ff21d0701e933b0e27146830e0e7a2b22429c09 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 11:08:28 -0400 Subject: [PATCH 074/181] feat: migrate OSF-era accounts onto a durable sign-in method Experiments are keyed by `owner: uid`, so any route that mints a fresh uid severs a researcher from their own data. linkWithPopup attaches a credential to the EXISTING Firebase user and leaves the uid alone, which is why Identity Platform was chosen over a second custom-token flow: no server-side account merge is needed at all. AddSignInMethodBanner triggers on providerData being EMPTY. That is exact rather than heuristic: OSF sign-in works by minting a Firebase custom token, and a custom-token session carries no federated provider and no password. Zero linked providers therefore means "this account is reachable by the flow being removed and nothing else" -- precisely the population that gets locked out. It disappears the moment they link anything, with no flag to write or maintain, and is not dismissible. LinkedAccounts lists methods from providerData crossed with the registry, and refuses to unlink the last one -- otherwise a researcher locks themselves out of an account that still owns their experiments. The account page now decides whether to offer the password form from providerData rather than the legacy users/{uid}.authMethod field, so a researcher who signed up with OSF and has since added a password still sees it. The OSF section moves below storage providers and appears only for accounts that actually connected OSF. scripts/backfill-osf-auth-emails.mjs is the escape hatch for researchers who never return before the cutoff. Their Auth records were created by createCustomToken and carry no email at all, so today they have no way to reclaim the account; copying the address from Firestore gives them password-reset and email-link routes back to the SAME uid. Dry-run by default. It refuses to guess on two populations and reports them for manual handling: synthetic user-<osfId>@osf.io placeholders (written when OSF's emails endpoint failed, not real inboxes) and addresses already owned by an email/password account. Replaces the dashboard's old banner, which urged researchers to switch between two OSF token methods -- both now legacy, so promoting either would push people further onto the platform they need to leave. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- components/account/AddSignInMethodBanner.js | 76 ++++++++ components/account/LinkedAccounts.js | 162 +++++++++++++++++ pages/admin/account.js | 81 ++++++--- pages/admin/index.js | 113 +++--------- scripts/backfill-osf-auth-emails.mjs | 183 ++++++++++++++++++++ 5 files changed, 496 insertions(+), 119 deletions(-) create mode 100644 components/account/AddSignInMethodBanner.js create mode 100644 components/account/LinkedAccounts.js create mode 100644 scripts/backfill-osf-auth-emails.mjs diff --git a/components/account/AddSignInMethodBanner.js b/components/account/AddSignInMethodBanner.js new file mode 100644 index 0000000..584f26b --- /dev/null +++ b/components/account/AddSignInMethodBanner.js @@ -0,0 +1,76 @@ +import { useContext, useState } from "react"; +import { Box, Text, VStack } from "@chakra-ui/react"; +import { UserContext } from "../../lib/context"; +import { linkedProviderIds } from "../../lib/auth-providers"; +import AuthProviderButtons from "../auth/AuthProviderButtons"; + +// Shown to researchers whose ONLY way in is the OSF sign-in that is being +// removed, prompting them to attach a durable provider before it goes. +// +// The trigger is `providerData` being empty, which is exact rather than +// heuristic: OSF sign-in works by minting a Firebase custom token +// (functions/src/oauth2-callback.ts), and a custom-token session carries no +// federated provider and no password. So zero linked providers means +// "this account can be reached by the OSF flow and nothing else" -- precisely +// the population that gets locked out at the cutoff. The moment they link +// anything the banner stops appearing, with no flag to write or maintain. +// +// Deliberately NOT dismissible: losing access to an account that still owns a +// researcher's experiments is not a nag, and the banner removes itself as +// soon as it is acted on. +export default function AddSignInMethodBanner() { + const { user } = useContext(UserContext); + + // The count is DERIVED from the user, not mirrored into state by an effect + // -- that would be a cascading render for a value already in hand. The one + // thing it cannot see is a link that just succeeded: linkWithPopup mutates + // providerData in place without re-emitting an auth state, so the handler + // records the post-link count here and it takes precedence. Tagged with the + // uid it describes so it can never bleed onto a different account. + const [afterLink, setAfterLink] = useState(null); + + if (!user) return null; + + const linkedCount = + afterLink?.uid === user.uid + ? afterLink.count + : linkedProviderIds(user).length; + + if (linkedCount > 0) return null; + + return ( + <Box + w="100%" + bg="orange.900" + border="1px solid" + borderColor="orange.500" + borderRadius="md" + px={4} + py={4} + > + <VStack align="stretch" gap={4}> + <Box> + <Text fontWeight="semibold" mb={1}> + Add a way to sign in + </Text> + <Text fontSize="sm" color="gray.200"> + You currently sign in to DataPipe through OSF, which is being + retired. Link another provider now and you will keep this account, + your experiments, and your settings exactly as they are. + </Text> + </Box> + + <AuthProviderButtons + mode="link" + verb="Link" + onSignedIn={(updated) => + setAfterLink({ + uid: updated.uid, + count: linkedProviderIds(updated).length, + }) + } + /> + </VStack> + </Box> + ); +} diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js new file mode 100644 index 0000000..8ad2dda --- /dev/null +++ b/components/account/LinkedAccounts.js @@ -0,0 +1,162 @@ +import { useContext, useState } from "react"; +import { HStack, VStack, Text, Button, Alert, Badge } from "@chakra-ui/react"; +import { linkWithPopup, unlink } from "firebase/auth"; +import { CircleCheck } from "lucide-react"; +import { UserContext } from "../../lib/context"; +import { auth } from "../../lib/firebase"; +import { + AUTH_PROVIDER_LIST, + PASSWORD_PROVIDER_ID, + canUnlink, + linkedProviderIds, +} from "../../lib/auth-providers"; +import { AUTH_PROVIDER_ICONS } from "../AuthProviderIcons"; +import { + isCancelledAuthError, + messageForAuthError, +} from "../../lib/auth-errors"; + +// Account-page section listing how this researcher can sign in, driven by +// Firebase's own record (user.providerData) crossed with AUTH_PROVIDERS. +// Firestore is deliberately not consulted: providerData IS the source of +// truth for sign-in methods, and the legacy users/{uid}.authMethod field +// describes only the OSF era. +// +// linkWithPopup attaches a credential to the EXISTING Firebase user, so the +// uid never changes. That matters more than it looks: experiments are keyed +// by `owner: uid`, so an account that gained its new sign-in method by any +// route that mints a fresh uid would be severed from its own data. +export default function LinkedAccounts() { + const { user } = useContext(UserContext); + + // The linked list is DERIVED from the user below rather than copied into + // state by an effect. link/unlink mutate providerData in place without + // re-emitting an auth state, so each call's return value is recorded here + // and takes precedence -- tagged with the uid it describes so it can never + // bleed onto a different account. + const [afterAction, setAfterAction] = useState(null); + const [pendingId, setPendingId] = useState(null); + const [error, setError] = useState(""); + + const handleLink = async (entry) => { + setPendingId(entry.id); + setError(""); + try { + const credential = await linkWithPopup( + auth.currentUser, + entry.makeProvider() + ); + setAfterAction({ + uid: credential.user.uid, + ids: linkedProviderIds(credential.user), + }); + } catch (err) { + if (!isCancelledAuthError(err?.code)) { + setError(messageForAuthError(err?.code, entry.name)); + } + } finally { + setPendingId(null); + } + }; + + const handleUnlink = async (entry) => { + setPendingId(entry.id); + setError(""); + try { + const updated = await unlink(auth.currentUser, entry.providerId); + setAfterAction({ uid: updated.uid, ids: linkedProviderIds(updated) }); + } catch (err) { + setError(messageForAuthError(err?.code, entry.name)); + } finally { + setPendingId(null); + } + }; + + if (!user) return null; + + const linkedIds = + afterAction?.uid === user.uid ? afterAction.ids : linkedProviderIds(user); + + const hasPassword = linkedIds.includes(PASSWORD_PROVIDER_ID); + + return ( + <VStack gap={3} w="100%" align="stretch"> + {error && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{error}</Text> + </Alert.Root> + )} + + {linkedIds.length === 1 && ( + <Text fontSize="sm" color="gray.400"> + You have one way to sign in. Adding a second means you keep access if + you ever lose the first. + </Text> + )} + + {AUTH_PROVIDER_LIST.map((entry) => { + const Icon = AUTH_PROVIDER_ICONS[entry.id]; + const linked = linkedIds.includes(entry.providerId); + // canUnlink counts the password method too, so a researcher with a + // password plus one federated provider can still drop the federated + // one. + const last = linked && !canUnlink(linkedIds, entry.providerId); + + return ( + <HStack + key={entry.id} + justifyContent="space-between" + w="100%" + flexWrap="wrap" + gap={3} + > + <HStack> + {Icon && <Icon />} + <Text fontSize="lg">{entry.name}</Text> + {linked && ( + <CircleCheck color="var(--chakra-colors-green-500)" size={18} /> + )} + </HStack> + + {linked ? ( + <Button + variant="outline" + size="sm" + disabled={last} + loading={pendingId === entry.id} + onClick={() => handleUnlink(entry)} + title={ + last + ? "This is your only way to sign in. Add another method before removing it." + : undefined + } + > + {last ? "Only sign-in method" : "Unlink"} + </Button> + ) : ( + <Button + colorPalette="brandTeal" + size="sm" + loading={pendingId === entry.id} + onClick={() => handleLink(entry)} + > + Link {entry.name} + </Button> + )} + </HStack> + ); + })} + + {hasPassword && ( + <HStack justifyContent="space-between" w="100%"> + <HStack> + <Text fontSize="lg">Email and password</Text> + <CircleCheck color="var(--chakra-colors-green-500)" size={18} /> + </HStack> + <Badge colorPalette="gray">Enabled</Badge> + </HStack> + )} + </VStack> + ); +} diff --git a/pages/admin/account.js b/pages/admin/account.js index 4ee7649..6fc115a 100644 --- a/pages/admin/account.js +++ b/pages/admin/account.js @@ -1,22 +1,43 @@ import AuthCheck from "../../components/AuthCheck"; -import { VStack, Heading, Text, Separator, Spinner, Center, Box } from "@chakra-ui/react"; +import { VStack, Heading, Text, Separator, Spinner, Center } from "@chakra-ui/react"; import ChangePassword from "../../components/account/ChangePassword"; import DeleteAccount from "../../components/account/DeleteAccount"; import { useState, useContext } from "react"; import SelectAuth from "../../components/account/SelectAuth"; +import LinkedAccounts from "../../components/account/LinkedAccounts"; import ProviderConnections from "../../components/account/ProviderConnections"; import { UserContext } from "../../lib/context"; import { useDocumentData } from "react-firebase-hooks/firestore"; import { doc } from "firebase/firestore"; import { db } from "../../lib/firebase"; import OAuthTokenStatus from "../../components/account/OAuthTokenStatus"; +import { + PASSWORD_PROVIDER_ID, + linkedProviderIds, +} from "../../lib/auth-providers"; +import { hasLegacyOsfConnection } from "../../lib/osf-sunset"; + +function SectionLabel({ children, color = "gray.500" }) { + return ( + <Text + fontSize="xs" + fontWeight="semibold" + textTransform="uppercase" + letterSpacing="wide" + color={color} + mb={3} + > + {children} + </Text> + ); +} export default function AccountPage({}) { const { user } = useContext(UserContext); - const [data, loading, error, snapshot, reload] = useDocumentData( - user?.uid ? doc(db, "users", user.uid) : null + const [data, loading] = useDocumentData( + user?.uid ? doc(db, "users", user.uid) : null ); const [deleting, setDeleting] = useState(false); @@ -29,47 +50,55 @@ export default function AccountPage({}) { ); } - // Determine user type: OAuth users have authMethod === 'osf' - const isOAuthUser = data?.authMethod === 'osf'; + // Whether to offer the password form is a question about SIGN-IN METHODS, + // so it is answered from Firebase's providerData rather than the legacy + // users/{uid}.authMethod field. A researcher who signed up with OSF and has + // since linked a password must see this; one who only ever used a + // federated provider has no password to change. + const hasPassword = linkedProviderIds(user).includes(PASSWORD_PROVIDER_ID); + + // The OSF section is legacy surface: it is shown only to researchers who + // actually connected OSF at some point, and disappears entirely for + // everyone else. New accounts never see it. + const showOsfSection = hasLegacyOsfConnection(data); + const isOsfOAuthUser = data?.authMethod === "osf"; return ( <AuthCheck fallbackRoute={deleting ? "/admin/deleted-account" : null}> <VStack gap={0} w="100%" maxW="560px" px={4} align="stretch"> <Heading mb={8}>Account Settings</Heading> - {/* OSF Connection Section */} - <Text fontSize="xs" fontWeight="semibold" textTransform="uppercase" letterSpacing="wide" color="gray.500" mb={3}> - OSF Connection - </Text> - {isOAuthUser ? ( - <OAuthTokenStatus /> - ) : ( - <SelectAuth /> - )} + {/* Sign-in methods */} + <SectionLabel>Sign-in Methods</SectionLabel> + <LinkedAccounts /> - {/* Storage Providers Section */} + {/* Storage Providers */} <Separator my={6} borderColor="whiteAlpha.200" /> - <Text fontSize="xs" fontWeight="semibold" textTransform="uppercase" letterSpacing="wide" color="gray.500" mb={3}> - Storage Providers - </Text> + <SectionLabel>Storage Providers</SectionLabel> <ProviderConnections /> - {/* Account Section - only for email users */} - {!isOAuthUser && ( + {/* OSF, legacy. Below the storage providers now, not above them -- + it serves in-flight experiments only. */} + {showOsfSection && ( + <> + <Separator my={6} borderColor="whiteAlpha.200" /> + <SectionLabel>OSF (Legacy)</SectionLabel> + {isOsfOAuthUser ? <OAuthTokenStatus /> : <SelectAuth />} + </> + )} + + {/* Account */} + {hasPassword && ( <> <Separator my={6} borderColor="whiteAlpha.200" /> - <Text fontSize="xs" fontWeight="semibold" textTransform="uppercase" letterSpacing="wide" color="gray.500" mb={3}> - Account - </Text> + <SectionLabel>Account</SectionLabel> <ChangePassword /> </> )} {/* Danger Zone */} <Separator my={6} borderColor="whiteAlpha.200" /> - <Text fontSize="xs" fontWeight="semibold" textTransform="uppercase" letterSpacing="wide" color="red.400" mb={3}> - Danger Zone - </Text> + <SectionLabel color="red.400">Danger Zone</SectionLabel> <DeleteAccount setDeleting={setDeleting} /> </VStack> </AuthCheck> diff --git a/pages/admin/index.js b/pages/admin/index.js index aee4cc8..35e53af 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -1,8 +1,8 @@ import AuthCheck from "../../components/AuthCheck"; import { collection, query, where, doc, deleteDoc } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; -import { useCollectionData, useDocumentData } from "react-firebase-hooks/firestore"; -import { useState, useSyncExternalStore } from "react"; +import { useCollectionData } from "react-firebase-hooks/firestore"; +import { useState } from "react"; import Link from "next/link"; import { Heading, @@ -17,107 +17,30 @@ import { Stack, Center, Card, - CloseButton, Link as ChakraLink, Tooltip, } from "@chakra-ui/react"; import { Trash2, Pencil } from "lucide-react"; +import AddSignInMethodBanner from "../../components/account/AddSignInMethodBanner"; +import OsfSunsetNotice from "../../components/OsfSunsetNotice"; +import { isLegacyOsfExperiment } from "../../lib/osf-sunset"; export default function AdminPage({}) { return ( <AuthCheck> <VStack gap={8} w="100%" maxW="960px" px={4}> - <OAuthBanner /> + <AddSignInMethodBanner /> <ExperimentList /> </VStack> </AuthCheck> ); } -// localStorage is external mutable state that does not exist during SSR, so -// it cannot be read in the render body or in a useState initializer. It used -// to be read in an effect that then called setState, which meant the banner's -// dismissed flag was wrong for one render: it defaulted to `true`, so a -// researcher who had NOT dismissed it got no banner until the effect ran and -// re-rendered. -// -// useSyncExternalStore reads the value during render on the client and falls -// back to getServerSnapshot on the server, with no intermediate wrong state. -// The listener set makes the banner disappear the instant it is dismissed -- -// a plain read would not re-render, since writing to localStorage is invisible -// to React. -const DISMISS_KEY = "datapipe-oauth-banner-dismissed"; -const dismissListeners = new Set(); - -function subscribeToDismissal(onStoreChange) { - dismissListeners.add(onStoreChange); - return () => dismissListeners.delete(onStoreChange); -} - -function dismissBanner() { - localStorage.setItem(DISMISS_KEY, "true"); - for (const listener of dismissListeners) listener(); -} - -// Booleans compare by value, so returning a fresh one each call is safe here; -// useSyncExternalStore only loops on a getSnapshot that returns a new OBJECT -// identity every time. -const getDismissed = () => localStorage.getItem(DISMISS_KEY) === "true"; -// Server-rendered markup omits the banner. Rendering it and then pulling it -// away from someone who had already dismissed it is the worse of the two. -const getDismissedOnServer = () => true; - -function OAuthBanner() { - const user = auth.currentUser; - const [userData] = useDocumentData(doc(db, "users", user.uid)); - const dismissed = useSyncExternalStore( - subscribeToDismissal, - getDismissed, - getDismissedOnServer - ); - - const hasOAuthConnection = userData?.refreshToken && userData?.authToken; - - if (dismissed || !userData || hasOAuthConnection) { - return null; - } - - const handleDismiss = () => { - dismissBanner(); - }; - - return ( - <Box - w="100%" - bg="brandTeal.900" - border="1px solid" - borderColor="brandTeal.600" - borderRadius="md" - px={4} - py={3} - position="relative" - > - <CloseButton - size="sm" - position="absolute" - right={2} - top={2} - onClick={handleDismiss} - /> - <Text pr={8}> - <strong>Simplify your setup:</strong> You can now link your OSF account - directly to DataPipe for automatic token management. Switch to one-click - authentication in your{" "} - <Link href="/admin/account"> - <Button variant="plain" colorPalette="brandTeal" fontSize="md" p={0} h="auto" minW={0}> - Account Settings - </Button> - </Link> - . - </Text> - </Box> - ); -} +// The dismissible banner that used to live here urged researchers to switch +// from an OSF personal access token to OSF one-click auth. Both sides of that +// choice are now legacy, so promoting either would be pushing people further +// onto the platform they need to leave. AddSignInMethodBanner above replaces +// it with the migration that actually matters. function ExperimentList() { const user = auth.currentUser; @@ -152,9 +75,9 @@ function ExperimentList() { No experiments yet </Heading> <Text color="gray.400" fontSize="sm" maxW="sm"> - Experiments connect your online study to an OSF project so - that data files are sent directly to OSF as participants - complete your task. + Experiments connect your online study to a storage provider + you control, so that data files are sent straight to your own + account as participants complete your task. </Text> <Link href="/admin/new"> @@ -181,6 +104,8 @@ function ExperimentList() { ); } + const hasLegacyOsfExperiment = querySnapshot.some(isLegacyOsfExperiment); + return ( <VStack gap={8} w="100%"> <Stack @@ -201,6 +126,8 @@ function ExperimentList() { </Link> </Stack> + {hasLegacyOsfExperiment && <OsfSunsetNotice scope="dashboard" />} + <VStack w="100%" gap={3}> {querySnapshot.map((exp) => ( <ExperimentItem key={exp.id} exp={exp} /> @@ -321,8 +248,8 @@ function DeleteAlertDialog({ exp }) { <Dialog.Body> <Text>Are you sure? This action is final.</Text> <Text> - Deleting the experiment will not delete any data that is already - on the OSF. + Deleting the experiment will not delete any data already + written to your storage provider. </Text> </Dialog.Body> diff --git a/scripts/backfill-osf-auth-emails.mjs b/scripts/backfill-osf-auth-emails.mjs new file mode 100644 index 0000000..0340ca9 --- /dev/null +++ b/scripts/backfill-osf-auth-emails.mjs @@ -0,0 +1,183 @@ +// Backfill email addresses onto the Firebase Auth records of OSF-era accounts. +// +// WHY THIS EXISTS +// +// Accounts created by "Sign in with OSF" were minted with +// auth.createCustomToken(uuid, ...) (functions/src/oauth2-callback.ts). A +// custom-token user record carries NO email, NO password and NO federated +// provider -- the researcher's address lives only in the Firestore document +// at users/{uid}. So once OSF sign-in is removed, an account whose owner never +// came back to link a provider has literally no way in: password reset has no +// address to send to, and signing in with Google would mint a DIFFERENT uid, +// severing them from experiments that reference `owner: uid`. +// +// Copying the address from Firestore onto the Auth record fixes that. It gives +// those researchers a self-service route back to the SAME uid: +// - "Forgot password" / email-link sign-in resolves to the existing record. +// - Google/GitHub sign-in with a matching address links to it cleanly. +// +// Run this BEFORE removing OSF sign-in, and before deleting +// functions/src/check-email-conflict.ts (which is what stops an email/password +// signup from colliding with one of these accounts in the meantime). +// +// Usage: +// node scripts/backfill-osf-auth-emails.mjs # dry run, changes nothing +// node scripts/backfill-osf-auth-emails.mjs --apply # actually writes +// +// Env: +// GOOGLE_APPLICATION_CREDENTIALS service-account key with Firebase Admin access +// FIREBASE_PROJECT_ID (optional) overrides the credential's project +// FIRESTORE_EMULATOR_HOST set by the emulator; safe to dry-run against +// +// Reports four categories, and never guesses: +// backfilled - Auth record now carries the Firestore address +// alreadySet - Auth record already had an email; left alone +// synthetic - address is the user-<osfId>@osf.io placeholder written when +// OSF's emails endpoint failed. Not a real inbox, so writing it +// would create a permanently unverifiable account. Needs manual +// handling. +// collision - another Auth record already owns that address (an +// email/password account). Writing it would fail anyway; +// merging the two is a judgement call, not a script's. + +import { initializeApp, applicationDefault } from "firebase-admin/app"; +import { getAuth } from "firebase-admin/auth"; +import { getFirestore } from "firebase-admin/firestore"; + +const apply = process.argv.includes("--apply"); + +// The fallback address oauth2-callback.ts writes when it cannot read a real +// one from OSF: `user-${osfUserId}@osf.io`. +const SYNTHETIC_EMAIL = /^user-[^@]+@osf\.io$/i; + +initializeApp({ + credential: applicationDefault(), + ...(process.env.FIREBASE_PROJECT_ID + ? { projectId: process.env.FIREBASE_PROJECT_ID } + : {}), +}); + +const auth = getAuth(); +const db = getFirestore(); + +const buckets = { + backfilled: [], + alreadySet: [], + synthetic: [], + collision: [], + noEmail: [], + noAuthRecord: [], + failed: [], +}; + +async function main() { + const snapshot = await db + .collection("users") + .where("authMethod", "==", "osf") + .get(); + + console.log( + `${apply ? "APPLY" : "DRY RUN"}: ${snapshot.size} OSF-era user document(s) found.\n` + ); + + for (const docSnapshot of snapshot.docs) { + const uid = docSnapshot.id; + const { email, displayName } = docSnapshot.data(); + + if (!email) { + buckets.noEmail.push({ uid }); + continue; + } + + if (SYNTHETIC_EMAIL.test(email)) { + buckets.synthetic.push({ uid, email }); + continue; + } + + let authRecord; + try { + authRecord = await auth.getUser(uid); + } catch (err) { + // A user document whose Auth record never materialized -- createCustomToken + // does not create one until the token is actually redeemed, so a signup + // that was abandoned mid-flow leaves exactly this. + if (err?.code === "auth/user-not-found") { + buckets.noAuthRecord.push({ uid, email }); + continue; + } + throw err; + } + + if (authRecord.email) { + buckets.alreadySet.push({ uid, email: authRecord.email }); + continue; + } + + // Check for an existing owner of this address BEFORE writing. updateUser + // would reject it anyway, but distinguishing "collision" from "failed" in + // the report is the whole point of running a dry pass first. + try { + const existing = await auth.getUserByEmail(email); + if (existing.uid !== uid) { + buckets.collision.push({ uid, email, conflictsWith: existing.uid }); + continue; + } + } catch (err) { + if (err?.code !== "auth/user-not-found") throw err; + // Not found is the good case: nobody owns this address. + } + + if (!apply) { + buckets.backfilled.push({ uid, email }); + continue; + } + + try { + await auth.updateUser(uid, { + email, + // Deliberately NOT verified. DataPipe never confirmed this address + // itself -- it came from OSF -- and marking it verified would let it + // stand in for proof of ownership on a later account link. + emailVerified: false, + ...(displayName && !authRecord.displayName ? { displayName } : {}), + }); + buckets.backfilled.push({ uid, email }); + } catch (err) { + buckets.failed.push({ uid, email, error: err?.message || String(err) }); + } + } + + report(); +} + +function report() { + const order = [ + ["backfilled", apply ? "Backfilled" : "Would backfill"], + ["alreadySet", "Already had an email (skipped)"], + ["synthetic", "Synthetic OSF placeholder address (NEEDS MANUAL HANDLING)"], + ["collision", "Address owned by another account (NEEDS MANUAL HANDLING)"], + ["noEmail", "No email on the user document (NEEDS MANUAL HANDLING)"], + ["noAuthRecord", "No Firebase Auth record (abandoned signup)"], + ["failed", "Failed"], + ]; + + console.log("\n=== Summary ==="); + for (const [key, label] of order) { + console.log(`${String(buckets[key].length).padStart(5)} ${label}`); + } + + for (const key of ["synthetic", "collision", "noEmail", "failed"]) { + if (buckets[key].length === 0) continue; + console.log(`\n--- ${key} ---`); + for (const row of buckets[key]) console.log(JSON.stringify(row)); + } + + if (!apply) { + console.log("\nDry run only -- nothing was written. Re-run with --apply."); + } +} + +main().catch((err) => { + console.error("Backfill aborted:", err); + process.exit(1); +}); From ecedc5bd17616596d8809980461ce749324eb54e Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 12:18:02 -0400 Subject: [PATCH 075/181] fix: outline buttons rendered near-black on the dark background The new "Sign in with ..." buttons, the OSF sign-in button, the OSF re-authorize button and the Unlink button were all effectively invisible: dark text on greyBackground (#1C1F22). Cause: Chakra v3's `outline` recipe sets `color: var(--chakra-colors-color-palette-fg)`. With no colorPalette that resolves to the DEFAULT GRAY palette's fg = gray.800. lib/theme.js already re-points the semantic `fg` token light, but not the gray palette's fg, which is what unpaletted recipes actually read. The non-obvious part is why the usual fix does not work. A plain `color="white"` prop -- the pattern used elsewhere in this codebase -- compiles into the SAME emotion class as the recipe, and the recipe's declaration is emitted AFTER it, so at equal specificity the recipe wins and the override silently does nothing. `css={{ color: "white" }}` behaves identically. Verified by reading the emitted stylesheet: the prop rule sat at byte offset 69676 and the recipe rule at 69819 for the identical class name. Fixed by doubling the selector (`&&`), giving 0-2-0 against the recipe's 0-1-0, which wins whatever the emission order. Confirmed against the rendered HTML: all four buttons on /signin and /signup now resolve to var(--chakra-colors-white). Shared as `outlineOnDark` from lib/theme.js so the four call sites cannot drift. NOTE: this same trap likely affects the pre-existing outline buttons in components/CopyButton.js and pages/admin/index.js, which use the color="white" prop that this change shows does not win. Not touched here -- they are behind auth and were not part of the reported problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- components/SignInWithOSF.js | 3 ++- components/account/LinkedAccounts.js | 3 ++- components/account/OsfRelinkButton.js | 9 ++++++++- components/auth/AuthProviderButtons.js | 3 ++- lib/theme.js | 28 ++++++++++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/components/SignInWithOSF.js b/components/SignInWithOSF.js index aaba640..ad4040c 100644 --- a/components/SignInWithOSF.js +++ b/components/SignInWithOSF.js @@ -6,6 +6,7 @@ import { VStack } from "@chakra-ui/react"; import { OsfIcon } from "./OsfIcon"; +import { outlineOnDark } from "../lib/theme"; export default function SignInWithOSF() { const [isLoading, setIsLoading] = useState(false); @@ -46,7 +47,7 @@ export default function SignInWithOSF() { )} <Button - variant="outline" + {...outlineOnDark} loading={isLoading} loadingText="Redirecting to OSF..." onClick={handleOSFSignin} diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index 8ad2dda..d96c72d 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -11,6 +11,7 @@ import { linkedProviderIds, } from "../../lib/auth-providers"; import { AUTH_PROVIDER_ICONS } from "../AuthProviderIcons"; +import { outlineOnDark } from "../../lib/theme"; import { isCancelledAuthError, messageForAuthError, @@ -121,7 +122,7 @@ export default function LinkedAccounts() { {linked ? ( <Button - variant="outline" + {...outlineOnDark} size="sm" disabled={last} loading={pendingId === entry.id} diff --git a/components/account/OsfRelinkButton.js b/components/account/OsfRelinkButton.js index 73e47f4..eb0cd11 100644 --- a/components/account/OsfRelinkButton.js +++ b/components/account/OsfRelinkButton.js @@ -1,6 +1,7 @@ import { useState } from "react"; import { Button } from "@chakra-ui/react"; import { OsfIcon } from "../OsfIcon"; +import { outlineOnDark } from "../../lib/theme"; // Re-runs the OSF authorization to restore WRITE access for an experiment // that is still collecting. This is the storage grant, not sign-in: it takes @@ -45,7 +46,13 @@ export default function OsfRelinkButton({ children, ...buttonProps }) { }; return ( - <Button variant="outline" size="md" loading={isLoading} onClick={handleClick} {...buttonProps}> + <Button + {...outlineOnDark} + size="md" + loading={isLoading} + onClick={handleClick} + {...buttonProps} + > <OsfIcon /> {children} </Button> ); diff --git a/components/auth/AuthProviderButtons.js b/components/auth/AuthProviderButtons.js index b39637c..bb3c46b 100644 --- a/components/auth/AuthProviderButtons.js +++ b/components/auth/AuthProviderButtons.js @@ -2,6 +2,7 @@ import { useState } from "react"; import { Button, Alert, Text, VStack } from "@chakra-ui/react"; import { linkWithPopup, signInWithPopup } from "firebase/auth"; import { auth } from "../../lib/firebase"; +import { outlineOnDark } from "../../lib/theme"; import { AUTH_PROVIDER_LIST } from "../../lib/auth-providers"; import { AUTH_PROVIDER_ICONS } from "../AuthProviderIcons"; import { @@ -74,7 +75,7 @@ export default function AuthProviderButtons({ return ( <Button key={entry.id} - variant="outline" + {...outlineOnDark} width="full" size="lg" loading={pendingId === entry.id} diff --git a/lib/theme.js b/lib/theme.js index 260380b..ef6be42 100644 --- a/lib/theme.js +++ b/lib/theme.js @@ -141,3 +141,31 @@ const config = defineConfig({ }); export const system = createSystem(defaultConfig, config); + +// Props for an outline Button on this app's permanently-dark background. +// +// Chakra v3's `outline` recipe sets `color: var(--chakra-colors-color-palette-fg)`. +// With no colorPalette that resolves to the default gray palette's fg = +// gray.800 -- near-black on greyBackground (#1C1F22), i.e. an invisible button. +// Note this theme already re-points the SEMANTIC `fg` token light (above); what +// it does not re-point is the gray PALETTE's fg, which is what unpaletted +// recipes actually read. +// +// The `&&` is load-bearing. A plain `color="white"` prop, and an equivalent +// `css={{ color: "white" }}`, both compile into the SAME emotion class as the +// recipe, and the recipe's declaration is emitted AFTER theirs -- so at equal +// specificity the recipe wins and the override silently does nothing. (Verified +// against the emitted stylesheet: the prop rule sat at a lower byte offset than +// the recipe rule for the identical class name.) Doubling the selector raises +// specificity to 0-2-0 against the recipe's 0-1-0, which wins whatever the +// emission order. +export const outlineOnDark = { + variant: "outline", + css: { + "&&": { + color: "white", + borderColor: "whiteAlpha.400", + _hover: { bg: "whiteAlpha.200", borderColor: "white" }, + }, + }, +}; From 31a200dce2968e78ba4b500f029c39fae3af4d07 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 13:25:12 -0400 Subject: [PATCH 076/181] fix: re-point the gray palette for this app's dark surface Fixes the cause rather than the symptom, and reverts the per-button workaround from the previous commit. DataPipe renders on a permanently dark surface (globalCss sets body to greyBackground #1C1F22) while Chakra's colour mode is light, so every palette resolves its _light values. For the GRAY palette those are built for a white page: gray.fg = gray.800 = #27272a, which is 1.11:1 against the body. Any component that does not name a colorPalette falls back to gray, so an unstyled `variant="outline"` or `variant="ghost"` button was effectively invisible. gray.fg is now gray.200 -- 13.05:1. The whole palette moves, not just fg: variants read different tokens, so lightening fg alone would leave `subtle` painting light text on the near-white gray.subtle background. gray.border is gray.500 rather than gray.600 because WCAG 1.4.11 asks 3.0 for non-text UI boundaries and 600 measured 2.14:1 while 500 gives 3.43:1. CORRECTION to the previous commit's reasoning. It claimed a style prop cannot beat the recipe, and that the outline buttons in Footer.js, CopyButton.js and dashboard/Title.js were therefore also broken. That was wrong -- Footer's button carries color="white" and does render white. The real defect was narrower: the new auth buttons set NO colour at all and so inherited the bad default. Components that set an explicit colour were never affected and are unchanged here. The `&&` double-specificity override and the `outlineOnDark` helper it needed are removed; the four call sites are plain `variant="outline"` again and are legible from the theme alone, as is any future one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- components/SignInWithOSF.js | 3 +- components/account/LinkedAccounts.js | 3 +- components/account/OsfRelinkButton.js | 3 +- components/auth/AuthProviderButtons.js | 3 +- lib/theme.js | 70 ++++++++++++++++---------- 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/components/SignInWithOSF.js b/components/SignInWithOSF.js index ad4040c..aaba640 100644 --- a/components/SignInWithOSF.js +++ b/components/SignInWithOSF.js @@ -6,7 +6,6 @@ import { VStack } from "@chakra-ui/react"; import { OsfIcon } from "./OsfIcon"; -import { outlineOnDark } from "../lib/theme"; export default function SignInWithOSF() { const [isLoading, setIsLoading] = useState(false); @@ -47,7 +46,7 @@ export default function SignInWithOSF() { )} <Button - {...outlineOnDark} + variant="outline" loading={isLoading} loadingText="Redirecting to OSF..." onClick={handleOSFSignin} diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index d96c72d..8ad2dda 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -11,7 +11,6 @@ import { linkedProviderIds, } from "../../lib/auth-providers"; import { AUTH_PROVIDER_ICONS } from "../AuthProviderIcons"; -import { outlineOnDark } from "../../lib/theme"; import { isCancelledAuthError, messageForAuthError, @@ -122,7 +121,7 @@ export default function LinkedAccounts() { {linked ? ( <Button - {...outlineOnDark} + variant="outline" size="sm" disabled={last} loading={pendingId === entry.id} diff --git a/components/account/OsfRelinkButton.js b/components/account/OsfRelinkButton.js index eb0cd11..9ce9f48 100644 --- a/components/account/OsfRelinkButton.js +++ b/components/account/OsfRelinkButton.js @@ -1,7 +1,6 @@ import { useState } from "react"; import { Button } from "@chakra-ui/react"; import { OsfIcon } from "../OsfIcon"; -import { outlineOnDark } from "../../lib/theme"; // Re-runs the OSF authorization to restore WRITE access for an experiment // that is still collecting. This is the storage grant, not sign-in: it takes @@ -47,7 +46,7 @@ export default function OsfRelinkButton({ children, ...buttonProps }) { return ( <Button - {...outlineOnDark} + variant="outline" size="md" loading={isLoading} onClick={handleClick} diff --git a/components/auth/AuthProviderButtons.js b/components/auth/AuthProviderButtons.js index bb3c46b..b39637c 100644 --- a/components/auth/AuthProviderButtons.js +++ b/components/auth/AuthProviderButtons.js @@ -2,7 +2,6 @@ import { useState } from "react"; import { Button, Alert, Text, VStack } from "@chakra-ui/react"; import { linkWithPopup, signInWithPopup } from "firebase/auth"; import { auth } from "../../lib/firebase"; -import { outlineOnDark } from "../../lib/theme"; import { AUTH_PROVIDER_LIST } from "../../lib/auth-providers"; import { AUTH_PROVIDER_ICONS } from "../AuthProviderIcons"; import { @@ -75,7 +74,7 @@ export default function AuthProviderButtons({ return ( <Button key={entry.id} - {...outlineOnDark} + variant="outline" width="full" size="lg" loading={pendingId === entry.id} diff --git a/lib/theme.js b/lib/theme.js index ef6be42..dedc612 100644 --- a/lib/theme.js +++ b/lib/theme.js @@ -83,6 +83,44 @@ const config = defineConfig({ border: { DEFAULT: { value: { _light: "{colors.gray.400}", _dark: "{colors.gray.400}" } }, }, + // DataPipe renders on a permanently dark surface (see globalCss below: + // body is greyBackground #1C1F22), but Chakra's mode is light, so every + // palette resolves its _light values. For the GRAY palette those are + // built for a white page and are wrong here -- most damagingly + // gray.fg = gray.800 = #27272a, a 1.09:1 contrast ratio against the + // body. Any component that does not name a colorPalette falls back to + // gray, so `variant="outline"` and `variant="ghost"` buttons across the + // app rendered near-black on near-black and were effectively invisible. + // + // Components that DO set an explicit color (components/Footer.js, + // CopyButton.js, dashboard/Title.js) were never affected and are + // unchanged by this -- a style prop still overrides the recipe. What + // this fixes is the default, so a button no longer has to remember to + // opt out of an invisible one. + // + // Measured against the body: gray.800 gave 1.11:1, gray.200 gives + // 13.05:1. + // + // These re-point the whole gray palette to a dark-surface reading. The + // whole palette, not just fg: variants read different tokens, and + // lightening fg alone would leave `subtle` painting light text on the + // near-white gray.subtle background. + gray: { + fg: { value: { _light: "{colors.gray.200}", _dark: "{colors.gray.200}" } }, + subtle: { value: { _light: "{colors.gray.800}", _dark: "{colors.gray.800}" } }, + muted: { value: { _light: "{colors.gray.700}", _dark: "{colors.gray.700}" } }, + emphasized: { value: { _light: "{colors.gray.600}", _dark: "{colors.gray.600}" } }, + // Inverted against the page: a light chip with dark text, so a solid + // gray button reads as a button instead of a hole. + solid: { value: { _light: "{colors.gray.200}", _dark: "{colors.gray.200}" } }, + contrast: { value: { _light: "{colors.gray.900}", _dark: "{colors.gray.900}" } }, + // gray.500 (#71717a) rather than the darker gray.600: measured + // against the #1C1F22 body, 600 gives 2.14:1 and 500 gives 3.43:1, + // and WCAG 1.4.11 wants 3.0 for non-text UI boundaries like a + // button outline. + border: { value: { _light: "{colors.gray.500}", _dark: "{colors.gray.500}" } }, + focusRing: { value: { _light: "{colors.gray.400}", _dark: "{colors.gray.400}" } }, + }, brandOrange: { contrast: { value: { _light: "white", _dark: "white" } }, fg: { value: { _light: "{colors.brandOrange.500}", _dark: "{colors.brandOrange.300}" } }, @@ -142,30 +180,8 @@ const config = defineConfig({ export const system = createSystem(defaultConfig, config); -// Props for an outline Button on this app's permanently-dark background. -// -// Chakra v3's `outline` recipe sets `color: var(--chakra-colors-color-palette-fg)`. -// With no colorPalette that resolves to the default gray palette's fg = -// gray.800 -- near-black on greyBackground (#1C1F22), i.e. an invisible button. -// Note this theme already re-points the SEMANTIC `fg` token light (above); what -// it does not re-point is the gray PALETTE's fg, which is what unpaletted -// recipes actually read. -// -// The `&&` is load-bearing. A plain `color="white"` prop, and an equivalent -// `css={{ color: "white" }}`, both compile into the SAME emotion class as the -// recipe, and the recipe's declaration is emitted AFTER theirs -- so at equal -// specificity the recipe wins and the override silently does nothing. (Verified -// against the emitted stylesheet: the prop rule sat at a lower byte offset than -// the recipe rule for the identical class name.) Doubling the selector raises -// specificity to 0-2-0 against the recipe's 0-1-0, which wins whatever the -// emission order. -export const outlineOnDark = { - variant: "outline", - css: { - "&&": { - color: "white", - borderColor: "whiteAlpha.400", - _hover: { bg: "whiteAlpha.200", borderColor: "white" }, - }, - }, -}; +// NOTE: an `outlineOnDark` helper used to live here, spreading a +// double-specificity `&&` override onto each outline button to beat the recipe. +// It is gone because the gray palette re-pointing above fixes the cause rather +// than each symptom -- a plain `variant="outline"` is now legible by default, +// app-wide, including on buttons nobody has touched. From 29f45c542d8141ec81eafc2ac4c32b48eecfb504 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 14:21:19 -0400 Subject: [PATCH 077/181] fix: give account-linking failures their own wording Linking GitHub to an ORCID-created account failed with auth/email-already-in-use and showed "Sign in using the method you set up originally, then add GitHub from your account settings" -- advice for a visitor at the sign-in page, shown to a researcher who was already signed in and already on the account page. The underlying refusal is correct. Firebase allows one account per email address, so a GitHub credential whose email belongs to another DataPipe account cannot be attached to this one. ORCID is what makes this reachable: it returns no email claim, so an ORCID account starts with none and the first federated provider linked to it is the first chance for a collision with an account the researcher already had. messageForAuthError now takes the operation as a third argument and the three call sites pass it. Only the collision case and the fallback differ; everything else reads the same in all three modes. The linking copy says outright that there is no self-service merge -- experiments are keyed by `owner: uid`, so combining accounts is a maintainer operation -- rather than implying a retry will help. Also adds auth/no-such-provider, which unlink can raise and which was falling through to a message about sign-in. lib/auth-errors.js had no test file; it has one now, pinning the wording that went wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/auth-errors.test.js | 115 +++++++++++++++++++++++++ components/account/LinkedAccounts.js | 4 +- components/auth/AuthProviderButtons.js | 2 +- lib/auth-errors.js | 35 +++++++- 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 __tests__/auth-errors.test.js diff --git a/__tests__/auth-errors.test.js b/__tests__/auth-errors.test.js new file mode 100644 index 0000000..b8ee4aa --- /dev/null +++ b/__tests__/auth-errors.test.js @@ -0,0 +1,115 @@ +import { + isCancelledAuthError, + messageForAuthError, +} from "../lib/auth-errors"; + +describe("isCancelledAuthError", () => { + it("treats the three popup-dismissal codes as cancellations", () => { + expect(isCancelledAuthError("auth/popup-closed-by-user")).toBe(true); + expect(isCancelledAuthError("auth/cancelled-popup-request")).toBe(true); + expect(isCancelledAuthError("auth/user-cancelled")).toBe(true); + }); + + it("does not swallow real failures", () => { + expect(isCancelledAuthError("auth/email-already-in-use")).toBe(false); + expect(isCancelledAuthError("auth/popup-blocked")).toBe(false); + expect(isCancelledAuthError(undefined)).toBe(false); + }); +}); + +describe("messageForAuthError email collisions", () => { + // The bug this pins: an ORCID account has no email address, so the first + // federated provider a researcher links is the first thing that can collide + // with an account they already had. Linking then fails with + // auth/email-already-in-use, and the sign-in wording told them to "sign in + // using the method you set up originally, then add GitHub from your account + // settings" -- which is precisely what they had just done. + it("does not send a linking researcher back to the account page they are on", () => { + const message = messageForAuthError( + "auth/email-already-in-use", + "GitHub", + "link" + ); + expect(message).not.toMatch(/account settings/i); + expect(message).toMatch(/different DataPipe account/i); + expect(message).toMatch(/GitHub/); + }); + + it("keeps the front-door advice on the sign-in path", () => { + const message = messageForAuthError( + "auth/email-already-in-use", + "Google", + "signIn" + ); + expect(message).toMatch(/account settings/i); + }); + + it("defaults to the sign-in wording when no mode is given", () => { + expect(messageForAuthError("auth/email-already-in-use", "Google")).toBe( + messageForAuthError("auth/email-already-in-use", "Google", "signIn") + ); + }); + + it("gives the same linking advice for the sign-in-only sibling code", () => { + expect( + messageForAuthError( + "auth/account-exists-with-different-credential", + "GitHub", + "link" + ) + ).toBe(messageForAuthError("auth/email-already-in-use", "GitHub", "link")); + }); + + it("says there is no self-service merge rather than implying a retry helps", () => { + const message = messageForAuthError( + "auth/email-already-in-use", + "GitHub", + "link" + ); + expect(message).toMatch(/Contact page/); + expect(message).not.toMatch(/try again/i); + }); +}); + +describe("messageForAuthError fallbacks", () => { + it("names the operation that actually failed", () => { + expect(messageForAuthError("auth/internal-error", "GitHub", "signIn")).toMatch( + /sign-in/ + ); + expect(messageForAuthError("auth/internal-error", "GitHub", "link")).toMatch( + /link your GitHub account/ + ); + expect( + messageForAuthError("auth/internal-error", "GitHub", "unlink") + ).toMatch(/unlink your GitHub account/); + }); + + it("falls back to sign-in wording for an unrecognised mode", () => { + expect(messageForAuthError("auth/internal-error", "GitHub", "wat")).toMatch( + /sign-in/ + ); + }); + + it("has a usable message when the provider name is unknown", () => { + expect(messageForAuthError("auth/internal-error")).toMatch( + /that provider/ + ); + }); +}); + +describe("messageForAuthError mode-independent codes", () => { + it.each([ + ["auth/credential-already-in-use", /already linked to a DataPipe account/], + ["auth/provider-already-linked", /already linked to a DataPipe account/], + ["auth/popup-blocked", /blocked the sign-in window/], + ["auth/operation-not-allowed", /not enabled for DataPipe/], + ["auth/no-such-provider", /not linked to this account/], + ["auth/unauthorized-domain", /not authorized for sign-in/], + ["auth/network-request-failed", /Check your connection/], + ["auth/requires-recent-login", /sign out and sign back in/], + ])("%s reads the same in every mode", (code, pattern) => { + for (const mode of ["signIn", "link", "unlink"]) { + expect(messageForAuthError(code, "GitHub", mode)).toMatch(pattern); + } + }); +}); diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index 8ad2dda..523c27e 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -52,7 +52,7 @@ export default function LinkedAccounts() { }); } catch (err) { if (!isCancelledAuthError(err?.code)) { - setError(messageForAuthError(err?.code, entry.name)); + setError(messageForAuthError(err?.code, entry.name, "link")); } } finally { setPendingId(null); @@ -66,7 +66,7 @@ export default function LinkedAccounts() { const updated = await unlink(auth.currentUser, entry.providerId); setAfterAction({ uid: updated.uid, ids: linkedProviderIds(updated) }); } catch (err) { - setError(messageForAuthError(err?.code, entry.name)); + setError(messageForAuthError(err?.code, entry.name, "unlink")); } finally { setPendingId(null); } diff --git a/components/auth/AuthProviderButtons.js b/components/auth/AuthProviderButtons.js index b39637c..c5a5dd3 100644 --- a/components/auth/AuthProviderButtons.js +++ b/components/auth/AuthProviderButtons.js @@ -53,7 +53,7 @@ export default function AuthProviderButtons({ } } catch (err) { if (!isCancelledAuthError(err?.code)) { - setError(messageForAuthError(err?.code, entry.name)); + setError(messageForAuthError(err?.code, entry.name, mode)); } } finally { setPendingId(null); diff --git a/lib/auth-errors.js b/lib/auth-errors.js index 1251554..1a63c7e 100644 --- a/lib/auth-errors.js +++ b/lib/auth-errors.js @@ -18,14 +18,40 @@ export function isCancelledAuthError(code) { return CANCELLED.has(code); } -export function messageForAuthError(code, providerName = "that provider") { +// The same code means different things depending on which operation raised it, +// and the advice has to differ with it. `auth/email-already-in-use` from the +// sign-in page means "you already have an account, go in the front door"; the +// identical code from the account page means "you are already inside, and this +// credential belongs to someone else's front door". Telling a researcher who +// is sitting in their account settings to go to their account settings is how +// this went wrong the first time. +const FALLBACK = { + signIn: (name) => `Could not complete ${name} sign-in. Please try again.`, + link: (name) => `Could not link your ${name} account. Please try again.`, + unlink: (name) => `Could not unlink your ${name} account. Please try again.`, +}; + +export function messageForAuthError( + code, + providerName = "that provider", + mode = "signIn" +) { switch (code) { case "auth/account-exists-with-different-credential": case "auth/email-already-in-use": // Deliberately does not name the other method. Firebase's email // enumeration protection makes fetchSignInMethodsForEmail return // nothing, so any specific claim here would be a guess. - return `An account already exists with this email address. Sign in using the method you set up originally, then add ${providerName} from your account settings.`; + // + // Firebase allows one account per email address, so linking a + // credential whose email is already spoken for is refused outright. + // There is no self-service merge -- experiments are keyed by + // `owner: uid` and moving them between accounts is a maintainer + // operation -- so the copy has to say that rather than imply a retry + // will help. + return mode === "link" + ? `Your ${providerName} account's email address already belongs to a different DataPipe account, so it can't be linked to this one. Sign out and sign in to that account instead, or link a ${providerName} account that uses a different email address. If you need two accounts combined, get in touch through the Contact page.` + : `An account already exists with this email address. Sign in using the method you set up originally, then add ${providerName} from your account settings.`; case "auth/credential-already-in-use": case "auth/provider-already-linked": @@ -37,6 +63,9 @@ export function messageForAuthError(code, providerName = "that provider") { case "auth/operation-not-allowed": return `${providerName} sign-in is not enabled for DataPipe yet. Please try another method.`; + case "auth/no-such-provider": + return `${providerName} is not linked to this account.`; + case "auth/unauthorized-domain": return "This site is not authorized for sign-in. Please report this to the DataPipe maintainers."; @@ -47,6 +76,6 @@ export function messageForAuthError(code, providerName = "that provider") { return "For security, please sign out and sign back in before changing your sign-in methods."; default: - return `Could not complete ${providerName} sign-in. Please try again.`; + return (FALLBACK[mode] || FALLBACK.signIn)(providerName); } } From 2b40584d54f3c4981fe69a943d6f0b72ac69c693 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 14:25:15 -0400 Subject: [PATCH 078/181] fix: give account-linking failures their own wording (#162) Linking GitHub to an ORCID-created account failed with auth/email-already-in-use and showed "Sign in using the method you set up originally, then add GitHub from your account settings" -- advice for a visitor at the sign-in page, shown to a researcher who was already signed in and already on the account page. The underlying refusal is correct. Firebase allows one account per email address, so a GitHub credential whose email belongs to another DataPipe account cannot be attached to this one. ORCID is what makes this reachable: it returns no email claim, so an ORCID account starts with none and the first federated provider linked to it is the first chance for a collision with an account the researcher already had. messageForAuthError now takes the operation as a third argument and the three call sites pass it. Only the collision case and the fallback differ; everything else reads the same in all three modes. The linking copy says outright that there is no self-service merge -- experiments are keyed by `owner: uid`, so combining accounts is a maintainer operation -- rather than implying a retry will help. Also adds auth/no-such-provider, which unlink can raise and which was falling through to a message about sign-in. lib/auth-errors.js had no test file; it has one now, pinning the wording that went wrong. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --- __tests__/auth-errors.test.js | 115 +++++++++++++++++++++++++ components/account/LinkedAccounts.js | 4 +- components/auth/AuthProviderButtons.js | 2 +- lib/auth-errors.js | 35 +++++++- 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 __tests__/auth-errors.test.js diff --git a/__tests__/auth-errors.test.js b/__tests__/auth-errors.test.js new file mode 100644 index 0000000..b8ee4aa --- /dev/null +++ b/__tests__/auth-errors.test.js @@ -0,0 +1,115 @@ +import { + isCancelledAuthError, + messageForAuthError, +} from "../lib/auth-errors"; + +describe("isCancelledAuthError", () => { + it("treats the three popup-dismissal codes as cancellations", () => { + expect(isCancelledAuthError("auth/popup-closed-by-user")).toBe(true); + expect(isCancelledAuthError("auth/cancelled-popup-request")).toBe(true); + expect(isCancelledAuthError("auth/user-cancelled")).toBe(true); + }); + + it("does not swallow real failures", () => { + expect(isCancelledAuthError("auth/email-already-in-use")).toBe(false); + expect(isCancelledAuthError("auth/popup-blocked")).toBe(false); + expect(isCancelledAuthError(undefined)).toBe(false); + }); +}); + +describe("messageForAuthError email collisions", () => { + // The bug this pins: an ORCID account has no email address, so the first + // federated provider a researcher links is the first thing that can collide + // with an account they already had. Linking then fails with + // auth/email-already-in-use, and the sign-in wording told them to "sign in + // using the method you set up originally, then add GitHub from your account + // settings" -- which is precisely what they had just done. + it("does not send a linking researcher back to the account page they are on", () => { + const message = messageForAuthError( + "auth/email-already-in-use", + "GitHub", + "link" + ); + expect(message).not.toMatch(/account settings/i); + expect(message).toMatch(/different DataPipe account/i); + expect(message).toMatch(/GitHub/); + }); + + it("keeps the front-door advice on the sign-in path", () => { + const message = messageForAuthError( + "auth/email-already-in-use", + "Google", + "signIn" + ); + expect(message).toMatch(/account settings/i); + }); + + it("defaults to the sign-in wording when no mode is given", () => { + expect(messageForAuthError("auth/email-already-in-use", "Google")).toBe( + messageForAuthError("auth/email-already-in-use", "Google", "signIn") + ); + }); + + it("gives the same linking advice for the sign-in-only sibling code", () => { + expect( + messageForAuthError( + "auth/account-exists-with-different-credential", + "GitHub", + "link" + ) + ).toBe(messageForAuthError("auth/email-already-in-use", "GitHub", "link")); + }); + + it("says there is no self-service merge rather than implying a retry helps", () => { + const message = messageForAuthError( + "auth/email-already-in-use", + "GitHub", + "link" + ); + expect(message).toMatch(/Contact page/); + expect(message).not.toMatch(/try again/i); + }); +}); + +describe("messageForAuthError fallbacks", () => { + it("names the operation that actually failed", () => { + expect(messageForAuthError("auth/internal-error", "GitHub", "signIn")).toMatch( + /sign-in/ + ); + expect(messageForAuthError("auth/internal-error", "GitHub", "link")).toMatch( + /link your GitHub account/ + ); + expect( + messageForAuthError("auth/internal-error", "GitHub", "unlink") + ).toMatch(/unlink your GitHub account/); + }); + + it("falls back to sign-in wording for an unrecognised mode", () => { + expect(messageForAuthError("auth/internal-error", "GitHub", "wat")).toMatch( + /sign-in/ + ); + }); + + it("has a usable message when the provider name is unknown", () => { + expect(messageForAuthError("auth/internal-error")).toMatch( + /that provider/ + ); + }); +}); + +describe("messageForAuthError mode-independent codes", () => { + it.each([ + ["auth/credential-already-in-use", /already linked to a DataPipe account/], + ["auth/provider-already-linked", /already linked to a DataPipe account/], + ["auth/popup-blocked", /blocked the sign-in window/], + ["auth/operation-not-allowed", /not enabled for DataPipe/], + ["auth/no-such-provider", /not linked to this account/], + ["auth/unauthorized-domain", /not authorized for sign-in/], + ["auth/network-request-failed", /Check your connection/], + ["auth/requires-recent-login", /sign out and sign back in/], + ])("%s reads the same in every mode", (code, pattern) => { + for (const mode of ["signIn", "link", "unlink"]) { + expect(messageForAuthError(code, "GitHub", mode)).toMatch(pattern); + } + }); +}); diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index 8ad2dda..523c27e 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -52,7 +52,7 @@ export default function LinkedAccounts() { }); } catch (err) { if (!isCancelledAuthError(err?.code)) { - setError(messageForAuthError(err?.code, entry.name)); + setError(messageForAuthError(err?.code, entry.name, "link")); } } finally { setPendingId(null); @@ -66,7 +66,7 @@ export default function LinkedAccounts() { const updated = await unlink(auth.currentUser, entry.providerId); setAfterAction({ uid: updated.uid, ids: linkedProviderIds(updated) }); } catch (err) { - setError(messageForAuthError(err?.code, entry.name)); + setError(messageForAuthError(err?.code, entry.name, "unlink")); } finally { setPendingId(null); } diff --git a/components/auth/AuthProviderButtons.js b/components/auth/AuthProviderButtons.js index b39637c..c5a5dd3 100644 --- a/components/auth/AuthProviderButtons.js +++ b/components/auth/AuthProviderButtons.js @@ -53,7 +53,7 @@ export default function AuthProviderButtons({ } } catch (err) { if (!isCancelledAuthError(err?.code)) { - setError(messageForAuthError(err?.code, entry.name)); + setError(messageForAuthError(err?.code, entry.name, mode)); } } finally { setPendingId(null); diff --git a/lib/auth-errors.js b/lib/auth-errors.js index 1251554..1a63c7e 100644 --- a/lib/auth-errors.js +++ b/lib/auth-errors.js @@ -18,14 +18,40 @@ export function isCancelledAuthError(code) { return CANCELLED.has(code); } -export function messageForAuthError(code, providerName = "that provider") { +// The same code means different things depending on which operation raised it, +// and the advice has to differ with it. `auth/email-already-in-use` from the +// sign-in page means "you already have an account, go in the front door"; the +// identical code from the account page means "you are already inside, and this +// credential belongs to someone else's front door". Telling a researcher who +// is sitting in their account settings to go to their account settings is how +// this went wrong the first time. +const FALLBACK = { + signIn: (name) => `Could not complete ${name} sign-in. Please try again.`, + link: (name) => `Could not link your ${name} account. Please try again.`, + unlink: (name) => `Could not unlink your ${name} account. Please try again.`, +}; + +export function messageForAuthError( + code, + providerName = "that provider", + mode = "signIn" +) { switch (code) { case "auth/account-exists-with-different-credential": case "auth/email-already-in-use": // Deliberately does not name the other method. Firebase's email // enumeration protection makes fetchSignInMethodsForEmail return // nothing, so any specific claim here would be a guess. - return `An account already exists with this email address. Sign in using the method you set up originally, then add ${providerName} from your account settings.`; + // + // Firebase allows one account per email address, so linking a + // credential whose email is already spoken for is refused outright. + // There is no self-service merge -- experiments are keyed by + // `owner: uid` and moving them between accounts is a maintainer + // operation -- so the copy has to say that rather than imply a retry + // will help. + return mode === "link" + ? `Your ${providerName} account's email address already belongs to a different DataPipe account, so it can't be linked to this one. Sign out and sign in to that account instead, or link a ${providerName} account that uses a different email address. If you need two accounts combined, get in touch through the Contact page.` + : `An account already exists with this email address. Sign in using the method you set up originally, then add ${providerName} from your account settings.`; case "auth/credential-already-in-use": case "auth/provider-already-linked": @@ -37,6 +63,9 @@ export function messageForAuthError(code, providerName = "that provider") { case "auth/operation-not-allowed": return `${providerName} sign-in is not enabled for DataPipe yet. Please try another method.`; + case "auth/no-such-provider": + return `${providerName} is not linked to this account.`; + case "auth/unauthorized-domain": return "This site is not authorized for sign-in. Please report this to the DataPipe maintainers."; @@ -47,6 +76,6 @@ export function messageForAuthError(code, providerName = "that provider") { return "For security, please sign out and sign back in before changing your sign-in methods."; default: - return `Could not complete ${providerName} sign-in. Please try again.`; + return (FALLBACK[mode] || FALLBACK.signIn)(providerName); } } From 2a3864a93eaaea8066d69dd6f788b029801070dc Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 16:13:11 -0400 Subject: [PATCH 079/181] fix: delete accounts server-side, and purge everything they own Account deletion ran client-side -- deleteUser(auth.currentUser) destroyed the Firebase Auth record first, and cleanup hung off the onUserDeleted trigger afterwards. That order cannot be made safe: once the auth record is gone, a cleanup that does not run leaves the researcher's data with no owner and no way for them to sign back in and retry. datapipe-test carries the residue. Two users/ documents belong to uids with no auth record, and so do two experiments -- one of them still `active`. An orphaned active experiment is not inert: api-data.ts persists each submission to Cloud Storage (persistPending) BEFORE it checks that the owner still exists, so every submission to an orphan leaves a file behind and then answers 400. Deletion now runs in functions/src/delete-account.ts: purge first, delete the auth record only once the purge returns. Failing now leaves the account intact and the operation retryable. The purge itself moves to purge-user-data.ts and fixes two gaps in what the trigger used to do: - It found experiments through users/{uid}.experiments, a client-maintained array. Anything missing from it survived its owner. It now queries `where owner == uid`, which cannot drift. - It deleted the experiment document but not the filenameClaims subcollection beneath it. Firestore does not cascade. It also now clears uploadQueue entries and pending-data/ objects, neither of which was touched before. onUserDeleted stays as a backstop for deletions that never reach the endpoint -- the console, the Admin SDK, a support action. purgeUserData is idempotent, so the trigger firing after deleteAccount has already purged is harmless. Moving the call server-side would have quietly dropped the recent-login requirement that client-side deleteUser enforced for us, so the endpoint checks the token's auth_time against Firebase's own five-minute threshold and verifies with checkRevoked. The client maps that to "sign out and sign back in" instead of the old bare tooltip, which showed a raw Firebase error message in red 12px text. functions/scripts/purge-orphaned-users.mjs cleans up what the old order already left behind: dry-run by default, and it treats an account as orphaned only on an explicit auth/user-not-found -- any other error is reported as undetermined and skipped. Both admin-SDK scripts move under functions/, where firebase-admin actually resolves. They imported it from scripts/, which has no node_modules, so neither could ever have run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- components/account/DeleteAccount.js | 146 +++++++----- firebase.json | 4 + .../scripts}/backfill-osf-auth-emails.mjs | 4 +- functions/scripts/purge-orphaned-users.mjs | 191 +++++++++++++++ .../purge-user-data-emulator.test.js | 217 ++++++++++++++++++ functions/src/delete-account.ts | 80 +++++++ functions/src/index.ts | 2 + functions/src/on-user-deleted.ts | 47 ++-- functions/src/purge-user-data.ts | 133 +++++++++++ 9 files changed, 734 insertions(+), 90 deletions(-) rename {scripts => functions/scripts}/backfill-osf-auth-emails.mjs (97%) create mode 100644 functions/scripts/purge-orphaned-users.mjs create mode 100644 functions/src/__tests__/purge-user-data-emulator.test.js create mode 100644 functions/src/delete-account.ts create mode 100644 functions/src/purge-user-data.ts diff --git a/components/account/DeleteAccount.js b/components/account/DeleteAccount.js index 053e7a1..2f7b379 100644 --- a/components/account/DeleteAccount.js +++ b/components/account/DeleteAccount.js @@ -1,29 +1,54 @@ -import { useState, useContext } from "react"; -import { UserContext } from "../../lib/context"; +import { useState } from "react"; import { HStack, + VStack, Button, Text, + Alert, Dialog, - Tooltip, } from "@chakra-ui/react"; import { auth } from "../../lib/firebase"; -import { deleteUser } from "firebase/auth"; import { useRouter } from "next/router"; +// Deletion runs server-side (functions/src/delete-account.ts) rather than +// through deleteUser() here. The client SDK can only delete the auth record, +// and it is the auth record that has to go LAST: the researcher's experiments, +// queued uploads, pending submissions and stored provider credentials all key +// off the uid, and destroying the account first means a failed cleanup strands +// them with no owner and no way to sign back in and retry. export default function DeleteAccount({ setDeleting }) { - const { user } = useContext(UserContext); const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); const router = useRouter(); const deleteAccount = async function () { + setIsSubmitting(true); try { - await deleteUser(auth.currentUser); + // Not forced: a refreshed token carries the same auth_time, so it would + // not get past the endpoint's recent-login check anyway. + const idToken = await auth.currentUser.getIdToken(); + const response = await fetch("/api/deleteaccount", { + method: "POST", + headers: { Authorization: `Bearer ${idToken}` }, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error( + body.code === "requires-recent-login" + ? "For security, sign out and sign back in, then delete your account." + : body.error || + "Could not delete your account. Nothing was lost -- please try again." + ); + } + + // The auth record is gone; drop the local session so the app does not + // keep acting on a user that no longer exists. + await auth.signOut().catch(() => {}); router.push("/admin/deleted-account"); } catch (error) { setDeleting(false); @@ -33,62 +58,65 @@ export default function DeleteAccount({ setDeleting }) { }; return ( - <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> - <Text fontSize={"lg"}>Delete DataPipe Account</Text> - <HStack> - {deleteError && ( - <Tooltip.Root> - <Tooltip.Trigger asChild> - <Text fontSize="sm" color="red.400" cursor="default">Failed</Text> - </Tooltip.Trigger> - <Tooltip.Positioner> - <Tooltip.Content>{deleteError}</Tooltip.Content> - </Tooltip.Positioner> - </Tooltip.Root> - )} - <Button loading={isSubmitting} onClick={() => setOpen(true)} colorPalette="red"> + <VStack w="100%" align="stretch" gap={3}> + {deleteError && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{deleteError}</Text> + </Alert.Root> + )} + + <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> + <Text fontSize={"lg"}>Delete DataPipe Account</Text> + <Button + loading={isSubmitting} + onClick={() => setOpen(true)} + colorPalette="red" + > Delete Account </Button> - </HStack> - <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> - <Dialog.Backdrop /> - <Dialog.Positioner> - <Dialog.Content bg="greyBackground" color="white"> - <Dialog.Header fontSize="lg" fontWeight="bold"> - Delete Account - </Dialog.Header> - <Dialog.Body> - <Text mb={4}> - Are you sure? This action is final. We cannot recover any - experiments that are associated with this account after - deletion. - </Text> - <Text> - Deleting your DataPipe account will not affect any data already - written to your storage provider. - </Text> - </Dialog.Body> + <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content bg="greyBackground" color="white"> + <Dialog.Header fontSize="lg" fontWeight="bold"> + Delete Account + </Dialog.Header> + + <Dialog.Body> + <Text mb={4}> + Are you sure? This action is final. We cannot recover any + experiments that are associated with this account after + deletion. + </Text> + <Text> + Deleting your DataPipe account will not affect any data + already written to your storage provider. + </Text> + </Dialog.Body> - <Dialog.Footer> - <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> - Cancel - </Button> - <Button - colorPalette="red" - onClick={() => { - setDeleting(true); - setOpen(false); - deleteAccount(); - }} - ml={3} - > - Delete - </Button> - </Dialog.Footer> - </Dialog.Content> - </Dialog.Positioner> - </Dialog.Root> - </HStack> + <Dialog.Footer> + <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> + Cancel + </Button> + <Button + colorPalette="red" + onClick={() => { + setDeleting(true); + setOpen(false); + setDeleteError(null); + deleteAccount(); + }} + ml={3} + > + Delete + </Button> + </Dialog.Footer> + </Dialog.Content> + </Dialog.Positioner> + </Dialog.Root> + </HStack> + </VStack> ); } diff --git a/firebase.json b/firebase.json index 9fb5245..4bf2f74 100644 --- a/firebase.json +++ b/firebase.json @@ -77,6 +77,10 @@ { "source": "/api/providersetupwarnings", "function": "providersetupwarnings" + }, + { + "source": "/api/deleteaccount", + "function": "deleteaccount" } ] }, diff --git a/scripts/backfill-osf-auth-emails.mjs b/functions/scripts/backfill-osf-auth-emails.mjs similarity index 97% rename from scripts/backfill-osf-auth-emails.mjs rename to functions/scripts/backfill-osf-auth-emails.mjs index 0340ca9..de5e259 100644 --- a/scripts/backfill-osf-auth-emails.mjs +++ b/functions/scripts/backfill-osf-auth-emails.mjs @@ -21,8 +21,8 @@ // signup from colliding with one of these accounts in the meantime). // // Usage: -// node scripts/backfill-osf-auth-emails.mjs # dry run, changes nothing -// node scripts/backfill-osf-auth-emails.mjs --apply # actually writes +// node functions/scripts/backfill-osf-auth-emails.mjs # dry run, changes nothing +// node functions/scripts/backfill-osf-auth-emails.mjs --apply # actually writes // // Env: // GOOGLE_APPLICATION_CREDENTIALS service-account key with Firebase Admin access diff --git a/functions/scripts/purge-orphaned-users.mjs b/functions/scripts/purge-orphaned-users.mjs new file mode 100644 index 0000000..698e08a --- /dev/null +++ b/functions/scripts/purge-orphaned-users.mjs @@ -0,0 +1,191 @@ +// Find and remove data belonging to accounts that no longer exist in Firebase +// Auth. +// +// WHY THIS EXISTS +// +// Account deletion used to run client-side: deleteUser(auth.currentUser) +// destroyed the Auth record first, and cleanup hung off the onUserDeleted +// trigger afterwards. When that cleanup did not run, or ran against a stale +// users/{uid}.experiments array, the data outlived the account with no owner +// left to notice. datapipe-test carries the proof -- two users/ documents and +// two experiments (one of them still `active`) belonging to uids that have no +// Auth record. +// +// An orphaned experiment is not inert. It still resolves in /api/data, and +// api-data.ts persists the submission to Cloud Storage BEFORE it checks that +// the owner exists, so every submission to an orphan leaves a file behind and +// then answers 400. That accrues storage indefinitely for a study nobody owns. +// +// functions/src/delete-account.ts fixes the ordering going forward (purge +// first, delete the Auth record last). This script cleans up what the old +// order already left behind. It should need to be run once per project. +// +// Usage: +// node functions/scripts/purge-orphaned-users.mjs # dry run, changes nothing +// node functions/scripts/purge-orphaned-users.mjs --apply # actually deletes +// +// Env: +// GOOGLE_APPLICATION_CREDENTIALS service-account key with Firebase Admin access +// FIREBASE_PROJECT_ID (optional) overrides the credential's project +// +// SAFETY: an account is treated as orphaned only when getUser(uid) raises +// auth/user-not-found. Any other error -- rate limit, transport failure, +// permission problem -- is reported as `undetermined` and skipped, because +// mistaking a live account for a dead one here would delete a researcher's +// study. + +import { initializeApp, applicationDefault } from "firebase-admin/app"; +import { getAuth } from "firebase-admin/auth"; +import { getFirestore } from "firebase-admin/firestore"; +import { getStorage } from "firebase-admin/storage"; + +const apply = process.argv.includes("--apply"); + +const projectId = process.env.FIREBASE_PROJECT_ID; +initializeApp({ + credential: applicationDefault(), + ...(projectId ? { projectId } : {}), + storageBucket: `${projectId || ""}.appspot.com`, +}); + +const auth = getAuth(); +const db = getFirestore(); +const bucket = getStorage().bucket(); + +async function authRecordState(uid) { + try { + await auth.getUser(uid); + return "live"; + } catch (e) { + if (e?.errorInfo?.code === "auth/user-not-found") return "missing"; + console.warn(` ! could not resolve ${uid}: ${e?.message || e}`); + return "undetermined"; + } +} + +// Every uid that owns something, whether or not it still has a users/ doc. +// Collected from both directions because the two can disagree -- that +// disagreement is the bug. +async function collectCandidateUids() { + const uids = new Set(); + const userDocs = await db.collection("users").get(); + for (const doc of userDocs.docs) uids.add(doc.id); + const experiments = await db.collection("experiments").get(); + for (const doc of experiments.docs) { + const owner = doc.data().owner; + if (owner) uids.add(owner); + } + return [...uids]; +} + +async function describe(uid) { + const owned = await db + .collection("experiments") + .where("owner", "==", uid) + .get(); + const queued = await db + .collection("uploadQueue") + .where("owner", "==", uid) + .get(); + const userDoc = await db.collection("users").doc(uid).get(); + + let pendingFiles = 0; + for (const doc of owned.docs) { + const [files] = await bucket.getFiles({ + prefix: `pending-data/${doc.id}/`, + }); + pendingFiles += files.length; + } + + return { + experiments: owned.docs.map((d) => ({ + id: d.id, + title: d.data().title, + active: d.data().active === true, + })), + queueEntries: queued.size, + pendingFiles, + email: userDoc.exists ? userDoc.data().email : "(no users/ document)", + }; +} + +async function purge(uid, summary) { + const batch = db.batch(); + for (const exp of summary.experiments) { + const claims = await db + .collection("experiments") + .doc(exp.id) + .collection("filenameClaims") + .get(); + for (const claim of claims.docs) batch.delete(claim.ref); + + const [files] = await bucket.getFiles({ prefix: `pending-data/${exp.id}/` }); + for (const file of files) await file.delete({ ignoreNotFound: true }); + + batch.delete(db.collection("experiments").doc(exp.id)); + batch.delete(db.collection("metadata").doc(exp.id)); + batch.delete(db.collection("logs").doc(exp.id)); + } + const queued = await db + .collection("uploadQueue") + .where("owner", "==", uid) + .get(); + for (const doc of queued.docs) batch.delete(doc.ref); + batch.delete(db.collection("users").doc(uid)); + await batch.commit(); +} + +const report = { orphaned: [], live: 0, undetermined: [] }; + +const candidates = await collectCandidateUids(); +console.log( + `${apply ? "APPLY" : "DRY RUN"}: checking ${candidates.length} uid(s)\n` +); + +for (const uid of candidates) { + const state = await authRecordState(uid); + if (state === "live") { + report.live += 1; + continue; + } + if (state === "undetermined") { + report.undetermined.push(uid); + continue; + } + + const summary = await describe(uid); + report.orphaned.push({ uid, ...summary }); + + console.log(`ORPHAN ${uid} (${summary.email})`); + for (const exp of summary.experiments) { + console.log( + ` experiment ${exp.id} "${exp.title}"${exp.active ? " [ACTIVE - still accepting data]" : ""}` + ); + } + if (summary.queueEntries) console.log(` ${summary.queueEntries} queue entries`); + if (summary.pendingFiles) console.log(` ${summary.pendingFiles} pending files`); + + if (apply) { + await purge(uid, summary); + console.log(" purged"); + } + console.log(""); +} + +console.log("---"); +console.log(`live accounts: ${report.live}`); +console.log(`orphaned accounts: ${report.orphaned.length}`); +console.log( + ` experiments: ${report.orphaned.reduce((n, o) => n + o.experiments.length, 0)}` + + ` (${report.orphaned.reduce((n, o) => n + o.experiments.filter((e) => e.active).length, 0)} active)` +); +console.log( + ` pending files: ${report.orphaned.reduce((n, o) => n + o.pendingFiles, 0)}` +); +console.log(`undetermined: ${report.undetermined.length}`); +if (report.undetermined.length) { + console.log(` ${report.undetermined.join("\n ")}`); +} +if (!apply && report.orphaned.length) { + console.log("\nNothing was changed. Re-run with --apply to delete."); +} diff --git a/functions/src/__tests__/purge-user-data-emulator.test.js b/functions/src/__tests__/purge-user-data-emulator.test.js new file mode 100644 index 0000000..3b9dc6a --- /dev/null +++ b/functions/src/__tests__/purge-user-data-emulator.test.js @@ -0,0 +1,217 @@ +/** + * @jest-environment node + */ + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +// app.js (imported transitively below) calls initializeApp() with no args and +// reads the default bucket from FIREBASE_CONFIG -- set before those imports. +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +const { randomUUID } = require("crypto"); +const { getFirestore } = require("firebase-admin/firestore"); +const { getStorage } = require("firebase-admin/storage"); +const { purgeUserData } = require("../../lib/purge-user-data.js"); + +jest.setTimeout(30000); + +const db = getFirestore(); +const bucket = getStorage().bucket(); + +// Every document this suite creates is namespaced by a per-run uid so it can +// never collide with, or delete, anything belonging to a suite running in +// parallel. See the note in scheduled-pending-recovery-emulator.test.js. +function makeUid() { + return `purge-test-${randomUUID()}`; +} + +async function seedExperiment(uid, experimentID, { active = true } = {}) { + await db.collection("experiments").doc(experimentID).set({ + active, + owner: uid, + title: "Purge fixture", + storageProvider: "gdrive", + }); + await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc("claim-one") + .set({ filename: "subject-1.json", claimedAt: Date.now() }); + await db.collection("metadata").doc(experimentID).set({ owner: uid }); + await db.collection("logs").doc(experimentID).set({ owner: uid }); + await bucket + .file(`pending-data/${experimentID}/subject-1.json_123`) + .save(JSON.stringify({ experimentID, filename: "s.json", data: "[]" }), { + contentType: "application/json", + }); +} + +async function exists(ref) { + return (await ref.get()).exists; +} + +describe("purgeUserData", () => { + const strays = []; + + afterEach(async () => { + // Only what this suite created and the purge did not remove. + while (strays.length) { + await strays.pop().delete().catch(() => {}); + } + }); + + it("removes every trace of the account", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + await db + .collection("users") + .doc(uid) + .set({ uid, email: "purge@example.com", experiments: [experimentID] }); + const queueDocId = `queue-${randomUUID()}`; + await db + .collection("uploadQueue") + .doc(queueDocId) + .set({ owner: uid, experimentID, status: "pending" }); + + const counts = await purgeUserData(uid); + + expect(counts).toMatchObject({ + experiments: 1, + filenameClaims: 1, + metadata: 1, + logs: 1, + queueEntries: 1, + pendingFiles: 1, + userDocument: 1, + }); + + expect(await exists(db.collection("users").doc(uid))).toBe(false); + expect(await exists(db.collection("experiments").doc(experimentID))).toBe( + false + ); + expect(await exists(db.collection("metadata").doc(experimentID))).toBe( + false + ); + expect(await exists(db.collection("logs").doc(experimentID))).toBe(false); + expect(await exists(db.collection("uploadQueue").doc(queueDocId))).toBe( + false + ); + + const [pending] = await bucket.getFiles({ + prefix: `pending-data/${experimentID}/`, + }); + expect(pending).toHaveLength(0); + }); + + // The regression that left a live experiment behind in datapipe-test: the + // old implementation read users/{uid}.experiments, so anything missing from + // that array survived its owner -- and a surviving `active` experiment still + // accepts submissions and writes them to Cloud Storage. + it("deletes experiments missing from the users/{uid}.experiments array", async () => { + const uid = makeUid(); + const listed = `exp-${randomUUID()}`; + const drifted = `exp-${randomUUID()}`; + await seedExperiment(uid, listed); + await seedExperiment(uid, drifted); + // The array knows about only one of the two. + await db + .collection("users") + .doc(uid) + .set({ uid, email: "drift@example.com", experiments: [listed] }); + + const counts = await purgeUserData(uid); + + expect(counts.experiments).toBe(2); + expect(await exists(db.collection("experiments").doc(drifted))).toBe(false); + }); + + // Deleting a Firestore document does not delete its subcollections. + it("clears the filenameClaims subcollection under each experiment", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + + await purgeUserData(uid); + + const claims = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .get(); + expect(claims.empty).toBe(true); + }); + + it("works when the user document is already gone", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + // No users/{uid} doc at all -- the orphan state already in datapipe-test. + + const counts = await purgeUserData(uid); + + expect(counts.experiments).toBe(1); + expect(counts.userDocument).toBe(0); + }); + + // deleteAccount purges and then deletes the auth record, which fires + // onUserDeleted, which purges again. + it("is idempotent", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + await db + .collection("users") + .doc(uid) + .set({ uid, email: "twice@example.com", experiments: [experimentID] }); + + await purgeUserData(uid); + const second = await purgeUserData(uid); + + expect(second).toMatchObject({ + experiments: 0, + filenameClaims: 0, + metadata: 0, + logs: 0, + queueEntries: 0, + pendingFiles: 0, + userDocument: 0, + }); + }); + + it("leaves another researcher's data untouched", async () => { + const victim = makeUid(); + const bystander = makeUid(); + const victimExp = `exp-${randomUUID()}`; + const bystanderExp = `exp-${randomUUID()}`; + await seedExperiment(victim, victimExp); + await seedExperiment(bystander, bystanderExp); + const bystanderUserRef = db.collection("users").doc(bystander); + await bystanderUserRef.set({ + uid: bystander, + email: "bystander@example.com", + experiments: [bystanderExp], + }); + strays.push(bystanderUserRef); + strays.push(db.collection("experiments").doc(bystanderExp)); + strays.push(db.collection("metadata").doc(bystanderExp)); + strays.push(db.collection("logs").doc(bystanderExp)); + + await purgeUserData(victim); + + expect(await exists(db.collection("experiments").doc(bystanderExp))).toBe( + true + ); + expect(await exists(bystanderUserRef)).toBe(true); + const [pending] = await bucket.getFiles({ + prefix: `pending-data/${bystanderExp}/`, + }); + expect(pending).toHaveLength(1); + await Promise.all(pending.map((f) => f.delete())); + }); +}); diff --git a/functions/src/delete-account.ts b/functions/src/delete-account.ts new file mode 100644 index 0000000..63e2787 --- /dev/null +++ b/functions/src/delete-account.ts @@ -0,0 +1,80 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { auth } from "./app.js"; +import { purgeUserData } from "./purge-user-data.js"; + +// Account deletion, moved server-side. +// +// It used to be a bare client-side deleteUser(auth.currentUser), with the +// cleanup hanging off the onUserDeleted auth trigger. That arrangement has an +// ordering problem that no amount of care in the trigger can fix: the auth +// record is destroyed first, so if the cleanup does not run -- or runs +// partially -- the researcher's data is stranded with no owner and no way for +// them to sign in and try again. datapipe-test still carries the residue: +// user documents and a still-`active` experiment belonging to accounts that no +// longer exist. +// +// Here the order is inverted. Purge first, delete the auth record only once +// the purge has returned. A failure now leaves the account intact and the +// operation retryable, which is the safe direction to fail in. +// +// onUserDeleted is kept as a backstop for deletions that do not come through +// this endpoint (the Firebase console, the Admin SDK, a support action). + +// Firebase's own threshold for auth/requires-recent-login. Client-side +// deleteUser enforced this for us; server-side nothing does, so the check has +// to be explicit or moving the call server-side would quietly weaken it -- a +// stolen session token would be enough to destroy an account. +const MAX_AUTH_AGE_SECONDS = 5 * 60; + +export const deleteAccount = onRequest({ cors: true }, async (req, res) => { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + let uid: string; + let authTime: number; + try { + const idToken = authHeader.split("Bearer ")[1]; + // checkRevoked: a researcher who signed out everywhere should not be able + // to delete the account with a token minted before that. + const decodedToken = await auth.verifyIdToken(idToken, true); + uid = decodedToken.uid; + authTime = decodedToken.auth_time; + } catch { + res.status(401).json({ error: "Invalid authentication token" }); + return; + } + + const tokenAgeSeconds = Date.now() / 1000 - authTime; + if (tokenAgeSeconds > MAX_AUTH_AGE_SECONDS) { + // A distinct code so the client can say "sign in again" rather than + // showing a generic failure. + res.status(403).json({ + error: "Please sign in again before deleting your account.", + code: "requires-recent-login", + }); + return; + } + + try { + const counts = await purgeUserData(uid); + await auth.deleteUser(uid); + res.status(200).json({ success: true, deleted: counts }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + console.error(`Account deletion failed for ${uid}: ${detail}`); + // The account still exists and still owns whatever survived, so the + // researcher can retry. Say so rather than leaving them guessing. + res.status(500).json({ + error: + "Could not finish deleting your account. Nothing was lost -- please try again.", + }); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index efb568b..7fd382e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ import { connectProvider, connectStaticTokenProvider, disconnectProvider } from import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; +import { deleteAccount } from "./delete-account.js"; import { createExperiment } from "./create-experiment.js"; import { getProviderAccessToken } from "./get-provider-access-token.js"; import { providerSetupWarnings } from "./provider-setup-warnings.js"; @@ -41,6 +42,7 @@ export { saveOsfToken as saveosftoken, getOsfToken as getosftoken, onUserDeleted as onuserdeleted, + deleteAccount as deleteaccount, createExperiment as createexperiment, getProviderAccessToken as getprovideraccesstoken, providerSetupWarnings as providersetupwarnings diff --git a/functions/src/on-user-deleted.ts b/functions/src/on-user-deleted.ts index f550896..1ce1b82 100644 --- a/functions/src/on-user-deleted.ts +++ b/functions/src/on-user-deleted.ts @@ -1,33 +1,22 @@ import * as auth from "firebase-functions/v1/auth"; -import { db } from "./app.js"; -import { UserData } from "./interfaces.js"; +import { purgeUserData } from "./purge-user-data.js"; +// Backstop for auth records deleted outside the app: the Firebase console, the +// Admin SDK, a support action. The normal path is the deleteAccount HTTPS +// function, which purges first and deletes the auth record afterwards; this +// trigger covers the deletions that never touch it. +// +// It is a backstop rather than the primary mechanism on purpose. It fires +// after the account is already gone, so a failure here strands data with no +// owner and no way for the researcher to sign back in and retry -- and there +// is evidence in datapipe-test that this has already happened at least twice. +// Whether the miss was a trigger that did not fire or an experiment missing +// from the users/{uid}.experiments array it used to read, relying on +// after-the-fact cleanup as the only line of defence is the wrong shape. +// +// purgeUserData is idempotent, so running after deleteAccount has already +// purged is harmless. export const onUserDeleted = auth.user().onDelete(async (user) => { - const uid = user.uid; - - // Get the user document to find their experiments - const userDocRef = db.collection("users").doc(uid); - const userDoc = await userDocRef.get(); - - if (userDoc.exists) { - const userData = userDoc.data() as UserData; - const experimentIds = userData.experiments || []; - - // Delete in batches of 500 (Firestore batch limit is 500 operations) - const batchSize = 500; - for (let i = 0; i < experimentIds.length; i += batchSize) { - const batch = db.batch(); - const chunk = experimentIds.slice(i, i + batchSize); - - for (const experimentId of chunk) { - batch.delete(db.collection("experiments").doc(experimentId)); - batch.delete(db.collection("logs").doc(experimentId)); - } - - await batch.commit(); - } - - // Delete the user document itself - await userDocRef.delete(); - } + const counts = await purgeUserData(user.uid); + console.log(`Purged data for deleted user ${user.uid}:`, counts); }); diff --git a/functions/src/purge-user-data.ts b/functions/src/purge-user-data.ts new file mode 100644 index 0000000..b3f716c --- /dev/null +++ b/functions/src/purge-user-data.ts @@ -0,0 +1,133 @@ +import { db, storage } from "./app.js"; + +// Everything that belongs to one researcher, removed in one pass. +// +// This exists because account deletion used to leave residue. The old +// implementation lived in on-user-deleted.ts and had two defects that this +// module fixes: +// +// 1. It found experiments through `users/{uid}.experiments`, an array the +// client maintains. When that array drifts, the experiment survives its +// owner -- and a surviving experiment is the dangerous kind of orphan, +// not a harmless one. api-data.ts writes the submission to Cloud Storage +// (persistPending) BEFORE it checks that the owner still exists, so a +// still-`active` orphan accepts and stores a file on every submission +// and then answers 400. Querying `where owner == uid` cannot drift. +// +// 2. It deleted the experiment document but not the `filenameClaims` +// subcollection underneath it. Deleting a Firestore document does not +// delete its subcollections; the claims would have outlived both the +// experiment and the account. +// +// Idempotent by construction: Firestore deletes of absent documents succeed, +// and the storage sweep tolerates a missing prefix. That matters because both +// callers can run for the same uid -- deleteAccount purges and then deletes +// the auth record, which fires the onUserDeleted trigger, which purges again. +const BATCH_LIMIT = 500; + +export interface PurgeCounts { + experiments: number; + filenameClaims: number; + metadata: number; + logs: number; + queueEntries: number; + pendingFiles: number; + userDocument: number; +} + +async function deleteInBatches( + refs: FirebaseFirestore.DocumentReference[] +): Promise<number> { + for (let i = 0; i < refs.length; i += BATCH_LIMIT) { + const batch = db.batch(); + for (const ref of refs.slice(i, i + BATCH_LIMIT)) { + batch.delete(ref); + } + await batch.commit(); + } + return refs.length; +} + +/** + * Remove every Firestore document and Cloud Storage object belonging to `uid`. + * + * Deliberately does NOT touch the Firebase Auth record. Callers order that + * themselves, and the order is load-bearing: purge first, delete the auth + * record last. Deleting the account first and then failing mid-purge would + * leave data with no owner and no way for the researcher to sign back in and + * retry -- the exact state this module is meant to prevent. + */ +export async function purgeUserData(uid: string): Promise<PurgeCounts> { + const counts: PurgeCounts = { + experiments: 0, + filenameClaims: 0, + metadata: 0, + logs: 0, + queueEntries: 0, + pendingFiles: 0, + userDocument: 0, + }; + + const ownedExperiments = await db + .collection("experiments") + .where("owner", "==", uid) + .get(); + + const experimentIds = ownedExperiments.docs.map((doc) => doc.id); + + for (const experimentId of experimentIds) { + // Subcollection first: once the parent document is gone the claims are + // unreachable through the console but still billable and still returned + // by collection-group queries. + const claims = await db + .collection("experiments") + .doc(experimentId) + .collection("filenameClaims") + .get(); + counts.filenameClaims += await deleteInBatches( + claims.docs.map((doc) => doc.ref) + ); + + // Submissions that were persisted but never uploaded. Left behind, these + // are replayed by scheduledPendingRecovery forever. + const [pendingFiles] = await storage + .bucket() + .getFiles({ prefix: `pending-data/${experimentId}/` }); + for (const file of pendingFiles) { + await file.delete({ ignoreNotFound: true }); + } + counts.pendingFiles += pendingFiles.length; + } + + counts.experiments = await deleteInBatches( + ownedExperiments.docs.map((doc) => doc.ref) + ); + + // metadata/ and logs/ are keyed by experiment id, not by uid. + counts.metadata = await deleteInBatches( + experimentIds.map((id) => db.collection("metadata").doc(id)) + ); + counts.logs = await deleteInBatches( + experimentIds.map((id) => db.collection("logs").doc(id)) + ); + + // uploadQueue is keyed by its own id and carries the owner as a field, so it + // has to be queried separately -- an entry can outlive the experiment it + // came from. + const queued = await db + .collection("uploadQueue") + .where("owner", "==", uid) + .get(); + counts.queueEntries = await deleteInBatches(queued.docs.map((doc) => doc.ref)); + + // Last: the user document holds connectedAccounts, i.e. the storage + // provider credentials. If an earlier step throws, the researcher still + // owns a coherent account. + const userDocRef = db.collection("users").doc(uid); + if ((await userDocRef.get()).exists) { + await userDocRef.delete(); + counts.userDocument = 1; + } + + return counts; +} From 20ca7a1b7e30e1b4fdc5cfc75323c49d781a6eaa Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Wed, 12 Aug 2026 16:16:44 -0400 Subject: [PATCH 080/181] fix: delete accounts server-side, and purge everything they own (#163) * fix: give account-linking failures their own wording Linking GitHub to an ORCID-created account failed with auth/email-already-in-use and showed "Sign in using the method you set up originally, then add GitHub from your account settings" -- advice for a visitor at the sign-in page, shown to a researcher who was already signed in and already on the account page. The underlying refusal is correct. Firebase allows one account per email address, so a GitHub credential whose email belongs to another DataPipe account cannot be attached to this one. ORCID is what makes this reachable: it returns no email claim, so an ORCID account starts with none and the first federated provider linked to it is the first chance for a collision with an account the researcher already had. messageForAuthError now takes the operation as a third argument and the three call sites pass it. Only the collision case and the fallback differ; everything else reads the same in all three modes. The linking copy says outright that there is no self-service merge -- experiments are keyed by `owner: uid`, so combining accounts is a maintainer operation -- rather than implying a retry will help. Also adds auth/no-such-provider, which unlink can raise and which was falling through to a message about sign-in. lib/auth-errors.js had no test file; it has one now, pinning the wording that went wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: delete accounts server-side, and purge everything they own Account deletion ran client-side -- deleteUser(auth.currentUser) destroyed the Firebase Auth record first, and cleanup hung off the onUserDeleted trigger afterwards. That order cannot be made safe: once the auth record is gone, a cleanup that does not run leaves the researcher's data with no owner and no way for them to sign back in and retry. datapipe-test carries the residue. Two users/ documents belong to uids with no auth record, and so do two experiments -- one of them still `active`. An orphaned active experiment is not inert: api-data.ts persists each submission to Cloud Storage (persistPending) BEFORE it checks that the owner still exists, so every submission to an orphan leaves a file behind and then answers 400. Deletion now runs in functions/src/delete-account.ts: purge first, delete the auth record only once the purge returns. Failing now leaves the account intact and the operation retryable. The purge itself moves to purge-user-data.ts and fixes two gaps in what the trigger used to do: - It found experiments through users/{uid}.experiments, a client-maintained array. Anything missing from it survived its owner. It now queries `where owner == uid`, which cannot drift. - It deleted the experiment document but not the filenameClaims subcollection beneath it. Firestore does not cascade. It also now clears uploadQueue entries and pending-data/ objects, neither of which was touched before. onUserDeleted stays as a backstop for deletions that never reach the endpoint -- the console, the Admin SDK, a support action. purgeUserData is idempotent, so the trigger firing after deleteAccount has already purged is harmless. Moving the call server-side would have quietly dropped the recent-login requirement that client-side deleteUser enforced for us, so the endpoint checks the token's auth_time against Firebase's own five-minute threshold and verifies with checkRevoked. The client maps that to "sign out and sign back in" instead of the old bare tooltip, which showed a raw Firebase error message in red 12px text. functions/scripts/purge-orphaned-users.mjs cleans up what the old order already left behind: dry-run by default, and it treats an account as orphaned only on an explicit auth/user-not-found -- any other error is reported as undetermined and skipped. Both admin-SDK scripts move under functions/, where firebase-admin actually resolves. They imported it from scripts/, which has no node_modules, so neither could ever have run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --- components/account/DeleteAccount.js | 146 +++++++----- firebase.json | 4 + .../scripts}/backfill-osf-auth-emails.mjs | 4 +- functions/scripts/purge-orphaned-users.mjs | 191 +++++++++++++++ .../purge-user-data-emulator.test.js | 217 ++++++++++++++++++ functions/src/delete-account.ts | 80 +++++++ functions/src/index.ts | 2 + functions/src/on-user-deleted.ts | 47 ++-- functions/src/purge-user-data.ts | 133 +++++++++++ 9 files changed, 734 insertions(+), 90 deletions(-) rename {scripts => functions/scripts}/backfill-osf-auth-emails.mjs (97%) create mode 100644 functions/scripts/purge-orphaned-users.mjs create mode 100644 functions/src/__tests__/purge-user-data-emulator.test.js create mode 100644 functions/src/delete-account.ts create mode 100644 functions/src/purge-user-data.ts diff --git a/components/account/DeleteAccount.js b/components/account/DeleteAccount.js index 053e7a1..2f7b379 100644 --- a/components/account/DeleteAccount.js +++ b/components/account/DeleteAccount.js @@ -1,29 +1,54 @@ -import { useState, useContext } from "react"; -import { UserContext } from "../../lib/context"; +import { useState } from "react"; import { HStack, + VStack, Button, Text, + Alert, Dialog, - Tooltip, } from "@chakra-ui/react"; import { auth } from "../../lib/firebase"; -import { deleteUser } from "firebase/auth"; import { useRouter } from "next/router"; +// Deletion runs server-side (functions/src/delete-account.ts) rather than +// through deleteUser() here. The client SDK can only delete the auth record, +// and it is the auth record that has to go LAST: the researcher's experiments, +// queued uploads, pending submissions and stored provider credentials all key +// off the uid, and destroying the account first means a failed cleanup strands +// them with no owner and no way to sign back in and retry. export default function DeleteAccount({ setDeleting }) { - const { user } = useContext(UserContext); const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); const router = useRouter(); const deleteAccount = async function () { + setIsSubmitting(true); try { - await deleteUser(auth.currentUser); + // Not forced: a refreshed token carries the same auth_time, so it would + // not get past the endpoint's recent-login check anyway. + const idToken = await auth.currentUser.getIdToken(); + const response = await fetch("/api/deleteaccount", { + method: "POST", + headers: { Authorization: `Bearer ${idToken}` }, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error( + body.code === "requires-recent-login" + ? "For security, sign out and sign back in, then delete your account." + : body.error || + "Could not delete your account. Nothing was lost -- please try again." + ); + } + + // The auth record is gone; drop the local session so the app does not + // keep acting on a user that no longer exists. + await auth.signOut().catch(() => {}); router.push("/admin/deleted-account"); } catch (error) { setDeleting(false); @@ -33,62 +58,65 @@ export default function DeleteAccount({ setDeleting }) { }; return ( - <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> - <Text fontSize={"lg"}>Delete DataPipe Account</Text> - <HStack> - {deleteError && ( - <Tooltip.Root> - <Tooltip.Trigger asChild> - <Text fontSize="sm" color="red.400" cursor="default">Failed</Text> - </Tooltip.Trigger> - <Tooltip.Positioner> - <Tooltip.Content>{deleteError}</Tooltip.Content> - </Tooltip.Positioner> - </Tooltip.Root> - )} - <Button loading={isSubmitting} onClick={() => setOpen(true)} colorPalette="red"> + <VStack w="100%" align="stretch" gap={3}> + {deleteError && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{deleteError}</Text> + </Alert.Root> + )} + + <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> + <Text fontSize={"lg"}>Delete DataPipe Account</Text> + <Button + loading={isSubmitting} + onClick={() => setOpen(true)} + colorPalette="red" + > Delete Account </Button> - </HStack> - <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> - <Dialog.Backdrop /> - <Dialog.Positioner> - <Dialog.Content bg="greyBackground" color="white"> - <Dialog.Header fontSize="lg" fontWeight="bold"> - Delete Account - </Dialog.Header> - <Dialog.Body> - <Text mb={4}> - Are you sure? This action is final. We cannot recover any - experiments that are associated with this account after - deletion. - </Text> - <Text> - Deleting your DataPipe account will not affect any data already - written to your storage provider. - </Text> - </Dialog.Body> + <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content bg="greyBackground" color="white"> + <Dialog.Header fontSize="lg" fontWeight="bold"> + Delete Account + </Dialog.Header> + + <Dialog.Body> + <Text mb={4}> + Are you sure? This action is final. We cannot recover any + experiments that are associated with this account after + deletion. + </Text> + <Text> + Deleting your DataPipe account will not affect any data + already written to your storage provider. + </Text> + </Dialog.Body> - <Dialog.Footer> - <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> - Cancel - </Button> - <Button - colorPalette="red" - onClick={() => { - setDeleting(true); - setOpen(false); - deleteAccount(); - }} - ml={3} - > - Delete - </Button> - </Dialog.Footer> - </Dialog.Content> - </Dialog.Positioner> - </Dialog.Root> - </HStack> + <Dialog.Footer> + <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> + Cancel + </Button> + <Button + colorPalette="red" + onClick={() => { + setDeleting(true); + setOpen(false); + setDeleteError(null); + deleteAccount(); + }} + ml={3} + > + Delete + </Button> + </Dialog.Footer> + </Dialog.Content> + </Dialog.Positioner> + </Dialog.Root> + </HStack> + </VStack> ); } diff --git a/firebase.json b/firebase.json index 9fb5245..4bf2f74 100644 --- a/firebase.json +++ b/firebase.json @@ -77,6 +77,10 @@ { "source": "/api/providersetupwarnings", "function": "providersetupwarnings" + }, + { + "source": "/api/deleteaccount", + "function": "deleteaccount" } ] }, diff --git a/scripts/backfill-osf-auth-emails.mjs b/functions/scripts/backfill-osf-auth-emails.mjs similarity index 97% rename from scripts/backfill-osf-auth-emails.mjs rename to functions/scripts/backfill-osf-auth-emails.mjs index 0340ca9..de5e259 100644 --- a/scripts/backfill-osf-auth-emails.mjs +++ b/functions/scripts/backfill-osf-auth-emails.mjs @@ -21,8 +21,8 @@ // signup from colliding with one of these accounts in the meantime). // // Usage: -// node scripts/backfill-osf-auth-emails.mjs # dry run, changes nothing -// node scripts/backfill-osf-auth-emails.mjs --apply # actually writes +// node functions/scripts/backfill-osf-auth-emails.mjs # dry run, changes nothing +// node functions/scripts/backfill-osf-auth-emails.mjs --apply # actually writes // // Env: // GOOGLE_APPLICATION_CREDENTIALS service-account key with Firebase Admin access diff --git a/functions/scripts/purge-orphaned-users.mjs b/functions/scripts/purge-orphaned-users.mjs new file mode 100644 index 0000000..698e08a --- /dev/null +++ b/functions/scripts/purge-orphaned-users.mjs @@ -0,0 +1,191 @@ +// Find and remove data belonging to accounts that no longer exist in Firebase +// Auth. +// +// WHY THIS EXISTS +// +// Account deletion used to run client-side: deleteUser(auth.currentUser) +// destroyed the Auth record first, and cleanup hung off the onUserDeleted +// trigger afterwards. When that cleanup did not run, or ran against a stale +// users/{uid}.experiments array, the data outlived the account with no owner +// left to notice. datapipe-test carries the proof -- two users/ documents and +// two experiments (one of them still `active`) belonging to uids that have no +// Auth record. +// +// An orphaned experiment is not inert. It still resolves in /api/data, and +// api-data.ts persists the submission to Cloud Storage BEFORE it checks that +// the owner exists, so every submission to an orphan leaves a file behind and +// then answers 400. That accrues storage indefinitely for a study nobody owns. +// +// functions/src/delete-account.ts fixes the ordering going forward (purge +// first, delete the Auth record last). This script cleans up what the old +// order already left behind. It should need to be run once per project. +// +// Usage: +// node functions/scripts/purge-orphaned-users.mjs # dry run, changes nothing +// node functions/scripts/purge-orphaned-users.mjs --apply # actually deletes +// +// Env: +// GOOGLE_APPLICATION_CREDENTIALS service-account key with Firebase Admin access +// FIREBASE_PROJECT_ID (optional) overrides the credential's project +// +// SAFETY: an account is treated as orphaned only when getUser(uid) raises +// auth/user-not-found. Any other error -- rate limit, transport failure, +// permission problem -- is reported as `undetermined` and skipped, because +// mistaking a live account for a dead one here would delete a researcher's +// study. + +import { initializeApp, applicationDefault } from "firebase-admin/app"; +import { getAuth } from "firebase-admin/auth"; +import { getFirestore } from "firebase-admin/firestore"; +import { getStorage } from "firebase-admin/storage"; + +const apply = process.argv.includes("--apply"); + +const projectId = process.env.FIREBASE_PROJECT_ID; +initializeApp({ + credential: applicationDefault(), + ...(projectId ? { projectId } : {}), + storageBucket: `${projectId || ""}.appspot.com`, +}); + +const auth = getAuth(); +const db = getFirestore(); +const bucket = getStorage().bucket(); + +async function authRecordState(uid) { + try { + await auth.getUser(uid); + return "live"; + } catch (e) { + if (e?.errorInfo?.code === "auth/user-not-found") return "missing"; + console.warn(` ! could not resolve ${uid}: ${e?.message || e}`); + return "undetermined"; + } +} + +// Every uid that owns something, whether or not it still has a users/ doc. +// Collected from both directions because the two can disagree -- that +// disagreement is the bug. +async function collectCandidateUids() { + const uids = new Set(); + const userDocs = await db.collection("users").get(); + for (const doc of userDocs.docs) uids.add(doc.id); + const experiments = await db.collection("experiments").get(); + for (const doc of experiments.docs) { + const owner = doc.data().owner; + if (owner) uids.add(owner); + } + return [...uids]; +} + +async function describe(uid) { + const owned = await db + .collection("experiments") + .where("owner", "==", uid) + .get(); + const queued = await db + .collection("uploadQueue") + .where("owner", "==", uid) + .get(); + const userDoc = await db.collection("users").doc(uid).get(); + + let pendingFiles = 0; + for (const doc of owned.docs) { + const [files] = await bucket.getFiles({ + prefix: `pending-data/${doc.id}/`, + }); + pendingFiles += files.length; + } + + return { + experiments: owned.docs.map((d) => ({ + id: d.id, + title: d.data().title, + active: d.data().active === true, + })), + queueEntries: queued.size, + pendingFiles, + email: userDoc.exists ? userDoc.data().email : "(no users/ document)", + }; +} + +async function purge(uid, summary) { + const batch = db.batch(); + for (const exp of summary.experiments) { + const claims = await db + .collection("experiments") + .doc(exp.id) + .collection("filenameClaims") + .get(); + for (const claim of claims.docs) batch.delete(claim.ref); + + const [files] = await bucket.getFiles({ prefix: `pending-data/${exp.id}/` }); + for (const file of files) await file.delete({ ignoreNotFound: true }); + + batch.delete(db.collection("experiments").doc(exp.id)); + batch.delete(db.collection("metadata").doc(exp.id)); + batch.delete(db.collection("logs").doc(exp.id)); + } + const queued = await db + .collection("uploadQueue") + .where("owner", "==", uid) + .get(); + for (const doc of queued.docs) batch.delete(doc.ref); + batch.delete(db.collection("users").doc(uid)); + await batch.commit(); +} + +const report = { orphaned: [], live: 0, undetermined: [] }; + +const candidates = await collectCandidateUids(); +console.log( + `${apply ? "APPLY" : "DRY RUN"}: checking ${candidates.length} uid(s)\n` +); + +for (const uid of candidates) { + const state = await authRecordState(uid); + if (state === "live") { + report.live += 1; + continue; + } + if (state === "undetermined") { + report.undetermined.push(uid); + continue; + } + + const summary = await describe(uid); + report.orphaned.push({ uid, ...summary }); + + console.log(`ORPHAN ${uid} (${summary.email})`); + for (const exp of summary.experiments) { + console.log( + ` experiment ${exp.id} "${exp.title}"${exp.active ? " [ACTIVE - still accepting data]" : ""}` + ); + } + if (summary.queueEntries) console.log(` ${summary.queueEntries} queue entries`); + if (summary.pendingFiles) console.log(` ${summary.pendingFiles} pending files`); + + if (apply) { + await purge(uid, summary); + console.log(" purged"); + } + console.log(""); +} + +console.log("---"); +console.log(`live accounts: ${report.live}`); +console.log(`orphaned accounts: ${report.orphaned.length}`); +console.log( + ` experiments: ${report.orphaned.reduce((n, o) => n + o.experiments.length, 0)}` + + ` (${report.orphaned.reduce((n, o) => n + o.experiments.filter((e) => e.active).length, 0)} active)` +); +console.log( + ` pending files: ${report.orphaned.reduce((n, o) => n + o.pendingFiles, 0)}` +); +console.log(`undetermined: ${report.undetermined.length}`); +if (report.undetermined.length) { + console.log(` ${report.undetermined.join("\n ")}`); +} +if (!apply && report.orphaned.length) { + console.log("\nNothing was changed. Re-run with --apply to delete."); +} diff --git a/functions/src/__tests__/purge-user-data-emulator.test.js b/functions/src/__tests__/purge-user-data-emulator.test.js new file mode 100644 index 0000000..3b9dc6a --- /dev/null +++ b/functions/src/__tests__/purge-user-data-emulator.test.js @@ -0,0 +1,217 @@ +/** + * @jest-environment node + */ + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +// app.js (imported transitively below) calls initializeApp() with no args and +// reads the default bucket from FIREBASE_CONFIG -- set before those imports. +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +const { randomUUID } = require("crypto"); +const { getFirestore } = require("firebase-admin/firestore"); +const { getStorage } = require("firebase-admin/storage"); +const { purgeUserData } = require("../../lib/purge-user-data.js"); + +jest.setTimeout(30000); + +const db = getFirestore(); +const bucket = getStorage().bucket(); + +// Every document this suite creates is namespaced by a per-run uid so it can +// never collide with, or delete, anything belonging to a suite running in +// parallel. See the note in scheduled-pending-recovery-emulator.test.js. +function makeUid() { + return `purge-test-${randomUUID()}`; +} + +async function seedExperiment(uid, experimentID, { active = true } = {}) { + await db.collection("experiments").doc(experimentID).set({ + active, + owner: uid, + title: "Purge fixture", + storageProvider: "gdrive", + }); + await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc("claim-one") + .set({ filename: "subject-1.json", claimedAt: Date.now() }); + await db.collection("metadata").doc(experimentID).set({ owner: uid }); + await db.collection("logs").doc(experimentID).set({ owner: uid }); + await bucket + .file(`pending-data/${experimentID}/subject-1.json_123`) + .save(JSON.stringify({ experimentID, filename: "s.json", data: "[]" }), { + contentType: "application/json", + }); +} + +async function exists(ref) { + return (await ref.get()).exists; +} + +describe("purgeUserData", () => { + const strays = []; + + afterEach(async () => { + // Only what this suite created and the purge did not remove. + while (strays.length) { + await strays.pop().delete().catch(() => {}); + } + }); + + it("removes every trace of the account", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + await db + .collection("users") + .doc(uid) + .set({ uid, email: "purge@example.com", experiments: [experimentID] }); + const queueDocId = `queue-${randomUUID()}`; + await db + .collection("uploadQueue") + .doc(queueDocId) + .set({ owner: uid, experimentID, status: "pending" }); + + const counts = await purgeUserData(uid); + + expect(counts).toMatchObject({ + experiments: 1, + filenameClaims: 1, + metadata: 1, + logs: 1, + queueEntries: 1, + pendingFiles: 1, + userDocument: 1, + }); + + expect(await exists(db.collection("users").doc(uid))).toBe(false); + expect(await exists(db.collection("experiments").doc(experimentID))).toBe( + false + ); + expect(await exists(db.collection("metadata").doc(experimentID))).toBe( + false + ); + expect(await exists(db.collection("logs").doc(experimentID))).toBe(false); + expect(await exists(db.collection("uploadQueue").doc(queueDocId))).toBe( + false + ); + + const [pending] = await bucket.getFiles({ + prefix: `pending-data/${experimentID}/`, + }); + expect(pending).toHaveLength(0); + }); + + // The regression that left a live experiment behind in datapipe-test: the + // old implementation read users/{uid}.experiments, so anything missing from + // that array survived its owner -- and a surviving `active` experiment still + // accepts submissions and writes them to Cloud Storage. + it("deletes experiments missing from the users/{uid}.experiments array", async () => { + const uid = makeUid(); + const listed = `exp-${randomUUID()}`; + const drifted = `exp-${randomUUID()}`; + await seedExperiment(uid, listed); + await seedExperiment(uid, drifted); + // The array knows about only one of the two. + await db + .collection("users") + .doc(uid) + .set({ uid, email: "drift@example.com", experiments: [listed] }); + + const counts = await purgeUserData(uid); + + expect(counts.experiments).toBe(2); + expect(await exists(db.collection("experiments").doc(drifted))).toBe(false); + }); + + // Deleting a Firestore document does not delete its subcollections. + it("clears the filenameClaims subcollection under each experiment", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + + await purgeUserData(uid); + + const claims = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .get(); + expect(claims.empty).toBe(true); + }); + + it("works when the user document is already gone", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + // No users/{uid} doc at all -- the orphan state already in datapipe-test. + + const counts = await purgeUserData(uid); + + expect(counts.experiments).toBe(1); + expect(counts.userDocument).toBe(0); + }); + + // deleteAccount purges and then deletes the auth record, which fires + // onUserDeleted, which purges again. + it("is idempotent", async () => { + const uid = makeUid(); + const experimentID = `exp-${randomUUID()}`; + await seedExperiment(uid, experimentID); + await db + .collection("users") + .doc(uid) + .set({ uid, email: "twice@example.com", experiments: [experimentID] }); + + await purgeUserData(uid); + const second = await purgeUserData(uid); + + expect(second).toMatchObject({ + experiments: 0, + filenameClaims: 0, + metadata: 0, + logs: 0, + queueEntries: 0, + pendingFiles: 0, + userDocument: 0, + }); + }); + + it("leaves another researcher's data untouched", async () => { + const victim = makeUid(); + const bystander = makeUid(); + const victimExp = `exp-${randomUUID()}`; + const bystanderExp = `exp-${randomUUID()}`; + await seedExperiment(victim, victimExp); + await seedExperiment(bystander, bystanderExp); + const bystanderUserRef = db.collection("users").doc(bystander); + await bystanderUserRef.set({ + uid: bystander, + email: "bystander@example.com", + experiments: [bystanderExp], + }); + strays.push(bystanderUserRef); + strays.push(db.collection("experiments").doc(bystanderExp)); + strays.push(db.collection("metadata").doc(bystanderExp)); + strays.push(db.collection("logs").doc(bystanderExp)); + + await purgeUserData(victim); + + expect(await exists(db.collection("experiments").doc(bystanderExp))).toBe( + true + ); + expect(await exists(bystanderUserRef)).toBe(true); + const [pending] = await bucket.getFiles({ + prefix: `pending-data/${bystanderExp}/`, + }); + expect(pending).toHaveLength(1); + await Promise.all(pending.map((f) => f.delete())); + }); +}); diff --git a/functions/src/delete-account.ts b/functions/src/delete-account.ts new file mode 100644 index 0000000..63e2787 --- /dev/null +++ b/functions/src/delete-account.ts @@ -0,0 +1,80 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { auth } from "./app.js"; +import { purgeUserData } from "./purge-user-data.js"; + +// Account deletion, moved server-side. +// +// It used to be a bare client-side deleteUser(auth.currentUser), with the +// cleanup hanging off the onUserDeleted auth trigger. That arrangement has an +// ordering problem that no amount of care in the trigger can fix: the auth +// record is destroyed first, so if the cleanup does not run -- or runs +// partially -- the researcher's data is stranded with no owner and no way for +// them to sign in and try again. datapipe-test still carries the residue: +// user documents and a still-`active` experiment belonging to accounts that no +// longer exist. +// +// Here the order is inverted. Purge first, delete the auth record only once +// the purge has returned. A failure now leaves the account intact and the +// operation retryable, which is the safe direction to fail in. +// +// onUserDeleted is kept as a backstop for deletions that do not come through +// this endpoint (the Firebase console, the Admin SDK, a support action). + +// Firebase's own threshold for auth/requires-recent-login. Client-side +// deleteUser enforced this for us; server-side nothing does, so the check has +// to be explicit or moving the call server-side would quietly weaken it -- a +// stolen session token would be enough to destroy an account. +const MAX_AUTH_AGE_SECONDS = 5 * 60; + +export const deleteAccount = onRequest({ cors: true }, async (req, res) => { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + let uid: string; + let authTime: number; + try { + const idToken = authHeader.split("Bearer ")[1]; + // checkRevoked: a researcher who signed out everywhere should not be able + // to delete the account with a token minted before that. + const decodedToken = await auth.verifyIdToken(idToken, true); + uid = decodedToken.uid; + authTime = decodedToken.auth_time; + } catch { + res.status(401).json({ error: "Invalid authentication token" }); + return; + } + + const tokenAgeSeconds = Date.now() / 1000 - authTime; + if (tokenAgeSeconds > MAX_AUTH_AGE_SECONDS) { + // A distinct code so the client can say "sign in again" rather than + // showing a generic failure. + res.status(403).json({ + error: "Please sign in again before deleting your account.", + code: "requires-recent-login", + }); + return; + } + + try { + const counts = await purgeUserData(uid); + await auth.deleteUser(uid); + res.status(200).json({ success: true, deleted: counts }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown error"; + console.error(`Account deletion failed for ${uid}: ${detail}`); + // The account still exists and still owns whatever survived, so the + // researcher can retry. Say so rather than leaving them guessing. + res.status(500).json({ + error: + "Could not finish deleting your account. Nothing was lost -- please try again.", + }); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index efb568b..7fd382e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ import { connectProvider, connectStaticTokenProvider, disconnectProvider } from import { saveOsfToken } from "./save-osf-token.js"; import { getOsfToken } from "./get-osf-token.js"; import { onUserDeleted } from "./on-user-deleted.js"; +import { deleteAccount } from "./delete-account.js"; import { createExperiment } from "./create-experiment.js"; import { getProviderAccessToken } from "./get-provider-access-token.js"; import { providerSetupWarnings } from "./provider-setup-warnings.js"; @@ -41,6 +42,7 @@ export { saveOsfToken as saveosftoken, getOsfToken as getosftoken, onUserDeleted as onuserdeleted, + deleteAccount as deleteaccount, createExperiment as createexperiment, getProviderAccessToken as getprovideraccesstoken, providerSetupWarnings as providersetupwarnings diff --git a/functions/src/on-user-deleted.ts b/functions/src/on-user-deleted.ts index f550896..1ce1b82 100644 --- a/functions/src/on-user-deleted.ts +++ b/functions/src/on-user-deleted.ts @@ -1,33 +1,22 @@ import * as auth from "firebase-functions/v1/auth"; -import { db } from "./app.js"; -import { UserData } from "./interfaces.js"; +import { purgeUserData } from "./purge-user-data.js"; +// Backstop for auth records deleted outside the app: the Firebase console, the +// Admin SDK, a support action. The normal path is the deleteAccount HTTPS +// function, which purges first and deletes the auth record afterwards; this +// trigger covers the deletions that never touch it. +// +// It is a backstop rather than the primary mechanism on purpose. It fires +// after the account is already gone, so a failure here strands data with no +// owner and no way for the researcher to sign back in and retry -- and there +// is evidence in datapipe-test that this has already happened at least twice. +// Whether the miss was a trigger that did not fire or an experiment missing +// from the users/{uid}.experiments array it used to read, relying on +// after-the-fact cleanup as the only line of defence is the wrong shape. +// +// purgeUserData is idempotent, so running after deleteAccount has already +// purged is harmless. export const onUserDeleted = auth.user().onDelete(async (user) => { - const uid = user.uid; - - // Get the user document to find their experiments - const userDocRef = db.collection("users").doc(uid); - const userDoc = await userDocRef.get(); - - if (userDoc.exists) { - const userData = userDoc.data() as UserData; - const experimentIds = userData.experiments || []; - - // Delete in batches of 500 (Firestore batch limit is 500 operations) - const batchSize = 500; - for (let i = 0; i < experimentIds.length; i += batchSize) { - const batch = db.batch(); - const chunk = experimentIds.slice(i, i + batchSize); - - for (const experimentId of chunk) { - batch.delete(db.collection("experiments").doc(experimentId)); - batch.delete(db.collection("logs").doc(experimentId)); - } - - await batch.commit(); - } - - // Delete the user document itself - await userDocRef.delete(); - } + const counts = await purgeUserData(user.uid); + console.log(`Purged data for deleted user ${user.uid}:`, counts); }); diff --git a/functions/src/purge-user-data.ts b/functions/src/purge-user-data.ts new file mode 100644 index 0000000..b3f716c --- /dev/null +++ b/functions/src/purge-user-data.ts @@ -0,0 +1,133 @@ +import { db, storage } from "./app.js"; + +// Everything that belongs to one researcher, removed in one pass. +// +// This exists because account deletion used to leave residue. The old +// implementation lived in on-user-deleted.ts and had two defects that this +// module fixes: +// +// 1. It found experiments through `users/{uid}.experiments`, an array the +// client maintains. When that array drifts, the experiment survives its +// owner -- and a surviving experiment is the dangerous kind of orphan, +// not a harmless one. api-data.ts writes the submission to Cloud Storage +// (persistPending) BEFORE it checks that the owner still exists, so a +// still-`active` orphan accepts and stores a file on every submission +// and then answers 400. Querying `where owner == uid` cannot drift. +// +// 2. It deleted the experiment document but not the `filenameClaims` +// subcollection underneath it. Deleting a Firestore document does not +// delete its subcollections; the claims would have outlived both the +// experiment and the account. +// +// Idempotent by construction: Firestore deletes of absent documents succeed, +// and the storage sweep tolerates a missing prefix. That matters because both +// callers can run for the same uid -- deleteAccount purges and then deletes +// the auth record, which fires the onUserDeleted trigger, which purges again. +const BATCH_LIMIT = 500; + +export interface PurgeCounts { + experiments: number; + filenameClaims: number; + metadata: number; + logs: number; + queueEntries: number; + pendingFiles: number; + userDocument: number; +} + +async function deleteInBatches( + refs: FirebaseFirestore.DocumentReference[] +): Promise<number> { + for (let i = 0; i < refs.length; i += BATCH_LIMIT) { + const batch = db.batch(); + for (const ref of refs.slice(i, i + BATCH_LIMIT)) { + batch.delete(ref); + } + await batch.commit(); + } + return refs.length; +} + +/** + * Remove every Firestore document and Cloud Storage object belonging to `uid`. + * + * Deliberately does NOT touch the Firebase Auth record. Callers order that + * themselves, and the order is load-bearing: purge first, delete the auth + * record last. Deleting the account first and then failing mid-purge would + * leave data with no owner and no way for the researcher to sign back in and + * retry -- the exact state this module is meant to prevent. + */ +export async function purgeUserData(uid: string): Promise<PurgeCounts> { + const counts: PurgeCounts = { + experiments: 0, + filenameClaims: 0, + metadata: 0, + logs: 0, + queueEntries: 0, + pendingFiles: 0, + userDocument: 0, + }; + + const ownedExperiments = await db + .collection("experiments") + .where("owner", "==", uid) + .get(); + + const experimentIds = ownedExperiments.docs.map((doc) => doc.id); + + for (const experimentId of experimentIds) { + // Subcollection first: once the parent document is gone the claims are + // unreachable through the console but still billable and still returned + // by collection-group queries. + const claims = await db + .collection("experiments") + .doc(experimentId) + .collection("filenameClaims") + .get(); + counts.filenameClaims += await deleteInBatches( + claims.docs.map((doc) => doc.ref) + ); + + // Submissions that were persisted but never uploaded. Left behind, these + // are replayed by scheduledPendingRecovery forever. + const [pendingFiles] = await storage + .bucket() + .getFiles({ prefix: `pending-data/${experimentId}/` }); + for (const file of pendingFiles) { + await file.delete({ ignoreNotFound: true }); + } + counts.pendingFiles += pendingFiles.length; + } + + counts.experiments = await deleteInBatches( + ownedExperiments.docs.map((doc) => doc.ref) + ); + + // metadata/ and logs/ are keyed by experiment id, not by uid. + counts.metadata = await deleteInBatches( + experimentIds.map((id) => db.collection("metadata").doc(id)) + ); + counts.logs = await deleteInBatches( + experimentIds.map((id) => db.collection("logs").doc(id)) + ); + + // uploadQueue is keyed by its own id and carries the owner as a field, so it + // has to be queried separately -- an entry can outlive the experiment it + // came from. + const queued = await db + .collection("uploadQueue") + .where("owner", "==", uid) + .get(); + counts.queueEntries = await deleteInBatches(queued.docs.map((doc) => doc.ref)); + + // Last: the user document holds connectedAccounts, i.e. the storage + // provider credentials. If an earlier step throws, the researcher still + // owns a coherent account. + const userDocRef = db.collection("users").doc(uid); + if ((await userDocRef.get()).exists) { + await userDocRef.delete(); + counts.userDocument = 1; + } + + return counts; +} From c394a094ee64858ff9962a0b2ad963afe5e3b5cc Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 13:58:26 -0400 Subject: [PATCH 081/181] feat: seal sessions into archives so capped providers stop breaking studies Zenodo allows 100 files per record and refuses the 101st, so a study simply stopped at ~session 100: the write mapped to QUOTA_EXCEEDED, a slow-tier queue failure no amount of retrying could clear. Zenodo's keyspace is also flat, so a metadataActive record showed data_raw_subject-1.json where Psych-DS calls for data/raw/subject-1.json. Both are now handled by the same mechanism. Compaction (during collection). Older sessions are sealed into a batch zip that carries the real Psych-DS tree, then the loose originals are deleted. Ordering is the safety property and is not negotiable: upload, verify the provider's reported md5, seal the claims, and only then delete. DataPipe keeps no copy of submitted data, so a delete before a verified upload is unrecoverable. Batch membership is recorded before the upload, so an interrupted pass resumes rather than sealing the same sessions into a second zip -- as hashes, not filenames, preserving the collision cache's privacy property. Archived claims lose their TTL. A confirmed claim normally expires after 90 days, which is only safe because a cold cache rehydrates from the provider's listing; an archived file is not in that listing, so an expiring claim would silently re-open a filename collected months earlier. Discovery is event-driven -- there is no cron. DataPipe is the only writer to these containers, so it already knows when one has grown. Two Firestore triggers replace what began as a 6-hour poll: `sessions` incrementing on an experiment, and upload-queue writes (which catch a draining backlog and a provider reporting the record full). An idle study now costs zero provider listings; an active one is examined on every change. A write gate closes the remaining hole. While a pass holds the lease, submissions divert to the durable upload queue instead of the provider, so the file count cannot grow mid-pass. It costs nothing: every caller already loads the experiment document. A record that fills anyway borrows .psychds-ignore's slot -- its content is a fixed constant, so giving it up loses nothing. Finalization (end of study). One merged archive holding the complete Psych-DS tree, built by streaming into Cloud Storage and uploaded from a stream, so archive size is bounded by the provider's 50 GB per-file limit rather than by function memory. A split would break the Psych-DS compatibility the archive exists to provide, so it must stay unreachable in practice. Finalization is permanent: the experiment stops accepting submissions, the retry worker refuses to write into a sealed record, and firestore.rules blocks a client from clearing the flag through the client SDK. Researcher-facing: a dashboard control with an explicit confirmation, and an FAQ entry explaining why adding files to provider storage during collection is unsupported -- it also desynchronizes the collision cache. Zenodo's setupWarnings stopgap is removed; it told researchers to stay under 100 submissions, which is no longer true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- __tests__/finalize-control.test.jsx | 166 +++ __tests__/firestore-rules.test.js | 121 ++ components/dashboard/FinalizeControl.js | 215 ++++ docs/finalization-spec.md | 214 ++++ docs/provider-migration-design.md | 158 ++- firebase.json | 4 + firestore.indexes.json | 58 +- firestore.rules | 32 +- functions/.env.local | 11 + .../__tests__/api-finalize-emulator.test.js | 293 +++++ .../src/__tests__/archive-reader.test.js | 308 +++++ .../src/__tests__/compaction-emulator.test.js | 1109 +++++++++++++++++ .../compaction-streaming-emulator.test.js | 174 +++ functions/src/__tests__/compaction.test.js | 289 +++++ .../src/__tests__/dataverse-emulator.test.js | 621 +++++++++ .../__tests__/finalization-emulator.test.js | 1039 +++++++++++++++ .../src/__tests__/providers-zenodo.test.js | 225 +++- .../src/__tests__/zenodo-emulator.test.js | 501 ++++++++ functions/src/api-base64.ts | 39 + functions/src/api-data.ts | 44 + functions/src/api-finalize.ts | 234 ++++ functions/src/api-messages.ts | 4 + functions/src/app.ts | 7 +- functions/src/archive-reader.ts | 201 +++ functions/src/collision-cache.ts | 98 ++ functions/src/compaction-gate.ts | 53 + functions/src/compaction-triggers.ts | 196 +++ functions/src/compaction.ts | 897 +++++++++++++ functions/src/finalization.ts | 547 ++++++++ functions/src/index.ts | 8 +- functions/src/interfaces.ts | 69 + functions/src/providers/dataverse.ts | 6 + functions/src/providers/gdrive.ts | 3 + functions/src/providers/osf.ts | 1 + functions/src/providers/types.ts | 96 ++ functions/src/providers/zenodo.ts | 280 ++++- functions/src/scheduled-upload-retry.ts | 46 + pages/admin/[experiment_id].js | 7 + pages/faq.js | 25 + 39 files changed, 8340 insertions(+), 59 deletions(-) create mode 100644 __tests__/finalize-control.test.jsx create mode 100644 components/dashboard/FinalizeControl.js create mode 100644 docs/finalization-spec.md create mode 100644 functions/src/__tests__/api-finalize-emulator.test.js create mode 100644 functions/src/__tests__/archive-reader.test.js create mode 100644 functions/src/__tests__/compaction-emulator.test.js create mode 100644 functions/src/__tests__/compaction-streaming-emulator.test.js create mode 100644 functions/src/__tests__/compaction.test.js create mode 100644 functions/src/__tests__/dataverse-emulator.test.js create mode 100644 functions/src/__tests__/finalization-emulator.test.js create mode 100644 functions/src/__tests__/zenodo-emulator.test.js create mode 100644 functions/src/api-finalize.ts create mode 100644 functions/src/archive-reader.ts create mode 100644 functions/src/compaction-gate.ts create mode 100644 functions/src/compaction-triggers.ts create mode 100644 functions/src/compaction.ts create mode 100644 functions/src/finalization.ts diff --git a/__tests__/finalize-control.test.jsx b/__tests__/finalize-control.test.jsx new file mode 100644 index 0000000..eafa375 --- /dev/null +++ b/__tests__/finalize-control.test.jsx @@ -0,0 +1,166 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +// FinalizeControl calls /api/finalize with a Bearer token, the same shape +// DeleteAccount uses for /api/deleteaccount -- mock auth.currentUser the same +// way provider-connections.test.jsx does. +const mockGetIdToken = jest.fn(() => Promise.resolve("id-token-123")); +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: { uid: "user-1", getIdToken: () => mockGetIdToken() } }, + db: {}, +})); + +import FinalizeControl from "../components/dashboard/FinalizeControl"; + +function renderControl(data) { + return render( + <ChakraProvider value={system}> + <FinalizeControl data={{ id: "exp1", ...data }} experimentId="exp1" /> + </ChakraProvider> + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIdToken.mockClear(); + mockGetIdToken.mockImplementation(() => Promise.resolve("id-token-123")); + global.fetch = jest.fn(); +}); + +describe("FinalizeControl — idle state", () => { + it("shows a Finalize button when the experiment is not finalized and nothing is in flight", () => { + renderControl({}); + expect(screen.getByRole("button", { name: /finalize/i })).toBeInTheDocument(); + }); + + // Chakra's Dialog (Ark UI/zag-js underneath) opens and closes through its + // own state machine, which updates asynchronously relative to the click + // that triggers it -- so every interaction with it below is awaited + // (findBy*/waitFor) rather than asserted on synchronously, same as any + // other async UI. + it("requires an explicit confirmation step before calling the API, and warns plainly about permanence and loose-file deletion", async () => { + renderControl({}); + fireEvent.click(screen.getByRole("button", { name: /finalize/i })); + + // The confirmation dialog must say, in plain language, that this cannot + // be undone AND that it deletes the loose files -- not just "are you + // sure?". + expect(await screen.findByText(/cannot be undone/i)).toBeInTheDocument(); + expect(screen.getByText(/delete/i)).toBeInTheDocument(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("does not call the API when the confirmation dialog is cancelled", async () => { + renderControl({}); + fireEvent.click(screen.getByRole("button", { name: /finalize/i })); + const cancelButton = await screen.findByRole("button", { name: /cancel/i }); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeInTheDocument(); + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("POSTs to /api/finalize with the bearer token and experimentID once confirmed", async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 202, + json: () => Promise.resolve({ status: "queued" }), + }); + + renderControl({}); + fireEvent.click(screen.getByRole("button", { name: /finalize/i })); + const confirmButton = await screen.findByRole("button", { name: /^confirm$/i }); + fireEvent.click(confirmButton); + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/finalize"); + expect(options.method).toBe("POST"); + expect(options.headers.Authorization).toBe("Bearer id-token-123"); + expect(JSON.parse(options.body)).toEqual({ experimentID: "exp1" }); + }); + + it("shows a retryable inline error when the API call fails", async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: "Something broke" }), + }); + + renderControl({}); + fireEvent.click(screen.getByRole("button", { name: /finalize/i })); + const confirmButton = await screen.findByRole("button", { name: /^confirm$/i }); + fireEvent.click(confirmButton); + + // The server's own error message is surfaced verbatim rather than a + // generic "something went wrong" -- more useful for a researcher trying + // to figure out what to do next. + await waitFor(() => { + expect(screen.getByText(/something broke/i)).toBeInTheDocument(); + }); + // Still retryable -- the button must come back rather than getting stuck. + expect(screen.getByRole("button", { name: /finalize/i })).toBeInTheDocument(); + }); +}); + +describe("FinalizeControl — in-progress states", () => { + it.each(["queued", "running"])( + "hides the Finalize button and shows progress copy while status is %s", + (status) => { + renderControl({ finalization: { status } }); + expect(screen.queryByRole("button", { name: /finalize/i })).not.toBeInTheDocument(); + expect(screen.getByText(/finaliz/i)).toBeInTheDocument(); + } + ); +}); + +describe("FinalizeControl — finalized state", () => { + it("shows a finalized notice and no button once finalized is true", () => { + renderControl({ finalized: true, finalization: { status: "finalized" } }); + expect(screen.queryByRole("button", { name: /finalize/i })).not.toBeInTheDocument(); + expect(screen.getByText(/finalized/i)).toBeInTheDocument(); + }); +}); + +describe("FinalizeControl — queued-uploads-pending", () => { + it("explains that uploads are still draining rather than showing a generic failure", () => { + renderControl({ + finalization: { + status: "queued-uploads-pending", + detail: "2 upload(s) for this experiment are still queued; finalize once the queue has drained", + }, + }); + + expect(screen.getByText(/drain/i)).toBeInTheDocument(); + // Must not read as a bare, unexplained failure. + expect(screen.queryByText(/^error$/i)).not.toBeInTheDocument(); + // Retryable once uploads drain. + expect(screen.getByRole("button", { name: /finalize/i })).toBeInTheDocument(); + }); +}); + +describe("FinalizeControl — failed / other terminal statuses", () => { + it("shows the failure detail and offers a retry", () => { + renderControl({ + finalization: { status: "failed", detail: "merged archive checksum mismatch" }, + }); + + expect(screen.getByText(/checksum mismatch/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /finalize/i })).toBeInTheDocument(); + }); + + it("treats nothing-to-archive as informational rather than an error", () => { + renderControl({ + finalization: { + status: "nothing-to-archive", + detail: "experiment has no collision-cache salt", + }, + }); + + expect(screen.getByRole("button", { name: /finalize/i })).toBeInTheDocument(); + }); +}); diff --git a/__tests__/firestore-rules.test.js b/__tests__/firestore-rules.test.js index b69171c..a383a4a 100644 --- a/__tests__/firestore-rules.test.js +++ b/__tests__/firestore-rules.test.js @@ -358,4 +358,125 @@ describe('/experiments — provider-migration generalization (step 7a)', () => { })); }); }); + + // Phase 4 of docs/finalization-spec.md. Finalization is permanent (decision + // 1) -- finalized/finalizedAt/finalization are written ONLY by admin-SDK + // code (functions/src/api-finalize.ts, functions/src/finalization.ts), + // which bypasses these rules entirely, so the whole point of this section + // is that the CLIENT SDK path (what these rules actually govern) can never + // touch them, in either direction: a researcher could otherwise clear + // `finalized` to keep submitting after finalization deleted the loose + // files, or set it early to fake a finalized state before the merge ever + // ran. + describe('6. finalization fields are locked against client writes', () => { + function finalizedFields(overrides = {}) { + return baseFields({ + id: overrides.id, + owner: overrides.owner, + storageProvider: 'zenodo', + providerContainer: { provider: 'zenodo', depositionId: 1 }, + ...overrides, + }); + } + + it('DENIES a client update that clears finalized on an already-finalized experiment', async () => { + const docId = 'exp-finalize-clear'; + await seedDB({ + [`experiments/${docId}`]: { + ...finalizedFields({ id: docId, owner: 'user123' }), + finalized: true, + finalization: { status: 'finalized' }, + }, + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertFails( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { finalized: false }) + ); + }); + + it('DENIES a client update that sets finalized to true on a non-finalized experiment', async () => { + const docId = 'exp-finalize-forge'; + await seedDB({ + [`experiments/${docId}`]: finalizedFields({ id: docId, owner: 'user123' }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertFails( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { finalized: true }) + ); + }); + + it('DENIES a client update that writes finalizedAt', async () => { + const docId = 'exp-finalize-timestamp'; + await seedDB({ + [`experiments/${docId}`]: finalizedFields({ id: docId, owner: 'user123' }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertFails( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { + finalizedAt: new Date(), + }) + ); + }); + + it('DENIES a client update that writes the finalization progress map', async () => { + const docId = 'exp-finalize-progress'; + await seedDB({ + [`experiments/${docId}`]: finalizedFields({ id: docId, owner: 'user123' }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertFails( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { + finalization: { status: 'finalized' }, + }) + ); + }); + + it('DENIES a create that arrives already carrying finalized: true', async () => { + const docId = 'exp-finalize-create-forge'; + const user123 = testEnv.authenticatedContext('user123'); + + await assertFails( + setDoc(doc(user123.firestore(), `experiments/${docId}`), { + ...finalizedFields({ id: docId, owner: 'user123' }), + finalized: true, + }) + ); + }); + + it('ALLOWS an ordinary field update on a finalized experiment, leaving finalized untouched', async () => { + // The owner must still be able to edit ordinary settings (e.g. flip + // `active` off) after finalization -- only finalized/finalizedAt/ + // finalization are locked, not the whole document. + const docId = 'exp-finalize-ordinary-edit'; + await seedDB({ + [`experiments/${docId}`]: { + ...finalizedFields({ id: docId, owner: 'user123' }), + finalized: true, + finalization: { status: 'finalized' }, + active: true, + }, + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertSucceeds( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { active: false }) + ); + }); + + it('ALLOWS ordinary edits on a non-finalized experiment (regression guard)', async () => { + const docId = 'exp-finalize-not-touched'; + await seedDB({ + [`experiments/${docId}`]: finalizedFields({ id: docId, owner: 'user123' }), + }); + + const user123 = testEnv.authenticatedContext('user123'); + await assertSucceeds( + updateDoc(doc(user123.firestore(), `experiments/${docId}`), { maxSessions: 50 }) + ); + }); + }); }); \ No newline at end of file diff --git a/components/dashboard/FinalizeControl.js b/components/dashboard/FinalizeControl.js new file mode 100644 index 0000000..999e02c --- /dev/null +++ b/components/dashboard/FinalizeControl.js @@ -0,0 +1,215 @@ +import { useState } from "react"; +import { + Stack, + HStack, + Text, + Button, + Alert, + Dialog, + Spinner, +} from "@chakra-ui/react"; + +import { auth } from "../../lib/firebase"; + +// Phase 4 of docs/finalization-spec.md. Modeled on MetadataControl.js for the +// data-driven shape and DeleteAccount.js for the confirm-dialog pattern +// (finalization is just as irreversible as account deletion, and for the +// same reason deserves the same explicit "are you sure" step rather than a +// bare toggle). +// +// `data` is the live experiment document as loaded by the parent page's +// useDocumentData listener (pages/admin/[experiment_id].js) -- that listener +// IS the "polling" docs/finalization-spec.md's Phase 4 asks for: every write +// finalizeTask makes to experiments/{id}.finalization (functions/src/ +// api-finalize.ts) arrives here as a normal prop update, no separate fetch +// loop required. +const IN_PROGRESS_STATUSES = new Set(["queued", "running"]); + +// Copy for every terminal, non-finalized status finalizeTask can leave behind +// (FinalizationState in functions/src/interfaces.ts). Keyed by status so a +// new status added to FinalizationResult (finalization.ts) fails safe: an +// unrecognized status still renders the Finalize button with no extra alert, +// rather than looking broken. +const STATUS_COPY = { + // The one status that must never read as a generic, alarming failure: it + // means the researcher's own data is still safely in flight and belongs in + // the archive, not that anything is wrong. + "queued-uploads-pending": { + tone: "warning", + title: "Some uploads are still in flight.", + describe: (detail) => + detail || + "Uploads for this experiment are still queued, and they belong in the final archive. Finalization will wait until the queue has drained -- try again once those uploads finish.", + }, + "nothing-to-archive": { + tone: "info", + title: "Nothing to finalize yet.", + describe: () => + "This experiment has never received any data, so there is nothing to merge. You can finalize later once data has been submitted.", + }, + "archive-too-large": { + tone: "error", + title: "The merged archive is too large for your storage provider.", + describe: (detail) => + detail || "The merged archive exceeded your storage provider's per-file limit.", + }, + "leased-elsewhere": { + tone: "error", + title: "Finalization is already running elsewhere.", + describe: () => + "Another finalization or compaction pass is already in progress for this experiment. Try again shortly.", + }, + "not-eligible": { + tone: "error", + title: "This experiment can't be finalized.", + describe: (detail) => detail || "This experiment is not eligible for finalization.", + }, + failed: { + tone: "error", + title: "Finalization failed.", + describe: (detail) => detail || "Something went wrong while finalizing this experiment.", + }, +}; + +async function requestFinalize(experimentId) { + const user = auth.currentUser; + const idToken = await user.getIdToken(); + const response = await fetch("/api/finalize", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({ experimentID: experimentId }), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error( + body.error || + body.detail || + "Could not start finalization. Nothing was changed -- please try again." + ); + } +} + +export default function FinalizeControl({ data, experimentId }) { + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const status = data?.finalization?.status; + const inProgress = IN_PROGRESS_STATUSES.has(status); + + const handleConfirm = async () => { + setSubmitting(true); + setSubmitError(null); + try { + await requestFinalize(experimentId); + } catch (error) { + setSubmitError(error.message); + } finally { + // Closed either way: on success there is nothing left to confirm (the + // live `data` prop takes over from here), and on failure the dialog + // would otherwise sit on top of the retry button and the error it + // explains -- both live outside the dialog, in the idle-state view. + setOpen(false); + setSubmitting(false); + } + }; + + // Finalized is permanent and wins over anything else: even a stray + // in-progress-looking status from a stale read cannot un-hide the control. + if (data?.finalized === true) { + return ( + <Alert.Root status="success" variant="subtle"> + <Alert.Indicator /> + <Stack gap={1}> + <Alert.Title>This experiment has been finalized.</Alert.Title> + <Text fontSize="sm"> + Every remaining file has been merged into one archive on your + storage provider, and no further submissions will be accepted. + This cannot be undone. + </Text> + </Stack> + </Alert.Root> + ); + } + + if (inProgress) { + return ( + <HStack gap={3} align="center"> + <Spinner size="sm" /> + <Text fontSize="sm"> + Finalizing this experiment… This merges every remaining file + into one archive and may take a while. You can leave this page -- + progress will continue and pick up here when you return. + </Text> + </HStack> + ); + } + + const terminalCopy = STATUS_COPY[status]; + + return ( + <Stack gap={3} align="flex-start"> + {terminalCopy && ( + <Alert.Root status={terminalCopy.tone} variant="subtle"> + <Alert.Indicator /> + <Stack gap={1}> + <Alert.Title>{terminalCopy.title}</Alert.Title> + <Text fontSize="sm">{terminalCopy.describe(data?.finalization?.detail)}</Text> + </Stack> + </Alert.Root> + )} + {submitError && ( + <Alert.Root status="error" variant="subtle"> + <Alert.Indicator /> + <Text fontSize="sm">{submitError}</Text> + </Alert.Root> + )} + + <Button colorPalette="red" onClick={() => setOpen(true)} loading={submitting}> + Finalize experiment + </Button> + + <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content bg="greyBackground" color="white"> + <Dialog.Header fontSize="lg" fontWeight="bold"> + Finalize this experiment? + </Dialog.Header> + + <Dialog.Body> + <Text mb={4}> + This action cannot be undone. Finalizing merges every + remaining file into a single archive on your storage provider + and permanently deletes the loose files it was built from. No + further submissions will be accepted once this completes. + </Text> + <Text> + This can take a while for large studies. You can leave this + page after confirming -- progress continues in the background + and will show here when you return. + </Text> + </Dialog.Body> + + <Dialog.Footer> + <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> + Cancel + </Button> + <Button + colorPalette="red" + loading={submitting} + onClick={handleConfirm} + ml={3} + > + Confirm + </Button> + </Dialog.Footer> + </Dialog.Content> + </Dialog.Positioner> + </Dialog.Root> + </Stack> + ); +} diff --git a/docs/finalization-spec.md b/docs/finalization-spec.md new file mode 100644 index 0000000..3435819 --- /dev/null +++ b/docs/finalization-spec.md @@ -0,0 +1,214 @@ +# Finalization — implementation spec + +**STATUS: all four phases BUILT and tested (2026-08-20).** 58 suites / 680 tests +passing. Not deployed; see the deploy-time caveats at the end. + +End-of-study compaction: merge every batch archive plus all remaining loose +files into ONE archive carrying the full Psych-DS tree, leaving +`dataset_description.json` loose. See the compaction section of +`provider-migration-design.md` for the incremental half, which already ships. + +## Locked decisions + +1. **Finalization is permanent.** A finalized experiment cannot be + un-finalized. It stops accepting submissions: a session arriving afterwards + would sit outside the archive and quietly make the record non-Psych-DS + again. +2. **One archive, essentially always.** Multiple final archives are *allowed* + but they break Psych-DS compatibility, so they are a last resort reachable + only above a provider's hard per-file limit (Zenodo: 50 GB). They must never + be triggered by our own memory ceiling — see Phase 2. +3. **`.psychds-ignore` goes INSIDE the final archive** and its loose copy is + deleted. Nothing regenerates it once submissions stop. + `dataset_description.json` stays loose so the record still shows a + descriptor. +4. **Publishing / minting a DOI is out of scope.** Irreversible, and the + researcher's call. + +## Why streaming, not splitting + +`buildArchive` assembles the whole zip in memory and `writeSessionFile` takes a +`Buffer`, so today the maximum final-archive size is bounded by function +memory. Raising memory just moves the wall. Since decision 2 says a split +breaks the format we exist to produce, the assemble-in-memory path is the thing +to remove: build the archive into Cloud Storage through a stream, then upload +it to the provider from a stream. Memory then stays flat regardless of study +size and a split is only ever forced by Zenodo's own 50 GB per-file limit, +which behavioral data will not reach. + +## Phase 1 — archive reader + +New module `functions/src/archive-reader.ts`. + +```ts +export function readArchive(zip: Buffer): Map<string, Buffer>; +``` + +- Parse the **central directory**, not local file headers. archiver streams, so + it sets the streaming bit and writes zeroed sizes into local headers with the + real values in a trailing data descriptor. The central directory always + carries true sizes. (A working reference implementation exists in + `functions/src/__tests__/compaction-emulator.test.js` — `readZipEntries`.) +- Support compression method 0 (stored) and 8 (deflate, via + `zlib.inflateRawSync`). Throw a descriptive `Error` for anything else. +- **Verify every member's CRC-32 and uncompressed size** against the central + directory before returning it. Not optional politeness: Phase 3 re-emits a + batch's members into the merged archive and then DELETES the batch, so a + member that decoded to the wrong bytes would pass every downstream check + (which only verify the *merged* archive uploaded intact) and the originals + would be gone. Stored entries have no other integrity check at all. +- Byte-exact for binary content. Non-UTF-8 bytes must survive untouched. +- Throw a descriptive `Error` on a truncated buffer, a missing + end-of-central-directory record, or a corrupt entry signature. +- No new npm dependency. + +**Correction, found while building this (2026-08-20):** an earlier draft of this +spec claimed archiver "may choose STORED over DEFLATE" for incompressible data. +That is false for `buildArchive` as configured — `zip-stream` emits method 0 +only when `zlib.level` is exactly 0, the entry is a directory/symlink, or +`store: true` is passed, and `buildArchive` uses level 9 unconditionally. +Verified empirically. Method 0 support is still required (another producer could +use it) but **nothing `buildArchive` emits will ever be method 0**, so no +downstream code may assume mixed methods appear in our own archives. + +## Phase 2 — streaming archive build and upload + +**2a. Provider interface** (`functions/src/providers/types.ts`), optional, +mirroring the `deleteFile` / `downloadFileBytes` convention: + +```ts +// Uploads from a readable stream, for payloads too large to hold in memory. +// Required for any provider with a non-null maxFileCount. +writeStreamedFile?( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + body: NodeJS.ReadableStream, + size: number, // exact byte length; Zenodo's bucket PUT needs Content-Length + meta: FileMeta +): Promise<WriteResult>; +``` + +Implement for Zenodo only. Same contract as `writeSessionFile`: same error +mapping, same `application/octet-stream` requirement (a real mimetype is a hard +415), same defensive read of `key`/`checksum` off the response. + +**2b. Streaming builder**, in `functions/src/compaction.ts` alongside +`buildArchive`: + +```ts +export async function buildArchiveToStorage( + entries: AsyncIterable<{ path: string; content: Buffer }>, + storagePath: string +): Promise<{ size: number; md5: string }>; +``` + +- Pipe `archiver` into `storage.bucket().file(storagePath).createWriteStream()`. +- Compute the md5 of the emitted bytes in-flight (hash a passthrough), so + nothing has to be re-read to verify. +- Consume `entries` lazily — the caller downloads each member just in time, so + at most one member is resident at a time. +- Byte-identical output to `buildArchive` for the same input, including the + pinned entry dates that make archives reproducible. +- `buildArchive` stays as-is; small callers keep using it. + +## Phase 3 — the finalization pass + +`finalizeExperiment(experimentID)` in a new `functions/src/finalization.ts`. +Structurally `compactExperiment` with a different selection rule. + +Order, and it is not negotiable — upload, verify, then delete, never the +reverse: + +1. Acquire the **compaction lease** (`compaction.compactingUntil`). The write + gate (`compaction-gate.ts`) already honors it, so submissions divert to the + queue for free while this runs. +2. Refuse if already finalized. +3. List. Members are every batch archive, every loose session file, and + `.psychds-ignore`. `dataset_description.json` is excluded. +4. Write a `finalization` record (status `uploading`, member hashes, expected + md5) BEFORE uploading, so an interrupted run resumes rather than duplicating + — same crash-safety hinge as `compactionBatches`. +5. Stream-build the merged archive to Cloud Storage: inflate each batch with + `readArchive` and re-emit its entries at their recorded paths; add loose + files at their reconstructed paths (`archivePathsFor`). +6. Upload via `writeStreamedFile`, verify the reported checksum against the + md5 from step 5. +7. Seal any not-yet-sealed claims (loose files), then delete every member and + the Cloud Storage temp object. +8. Mark the experiment finalized and stop accepting submissions. + +**Timeout matters and decides the surface — and the first answer here was +wrong.** The spec originally said "HTTP endpoint, runs synchronously at 3600 s". +That does not work: every DataPipe endpoint is reached through a Firebase +Hosting rewrite (see `firebase.json`), and **hosting rewrites to functions have +a hard 60-second timeout**. The function would keep running while the client +got a 504, which for an irreversible operation is the worst possible UX. A +Firestore trigger is no better — event-triggered functions cap at 540 s. + +So finalization is split in two: + +- `apiFinalize` (`onRequest`, reached at `/api/finalize`) validates auth and + ownership, runs the cheap pre-checks, **enqueues a Cloud Task, and returns + 202 immediately.** Well inside 60 s. +- A `onTaskDispatched` function runs `finalizeExperiment` with a long timeout. + +Returning 202 and continuing work in the same invocation is NOT an option: +Cloud Functions throttles CPU after the response and may kill the instance, so +background work after responding is not guaranteed to finish. + +The client polls the experiment document for progress. The Cloud Tasks emulator +runs as part of `firebase emulators:exec`, so this is testable locally. + +## Phase 4 — endpoint and dashboard control + +- `apiFinalize`, an `onRequest` function. Copy the auth shape from + `api-queue-status.ts`: Bearer ID token, `auth.verifyIdToken`, then an owner + check against the experiment document. +- Enqueues a Cloud Task and returns 202; `finalizeTask` (`onTaskDispatched`) + runs the pass. See the corrected timeout discussion in Phase 3 — a synchronous + endpoint 504s at the hosting layer after 60 s while continuing to run. +- **`onTaskDispatched` caps at 1800 s (30 min)**, not 3600 s; only + `onRequest`/callable functions get 3600 s. Verified while building Phase 4. +- The client polls the experiment document (`finalization` state map) for + progress. +- Dashboard control modelled on `components/dashboard/MetadataControl.js`, with + an explicit confirmation step because it is irreversible. +- Submissions to a finalized experiment are rejected in `api-data.ts` and + `api-base64.ts` with a new message in `api-messages.ts`. +- **`firestore.rules` — DONE.** The experiment update rule tolerated arbitrary + extra fields, so a client could have cleared the finalized flag through the + client SDK. `finalizationFieldsUntouched()` now blocks any client add, change + or removal of `finalized`/`finalizedAt`/`finalization` on update, and + `isCreatableProvider()` blocks them on create. Covered in + `__tests__/firestore-rules.test.js`. + +## Working notes for whoever implements this + +- Tests import from the COMPILED output, so `npm run build --prefix functions` + before running them. +- Run tests from the repo root, never from `functions/`: + `firebase emulators:exec --project datapipe-test 'npx jest --ci <paths>'` + Without `--project datapipe-test` every API test 404s. +- Fixed mock-server ports 3579-3583 are taken. Use 3584+. +- `node-fetch` is ESM-only and Jest's CJS transform cannot parse it. Suites that + make no HTTP call stub it (`jest.mock("node-fetch", () => ({ __esModule: true, + default: jest.fn() }))`); suites that need real requests alias it to Node's + global fetch. See the top of `compaction-emulator.test.js` for both. +- Do not commit. Leave changes in the working tree. + +## Deploy-time caveats — none of these can be verified by the emulator + +- **Composite indexes.** `firestore.indexes.json` gained indexes for the + compaction/finalization queries. The Firestore emulator does not enforce + composite indexes, so the suite passes without them and only a deploy proves + the query shapes match. +- **The Cloud Tasks queue.** `firebase deploy` provisions a queue for an + `onTaskDispatched` function, but that has not been exercised here. Confirm + `finalizetask`'s queue exists and that `apiFinalize` can enqueue to it in the + deployed project. +- **The 30-minute task ceiling.** A study large enough to exceed + `onTaskDispatched`'s 1800 s cap will have its task killed mid-pass. That is + not data loss — the pass is crash-safe and resumes from its `finalizationRuns` + record, and Cloud Tasks retries — but nobody has run a study big enough to + observe it. Worth a deliberate large-study test before relying on it. diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index a76fd19..c122e14 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -337,6 +337,137 @@ touched again. `application/zip` on an unpublished deposition. Bulk download works throughout collection, not only after publication. +### Compaction — BUILT (incremental only, 2026-08-13) + +`functions/src/compaction.ts` + `compaction-triggers.ts`. Incremental batch +sealing ships; **finalization (the single end-of-study merge) does not** and is +the next build. Decided at build time: finalization will **re-merge** sealed +batch zips into one archive rather than leaving them alongside a final zip, so +batch archives are written with the full Psych-DS tree inside them and are +re-mergeable by construction. + +Generalized rather than special-cased. `ProviderCapabilities.maxFileCount` is +what enrols a provider, and it is the one capability that is **not** merely +descriptive — a non-null value is a contract that the adapter also implements +`deleteFile`, `downloadFileBytes` and (on a flat keyspace) `archivePathFor`. +Zenodo's 100 is the only non-null value; Dataverse stays null because its cap +is per-installation and unreadable, and it implements neither method. + +**Discovery is entirely event-driven — there is no cron.** DataPipe is the only +writer to these containers, so it already knows the moment one has grown and +never has to ask on a timer. Two Firestore triggers (`compaction-triggers.ts`): + +- `onDocumentUpdated("experiments/{id}")` fires when `sessions` increments, + which `api-data.ts` already does on every submission path — so this needed no + new write anywhere, and notably avoided threading a provider file counter + through the raw write, every derived-file write, the metadata write and the + retry worker. The before/after snapshots arrive in the event payload, so + "capped provider? did `sessions` move? is a lease already held? could it + plausibly be near the watermark?" all cost zero reads, and a submission to a + non-capped provider returns immediately. Comparing `sessions` is also what + stops a pass from re-triggering itself, since compaction writes only + `compaction.*`. +- `onDocumentWritten("uploadQueue/{id}")` covers the two cases `sessions` + cannot see: an entry with `providerErrorCode: "QUOTA_EXCEEDED"` (the provider + itself reporting the record is full) and an entry reaching `completed` (the + retry worker landing a file for a submission that incremented `sessions` when + it originally failed). + +Three earlier designs were tried and discarded: a 6-hour poll, then a +proximity-aware poll bolted on to patch the hole the first one left, then a +change-triggered poll. All shared the same defect — a burst can fill a record +inside a minute, and any interval reacts after the fact. Firestore triggers are +at-least-once with retries for up to 7 days, and duplicate delivery is harmless +because compaction takes a lease. + +A researcher uploading to the provider by hand produces no event. That is +**documented as unsupported** (see the FAQ) rather than engineered around: it +also desynchronizes the collision cache, so a background sweep would not make it +safe, only later-detected. It is not silent either — the next submission that +finds the record full writes a `QUOTA_EXCEEDED` queue entry, which is the second +trigger above. + +After a successful pass, `compactExperiment` moves the `nextRetryAt` of that +experiment's quota-blocked queue entries to now, so they drain on the retry +worker's next tick instead of waiting out slow-tier backoff for a condition +that has just been fixed. + +**Ordering is the safety property**: upload → verify the reported md5 → seal +claims → delete originals. A batch's membership is written to +`experiments/{id}/compactionBatches/{index}` *before* the archive is uploaded, +so a pass interrupted anywhere resumes rather than sealing the same sessions +into a second zip. That record stores **hashes, not filenames**, preserving the +collision cache's "the raw filename is never stored anywhere" property; the +resume path recovers names by hashing the current listing and matching. + +**Archived claims lose their TTL.** A confirmed claim normally expires after 90 +days, which is safe only because a cold cache rehydrates from the provider's +listing. An archived file is not in that listing, so its claim is rewritten +with `expiresAt` deleted (Firestore TTL skips documents without the field). +Without this, compaction would quietly re-open filenames collected months +earlier — the failure is invisible at compaction time and only surfaces as a +duplicate session much later. + +**`downloadFileBytes` had to be added** alongside `downloadFile`. The latter +returns `response.text()`, which is right for `metadata-block.ts` reading back +JSON and catastrophic here: `/api/base64` submissions are images, audio and +video, and UTF-8 decoding replaces every invalid sequence with U+FFFD. The +archive would be corrupt, the subsequent write would succeed, and the originals +would then be deleted. + +**A write gate, not just recovery, is what keeps a record from filling.** +DataPipe is the only writer to these containers, so while a pass holds the +compaction lease, `api-data.ts`, `api-base64.ts` and the retry worker divert +submissions into the upload queue instead of writing to the provider +(`compaction-gate.ts`). The file count therefore cannot grow during a pass, +which is what guarantees room for the archive it is about to upload. Gating +costs nothing: every one of those callers already loads the experiment +document for its own reasons, so the lease field is in hand. Held entries are +recorded with `providerErrorCode: "CONTENTION"` — precisely this case as +`types.ts` defines it — which puts them on the 60-second fast tier, and +compaction releases them explicitly the moment its pass ends. + +The participant is unaffected: the queue writes its payload to Cloud Storage +first and returns 202, the same durable buffer that already absorbs provider +outages. Holding the data in function memory instead was considered and +rejected — a Cloud Functions instance can be torn down at any moment, so RAM is +the one place it would exist with no durable copy. + +**Saturation is still possible, and still recovers without a human.** The gate +has a residual race: callers test the document they already loaded, so a pass +starting between that read and the provider write is not seen, and a burst can +reach the cap in the gap between a trigger firing and its pass taking the +lease. The fallback is that one slot can always be borrowed — `.psychds-ignore` +holds a fixed constant shared with the Psych-DS tooling, so deleting it to make +room loses nothing and restoring it is a PUT of a literal, needing no resume +state because the next submission would rewrite it anyway. + +An earlier version of that fallback *staged the archive over one of its own +batch members*, reasoning that the member's bytes were already inside the +archive being written. A test caught that this is wrong: if the staged upload +then fails verification, that session's only copy is gone from the provider and +survives solely in function memory. A plain (non-metadataActive) experiment has +no reproducible file, so it gets no fallback and returns `status: "saturated"`, +asking for one file to be removed by hand — an honest trade, since it writes one +file per submission and has far more headroom to begin with. + +For the same burst reason, `KEEP_LOOSE` is 5 (not 20) and `MAX_BATCH_FILES` is +95 (not 60). Every file held back after a pass is headroom given up. + +**Finalization — the end-of-study merge — is specified in +`docs/finalization-spec.md` and is now BUILT (2026-08-20), not yet deployed.** Locked there: finalization is +permanent (a finalized experiment stops accepting submissions), and the merged +result must be ONE archive — multiple archives are allowed only above a +provider's hard per-file limit, because a split breaks the Psych-DS +compatibility the archive exists to provide. That constraint is what rules out +the obvious memory guard and forces a streamed build instead. + +Residual ceiling: each sealed batch is itself a file, so a record still tops +out around `maxFileCount` batches (thousands of sessions at the 60-file default +batch). Finalization removes it. Zenodo's `setupWarnings` — the stopgap telling +researchers to stay under 100 submissions — is deleted, since there is no +longer anything for a researcher to act on at setup. + ### Zenodo spike — RESULT: PASS (live, sandbox.zenodo.org, 2026-08-11) `scripts/zenodo-spike.mjs`, driving the real compiled adapter. **All five gates @@ -682,13 +813,20 @@ Google Drive provider is announced: - Do researchers need placement control for the Drive folder strongly enough to justify a Google Picker integration, or is the app-created root folder acceptable? (Default answer: root folder; revisit on demand.) -- **Zenodo's flat keyspace vs. Psych-DS (raised 2026-08-11, needs a decision).** - Zenodo cannot store a slash in a file key, so a `metadataActive` experiment's - live deposition shows `data_raw_subject-1.json` rather than - `data/raw/subject-1.json`, and is not a valid Psych-DS component while - collection is in progress. Three options: (a) accept it, and let the - compaction archive carry the real Psych-DS tree — cheapest, and the archive is - the artifact researchers actually cite; (b) suppress the derived Psych-DS - files on Zenodo and generate them only into the archive; (c) treat Zenodo as - unsupported for `metadataActive` experiments. (a) is the working assumption - and what the code does today. +- ~~**Zenodo's flat keyspace vs. Psych-DS**~~ **RESOLVED as (a), 2026-08-13.** + Live keys stay flat and the compaction archive carries the real Psych-DS + tree. What made (a) safe rather than merely cheapest is that the + reconstruction turned out to be **exact, not heuristic**: + `metadata-derived-files.ts` flattens researcher subfolders with `-` *before* + building a path, so a leaf reaching a provider never contains a slash, and + the only shapes DataPipe writes are `data/raw/<leaf>`, + `data/<stem>_data.csv`, `dataset_description.json` and `.psychds-ignore`. + `zenodo.ts`'s `fromZenodoKey` inverts exactly those. It is applied only to + `metadataActive` experiments — the gate that removes the one residual + ambiguity, a researcher's own `data_x.json` in an experiment that writes no + slashed paths at all. +- **Not yet verified in production: the three new composite indexes** added to + `firestore.indexes.json` for compaction (two on `uploadQueue`, + one on `experiments`). The Firestore emulator does not enforce composite + indexes, so the test suite passes without them and only a deploy can confirm + the query shapes match. diff --git a/firebase.json b/firebase.json index 4bf2f74..f5e8444 100644 --- a/firebase.json +++ b/firebase.json @@ -81,6 +81,10 @@ { "source": "/api/deleteaccount", "function": "deleteaccount" + }, + { + "source": "/api/finalize", + "function": "apifinalize" } ] }, diff --git a/firestore.indexes.json b/firestore.indexes.json index 0dbb05f..1348976 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -4,26 +4,68 @@ "collectionGroup": "users", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "usingPersonalToken", "order": "ASCENDING" }, - { "fieldPath": "refreshTokenExpires", "order": "ASCENDING" } + { + "fieldPath": "usingPersonalToken", + "order": "ASCENDING" + }, + { + "fieldPath": "refreshTokenExpires", + "order": "ASCENDING" + } ] }, { "collectionGroup": "uploadQueue", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "experimentID", "order": "ASCENDING" }, - { "fieldPath": "owner", "order": "ASCENDING" }, - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "experimentID", + "order": "ASCENDING" + }, + { + "fieldPath": "owner", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "uploadQueue", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "nextRetryAt", "order": "ASCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "nextRetryAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "uploadQueue", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "experimentID", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "providerErrorCode", + "order": "ASCENDING" + } ] } ], diff --git a/firestore.rules b/firestore.rules index 313a31b..2ff76aa 100644 --- a/firestore.rules +++ b/firestore.rules @@ -27,11 +27,34 @@ service cloud.firestore { function baseFields() { return request.resource.data.keys().hasAll(['active', 'activeBase64', 'activeConditionAssignment', 'id', 'owner', 'title', 'sessions', 'nConditions', 'currentCondition', 'useValidation', 'allowJSON', 'allowCSV', 'requiredFields', 'maxSessions', 'limitSessions']) } + // Finalization is PERMANENT (docs/finalization-spec.md, decision 1): + // finalized/finalizedAt/finalization are written only by admin-SDK code + // (api-finalize.ts / finalization.ts), which never goes through these + // rules at all. This is what stops the CLIENT SDK path from doing it + // instead -- diff().affectedKeys() flags a key the instant a write + // would add, change, OR remove it, so this blocks every direction at + // once: clearing `finalized` back to false to keep submitting after the + // loose files are gone, and setting it early to forge a finalized state + // that was never actually merged. + // + // `compaction` and `collisionCache` are listed for a different but + // equally concrete reason: both are server-managed maps holding + // Timestamps that server code calls .toMillis() on directly, and + // compaction.compactingUntil is read on the participant SUBMISSION path + // (compaction-gate.ts). A client writing a non-Timestamp there would + // have broken every submission to that experiment. The gate is now + // defensive about that too, but the field has no business being + // client-writable in the first place. Verified no client code writes + // either map. + function serverManagedFieldsUntouched() { + return !request.resource.data.diff(resource.data).affectedKeys() + .hasAny(['finalized', 'finalizedAt', 'finalization', 'compaction', 'collisionCache']); + } // UPDATE shape: legacy-tolerant. An experiment created before the // provider-migration schema carries the OSF triple and no // storageProvider, and must stay editable through the OSF wind-down. function verifyFields() { - return baseFields() && + return baseFields() && serverManagedFieldsUntouched() && (('storageProvider' in request.resource.data) ? request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']) : request.resource.data.keys().hasAll(['osfRepo', 'osfComponent', 'osfFilesLink'])); @@ -46,10 +69,15 @@ service cloud.firestore { // also closes the legacy no-provider branch above, which meant OSF by // default (see getProviderForExperiment in // functions/src/providers/index.ts). + // No new experiment is ever born finalized -- a create that already + // carries finalized/finalizedAt/finalization is exactly as forged as an + // update that adds them (see serverManagedFieldsUntouched() above), just + // with no prior document for diff() to compare against. function isCreatableProvider() { return ('storageProvider' in request.resource.data) && request.resource.data.storageProvider != 'osf' && - request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']); + request.resource.data.keys().hasAll(['storageProvider', 'providerContainer']) && + !request.resource.data.keys().hasAny(['finalized', 'finalizedAt', 'finalization']); } allow read: if(request.auth.uid != null) && resource.data.owner == request.auth.uid; diff --git a/functions/.env.local b/functions/.env.local index 16d4020..d7684a1 100644 --- a/functions/.env.local +++ b/functions/.env.local @@ -17,4 +17,15 @@ GDRIVE_AUTHORIZE_URL=http://127.0.0.1:3580/authorize GDRIVE_CLIENT_ID=test-client-id GDRIVE_CLIENT_SECRET=test-client-secret GDRIVE_REDIRECT_URI=http://localhost:3000/oauth2/connect + +# Redirects every Zenodo call to zenodo-emulator.test.js's mock (port 3581). +# Zenodo's adapter allowlists zenodo.org/sandbox.zenodo.org, so unlike gdrive +# this is not merely a convenience -- there is no other way to reach a local +# mock. zenodo.ts only reads it when FUNCTIONS_EMULATOR=true, so it cannot +# take effect on a deployed function even if it leaked into a deploy. +# +# Dataverse deliberately has NO equivalent: its serverUrl rides on the +# container, so dataverse-emulator.test.js seeds http://127.0.0.1:3582 +# straight into the experiment doc and needs no env wiring at all. +ZENODO_API_BASE=http://127.0.0.1:3581 TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab diff --git a/functions/src/__tests__/api-finalize-emulator.test.js b/functions/src/__tests__/api-finalize-emulator.test.js new file mode 100644 index 0000000..475a8bf --- /dev/null +++ b/functions/src/__tests__/api-finalize-emulator.test.js @@ -0,0 +1,293 @@ +/** + * @jest-environment node + */ + +// End-to-end coverage for the Phase 4 surface of docs/finalization-spec.md: +// apiFinalize (onRequest, POST /api/finalize) and finalizeTask +// (onTaskDispatched), which apiFinalize hands off to via a real Cloud Task. +// +// This suite is deliberately NOT about finalizeExperiment's own correctness +// -- that is finalization-emulator.test.js's job, exercised in-process +// against a mock Zenodo. This suite is about the PLUMBING around it: auth, +// ownership, the cheap pre-checks, the HTTP contract, and -- the genuinely +// new risk surface -- whether a task enqueued through the real Cloud Tasks +// emulator actually reaches finalizeTask and whether finalizeTask correctly +// narrates `experiments/{id}.finalization` through queued -> running -> +// a terminal status. +// +// Both scenarios below are chosen SPECIFICALLY so finalizeExperiment reaches +// its terminal status without any network call at all, so this suite needs no +// mock provider server (and no fixed port to contend for): +// - a gdrive-shaped experiment: gdrive.capabilities.maxFileCount is null, +// so finalizeExperiment returns "not-eligible" immediately after +// getProvider() (a pure in-memory lookup), before ever touching the +// provider or the owner's token. +// - a zenodo-shaped experiment with a collisionCache.salt already set and a +// pending uploadQueue entry: finalizeExperiment's queued-uploads-pending +// check runs BEFORE it acquires the compaction lease or calls +// provider.listFiles, so this also resolves with no network call. +// +// Real Auth-emulator idTokens via accounts:signUp, same helper as +// create-experiment-emulator.test.js and oauth-connect-emulator.test.js. + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +jest.setTimeout(60000); + +const config = { projectId: "datapipe-test" }; +const FUNCTIONS_BASE = "http://localhost:5001/datapipe-test/us-central1"; +const APIFINALIZE_URL = `${FUNCTIONS_BASE}/apifinalize`; +const AUTH_EMULATOR_SIGNUP_URL = + "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake"; + +async function signUpEmulatorUser() { + const email = `api-finalize-${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 callFinalize(experimentID, idToken, { method = "POST" } = {}) { + const headers = { "Content-Type": "application/json" }; + if (idToken !== undefined) { + headers.Authorization = `Bearer ${idToken}`; + } + const res = await fetch(APIFINALIZE_URL, { + method, + headers, + body: method === "POST" ? JSON.stringify({ experimentID }) : undefined, + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { rawBody: text }; + } + return { status: res.status, body }; +} + +let db; + +beforeAll(() => { + let app; + try { + app = getApp("api-finalize-test"); + } catch { + app = initializeApp(config, "api-finalize-test"); + } + db = getFirestore(app); +}); + +async function seedNotEligibleExperiment(uid) { + const experimentID = `finalize-notelig-${randomUUID()}`; + await db.collection("experiments").doc(experimentID).set({ + owner: uid, + active: true, + sessions: 0, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId: "irrelevant-folder" }, + }); + return experimentID; +} + +async function seedQueuedUploadsPendingExperiment(uid) { + const experimentID = `finalize-queued-${randomUUID()}`; + await db.collection("experiments").doc(experimentID).set({ + owner: uid, + active: true, + sessions: 1, + storageProvider: "zenodo", + providerContainer: { + provider: "zenodo", + depositionId: 1, + bucketUrl: "http://127.0.0.1:1/api/files/nonexistent-bucket", + serverUrl: "https://zenodo.org", + }, + collisionCache: { salt: "api-finalize-test-salt" }, + }); + await db.collection("uploadQueue").add({ + experimentID, + owner: uid, + status: "pending", + filename: "sub-1_data.json", + dataType: "data", + storageProvider: "zenodo", + }); + return experimentID; +} + +async function seedLegacyExperiment(uid) { + // No storageProvider/providerContainer at all -- the cheap pre-check + // apiFinalize is meant to run itself, without ever reaching a Cloud Task. + const experimentID = `finalize-legacy-${randomUUID()}`; + await db.collection("experiments").doc(experimentID).set({ + owner: uid, + active: true, + sessions: 0, + osfRepo: "abc12", + osfComponent: "def34", + osfFilesLink: "https://files.osf.io/v1/resources/abc12/providers/osfstorage/", + }); + return experimentID; +} + +async function waitForTerminalFinalization(experimentID, { timeoutMs = 30000, pollMs = 250 } = {}) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const snap = await db.collection("experiments").doc(experimentID).get(); + const data = snap.data(); + const status = data?.finalization?.status; + if (status && status !== "queued" && status !== "running") { + return data; + } + if (Date.now() > deadline) { + throw new Error( + `finalization on ${experimentID} never reached a terminal status (last seen: ${status ?? "none"})` + ); + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } +} + +describe("apiFinalize — auth and request shape", () => { + it("rejects non-POST methods", async () => { + const { status } = await callFinalize("whatever", "irrelevant", { method: "GET" }); + expect(status).toBe(405); + }); + + it("returns 401 when there is no Authorization header", async () => { + const { status } = await callFinalize("whatever", undefined); + expect(status).toBe(401); + }); + + it("returns 401 for a garbage bearer token", async () => { + const { status } = await callFinalize("whatever", "not-a-real-token"); + expect(status).toBe(401); + }); + + it("returns 400 when experimentID is missing from the body", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const res = await fetch(APIFINALIZE_URL, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${idToken}` }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + void uid; + }); + + it("returns 403 for an experiment that does not exist", async () => { + const { idToken } = await signUpEmulatorUser(); + const { status } = await callFinalize(`no-such-experiment-${randomUUID()}`, idToken); + expect(status).toBe(403); + }); + + it("returns 403 when the caller does not own the experiment", async () => { + const owner = await signUpEmulatorUser(); + const intruder = await signUpEmulatorUser(); + const experimentID = await seedNotEligibleExperiment(owner.uid); + + const { status } = await callFinalize(experimentID, intruder.idToken); + expect(status).toBe(403); + + // And nothing was enqueued on the owner's behalf by the rejected call. + const snap = await db.collection("experiments").doc(experimentID).get(); + expect(snap.data().finalization).toBeUndefined(); + }); +}); + +describe("apiFinalize — cheap pre-checks (no Cloud Task involved)", () => { + it("returns not-eligible for a legacy OSF-shaped experiment without enqueueing anything", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const experimentID = await seedLegacyExperiment(uid); + + const { status, body } = await callFinalize(experimentID, idToken); + expect(status).toBe(400); + expect(body.status).toBe("not-eligible"); + + // A synchronous pre-check rejection must not leave a "queued" state + // behind for the dashboard to poll against forever. + const snap = await db.collection("experiments").doc(experimentID).get(); + expect(snap.data().finalization).toBeUndefined(); + }); + + it("returns already-finalized without touching finalization state", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const experimentID = await seedNotEligibleExperiment(uid); + await db.collection("experiments").doc(experimentID).update({ + finalized: true, + finalizedAt: Timestamp.now(), + }); + + const { status, body } = await callFinalize(experimentID, idToken); + expect(status).toBe(200); + expect(body.status).toBe("already-finalized"); + + const snap = await db.collection("experiments").doc(experimentID).get(); + expect(snap.data().finalization).toBeUndefined(); + }); +}); + +describe("apiFinalize + finalizeTask — real Cloud Tasks round trip", () => { + it("enqueues a task that carries finalization.status through queued/running to a terminal not-eligible result", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const experimentID = await seedNotEligibleExperiment(uid); + + const { status, body } = await callFinalize(experimentID, idToken); + expect(status).toBe(202); + expect(body.status).toBe("queued"); + + const final = await waitForTerminalFinalization(experimentID); + expect(final.finalization.status).toBe("not-eligible"); + expect(typeof final.finalization.detail).toBe("string"); + expect(final.finalization.startedAt).toBeTruthy(); + expect(final.finalization.finishedAt).toBeTruthy(); + // not-eligible is a refusal, not a success -- the experiment must not + // have been marked finalized. + expect(final.finalized).not.toBe(true); + }); + + it("surfaces queued-uploads-pending as its own terminal status, not a generic failure", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const experimentID = await seedQueuedUploadsPendingExperiment(uid); + + const { status, body } = await callFinalize(experimentID, idToken); + expect(status).toBe(202); + expect(body.status).toBe("queued"); + + const final = await waitForTerminalFinalization(experimentID); + expect(final.finalization.status).toBe("queued-uploads-pending"); + expect(final.finalization.detail).toMatch(/queued/i); + expect(final.finalized).not.toBe(true); + }); + + it("is idempotent against a duplicate click: a second call while the first is in flight does not error", async () => { + const { uid, idToken } = await signUpEmulatorUser(); + const experimentID = await seedNotEligibleExperiment(uid); + + const first = await callFinalize(experimentID, idToken); + expect(first.status).toBe(202); + + const second = await callFinalize(experimentID, idToken); + // Whether the first task already flipped this to "running" or is still + // "queued", the second call must report the in-flight state rather than + // enqueueing a second task or erroring. + expect(second.status).toBe(202); + expect(["queued", "running"]).toContain(second.body.status); + + // The pass still reaches exactly one clean terminal state. + const final = await waitForTerminalFinalization(experimentID); + expect(final.finalization.status).toBe("not-eligible"); + }); +}); diff --git a/functions/src/__tests__/archive-reader.test.js b/functions/src/__tests__/archive-reader.test.js new file mode 100644 index 0000000..7342b3f --- /dev/null +++ b/functions/src/__tests__/archive-reader.test.js @@ -0,0 +1,308 @@ +/** + * @jest-environment node + */ + +// Unit coverage for archive-reader.ts's readArchive -- the inverse of +// compaction.ts's buildArchive. buildArchive is the only producer of the +// archives this reads in production (Phase 3 will feed its batch zips back +// through readArchive during finalization), so the round-trip test against +// buildArchive's own output is the one that matters most here: if the two +// ever drift, a finalization pass would silently drop or corrupt data. +// +// archive-reader.ts itself has zero firebase imports and needs no bootstrap, +// but this suite also imports compaction.js for buildArchive, which pulls in +// app.js (initializeApp with no args) and providers/index.js (and therefore +// every adapter, each importing the ESM-only node-fetch at module scope) -- +// same env-var and node-fetch mock as compaction.test.js. Nothing here makes +// an HTTP call. +import { randomBytes } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); +jest.mock("node-fetch", () => ({ __esModule: true, default: jest.fn() })); + +let readArchive; +let buildArchive; +let archiver; + +beforeAll(async () => { + readArchive = (await import("../../lib/archive-reader.js")).readArchive; + buildArchive = (await import("../../lib/compaction.js")).buildArchive; + archiver = (await import("archiver")).default; +}); + +/** + * buildArchive always configures archiver with zlib level 9 and never sets + * `store: true`, so in practice it emits method 8 (deflate) for every entry + * regardless of how compressible the content is -- zip-stream (archiver's + * dependency) only switches to STORED when zlib.level is exactly 0, or the + * entry is a directory/symlink, or `store` is passed explicitly. So + * buildArchive itself never exercises method 0, contrary to what the spec's + * "archiver may choose STORED over DEFLATE" comment implies. The requirement + * to support method 0 still stands (it's a valid zip and other producers use + * it), so this builds one directly with archiver's `store` option to get real + * coverage of that decode path. + */ +function buildStoredArchive(entries) { + const archive = archiver("zip", { zlib: { level: 9 } }); + const chunks = []; + const collected = new Promise((resolve, reject) => { + archive.on("data", (chunk) => chunks.push(chunk)); + archive.on("warning", reject); + archive.on("error", reject); + archive.on("end", () => resolve(Buffer.concat(chunks))); + }); + for (const entry of entries) { + archive.append(entry.content, { name: entry.path, date: new Date(0), store: true }); + } + archive.finalize(); + return collected; +} + +describe("readArchive / buildArchive round-trip", () => { + test("round-trips buildArchive output exactly", async () => { + const inputs = [ + { path: "dataset_description.json", content: Buffer.from('{"name":"study"}') }, + { path: "data/raw/subject-1.json", content: Buffer.from('{"trials":[1,2,3]}') }, + { path: "data/subject-1_data.csv", content: Buffer.from("a,b\n1,2\n3,4\n") }, + ]; + const { zip } = await buildArchive(inputs); + + const entries = readArchive(zip); + + expect(entries.size).toBe(inputs.length); + for (const input of inputs) { + expect(entries.has(input.path)).toBe(true); + expect(entries.get(input.path).equals(input.content)).toBe(true); + } + }); + + test("nested paths survive", async () => { + const { zip } = await buildArchive([ + { path: "data/raw/subject-1.json", content: Buffer.from("{}") }, + { path: "data/raw/deep/nested/path/subject-2.json", content: Buffer.from("{}") }, + ]); + + const entries = readArchive(zip); + + expect([...entries.keys()].sort()).toEqual( + ["data/raw/deep/nested/path/subject-2.json", "data/raw/subject-1.json"].sort() + ); + }); + + test("non-UTF-8 binary content round-trips byte-identically", async () => { + const binary = Buffer.from([0x89, 0xff, 0xfe, 0x00, 0x80, 0x00, 0x01, 0x02, 0x89, 0xff]); + const { zip } = await buildArchive([{ path: "data/blob.bin", content: binary }]); + + const entries = readArchive(zip); + + expect(entries.get("data/blob.bin").equals(binary)).toBe(true); + }); + + test("an empty archive yields an empty Map", async () => { + const { zip } = await buildArchive([]); + + const entries = readArchive(zip); + + expect(entries.size).toBe(0); + }); + + test("incompressible random bytes round-trip under deflate (buildArchive's actual path)", async () => { + const random = randomBytes(64 * 1024); + const { zip } = await buildArchive([{ path: "data/random.bin", content: random }]); + + const entries = readArchive(zip); + + expect(entries.get("data/random.bin").equals(random)).toBe(true); + }); + + test("compression method 0 (stored) round-trips byte-identically", async () => { + // See buildStoredArchive's comment: buildArchive itself never emits + // method 0, so this constructs one directly to cover the STORED decode + // path the spec requires regardless. + const content = Buffer.from("hello, this entry is stored, not deflated"); + const zip = await buildStoredArchive([{ path: "data/stored.txt", content }]); + + // Confirm the entry really is method 0 before trusting the round-trip -- + // otherwise this would silently degrade into re-testing deflate. + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + const cdOffset = zip.readUInt32LE(eocd + 16); + expect(zip.readUInt16LE(cdOffset + 10)).toBe(0); + + const entries = readArchive(zip); + expect(entries.get("data/stored.txt").equals(content)).toBe(true); + }); + + test("a few hundred entries round-trip without offset-arithmetic bugs", async () => { + const inputs = []; + for (let i = 0; i < 300; i += 1) { + inputs.push({ + path: `data/raw/subject-${i}.json`, + content: Buffer.from(JSON.stringify({ index: i, payload: "x".repeat(i % 50) })), + }); + } + const { zip } = await buildArchive(inputs); + + const entries = readArchive(zip); + + expect(entries.size).toBe(inputs.length); + for (const input of inputs) { + expect(entries.get(input.path).equals(input.content)).toBe(true); + } + }); +}); + +describe("readArchive integrity verification", () => { + // Phase 3 re-emits a batch archive's members into a merged archive and then + // DELETES the batch archive -- DataPipe keeps no other copy of submitted + // data. If readArchive silently accepted a member whose decoded bytes don't + // match what the archive itself claims, that corruption would be baked into + // the merged archive, "verified" only by checking that upload succeeded, + // and then the only surviving copy would be deleted. So every decoded + // member is checked against the central directory's own crc32 and + // uncompressed-size fields before being handed back, the same + // verify-before-delete discipline compaction.ts applies to the provider + // checksum. + + test("a valid archive still round-trips (integrity checks don't false-positive)", async () => { + const content = Buffer.from("nothing wrong with this entry"); + const zip = await buildStoredArchive([{ path: "data/fine.txt", content }]); + + const entries = readArchive(zip); + + expect(entries.get("data/fine.txt").equals(content)).toBe(true); + }); + + test("detects a corrupted member via CRC-32 mismatch", async () => { + // Built STORED, not deflated: a flipped byte in a deflate stream often + // fails to inflate at all, which would exercise the existing "failed to + // inflate" path instead of the new CRC check this test targets. STORED + // has no decode step to fail, so a flipped byte can ONLY be caught by the + // CRC-32 check -- which is exactly why the spec calls out that stored + // entries have no integrity check without it. + const content = Buffer.from("this entry's bytes must not be altered in transit"); + const zip = await buildStoredArchive([{ path: "data/tamper.txt", content }]); + + // Locate the member's actual data bytes the same way readArchive does: + // central directory entry -> local header offset -> past the local + // header's name/extra fields. Deliberately NOT touching the central + // directory itself, so this is a genuine "the bytes changed after the + // archive was built" corruption, not a relabeled size/crc field. + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + const cdOffset = zip.readUInt32LE(eocd + 16); + const localHeaderOffset = zip.readUInt32LE(cdOffset + 42); + const localNameLength = zip.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = zip.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength; + + const corrupted = Buffer.from(zip); + // Flip a bit in the middle of the member's data. Same length in, same + // length out, so this can't accidentally also trip the size check -- + // isolating this test to the CRC path specifically. + corrupted[dataStart + 5] ^= 0xff; + + expect(() => readArchive(corrupted)).toThrow(/data\/tamper\.txt/); + expect(() => readArchive(corrupted)).toThrow(/crc/i); + }); + + test("rejects a member whose recorded uncompressed size disagrees with reality", async () => { + const content = Buffer.from("this entry's declared size will be lied about"); + const zip = await buildStoredArchive([{ path: "data/wrong-size.txt", content }]); + + // Tamper only the central directory's uncompressed-size field (offset 24 + // from the entry start) -- the member's actual bytes, and its crc32 + // field, are untouched, so this isolates the size check from the CRC + // check above. + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + const cdOffset = zip.readUInt32LE(eocd + 16); + + const corrupted = Buffer.from(zip); + const declaredSize = corrupted.readUInt32LE(cdOffset + 24); + corrupted.writeUInt32LE(declaredSize + 10, cdOffset + 24); + + expect(() => readArchive(corrupted)).toThrow(/data\/wrong-size\.txt/); + expect(() => readArchive(corrupted)).toThrow(/size/i); + }); +}); + +describe("readArchive error handling", () => { + test("throws a descriptive error for a missing end-of-central-directory record", () => { + const notAZip = Buffer.from("this is definitely not a zip file, just plain text"); + + expect(() => readArchive(notAZip)).toThrow(/end-of-central-directory/i); + }); + + test("throws a descriptive error for a truncated buffer", () => { + expect(() => readArchive(Buffer.alloc(0))).toThrow(); + expect(() => readArchive(Buffer.from([0x50, 0x4b]))).toThrow(); + }); + + test("throws a descriptive error for a corrupt central-directory entry signature", async () => { + const { zip } = await buildArchive([{ path: "a.json", content: Buffer.from("hi") }]); + + // Find the central directory (EOCD points at it) and flip one byte of the + // first entry's signature so it no longer reads 0x02014b50. + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + expect(eocd).toBeGreaterThanOrEqual(0); + const cdOffset = zip.readUInt32LE(eocd + 16); + + const corrupted = Buffer.from(zip); + corrupted[cdOffset] = 0xff; + + expect(() => readArchive(corrupted)).toThrow(/central directory/i); + }); + + test("throws a descriptive error naming the entry and method for unsupported compression", async () => { + // Method 8 (deflate) is what buildArchive normally emits for compressible + // text at zlib level 9; force STORED-vs-DEFLATE is out of our control, so + // instead we synthesize an entry with an unsupported method (e.g. 12, + // bzip2) by patching a real central-directory entry's method field. + const { zip } = await buildArchive([{ path: "a.json", content: Buffer.from("hi") }]); + + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + const cdOffset = zip.readUInt32LE(eocd + 16); + + const corrupted = Buffer.from(zip); + // Central directory entry method field is at offset +10 from the entry + // start (see readArchive's own layout comments / the reference in + // compaction-emulator.test.js). + corrupted.writeUInt16LE(12, cdOffset + 10); + + expect(() => readArchive(corrupted)).toThrow(/a\.json/); + expect(() => readArchive(corrupted)).toThrow(/12/); + }); +}); diff --git a/functions/src/__tests__/compaction-emulator.test.js b/functions/src/__tests__/compaction-emulator.test.js new file mode 100644 index 0000000..f9bf7be --- /dev/null +++ b/functions/src/__tests__/compaction-emulator.test.js @@ -0,0 +1,1109 @@ +/** + * @jest-environment node + */ + +// End-to-end coverage for archive compaction (compaction.ts / +// compaction-triggers.ts) against a self-contained mock Zenodo. +// +// WHY THIS IS IN-PROCESS RATHER THAN THROUGH THE FUNCTIONS EMULATOR, unlike +// zenodo-emulator.test.js: compaction has no HTTP endpoint. It is driven +// entirely by Firestore triggers, whose handlers are invoked here through the +// v2 `.run()` seam, and by compactExperiment directly -- the same approach +// scheduled-upload-retry.ts's retryPendingUploads seam takes. +// +// Being in-process also means this file, not functions/.env.local, supplies +// ZENODO_API_BASE. It points at port 3583 (its own mock, distinct from +// zenodo-emulator.test.js's 3581 so the two suites can run concurrently), and +// FUNCTIONS_EMULATOR is set below because zenodo.ts refuses to honor the +// override without it. The seeded container still carries a realistic +// https://zenodo.org, so if the override ever stopped applying these tests +// would try to reach the real service and fail loudly rather than passing +// against a mock they were never using. +// +// THE MOCK COMPUTES REAL MD5s. That is the one substantive difference from +// zenodo-emulator.test.js's mock, which reports a stand-in. Compaction deletes +// research data on the strength of a checksum comparison, so a mock that +// could not produce a genuine mismatch would leave the only safety gate in +// the feature untested. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore, Timestamp, FieldValue } from "firebase-admin/firestore"; +import { randomUUID, createHash } from "crypto"; +import { inflateRawSync } from "zlib"; +import express from "express"; +// The real constant, so the seeded record matches what compaction restores. +import { PSYCHDS_IGNORE_CONTENT } from "@jspsych/metadata"; + +const ZENODO_PORT = 3583; +const BUCKET_ID = "compaction-bucket"; +const ZENODO_SERVER_URL = "https://zenodo.org"; +const OWNER_ID = "compaction-emulator-owner"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); +// See the header: without this, zenodo.ts ignores ZENODO_API_BASE by design. +process.env.FUNCTIONS_EMULATOR = "true"; +process.env.ZENODO_API_BASE = `http://127.0.0.1:${ZENODO_PORT}`; + +jest.setTimeout(120000); + +// Every adapter imports node-fetch at module scope, and it is ESM-only, so +// Jest's CJS transform cannot parse it -- the wall every in-process suite here +// hits. Those suites stub it with a jest.fn() because they make no HTTP call; +// this one is the opposite, and its whole point is that real requests reach +// the mock below. Node 22's built-in fetch is a drop-in for the surface the +// adapter uses (text/json/arrayBuffer, and a Buffer body on PUT), so the +// module is aliased to it rather than faked. +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => globalThis.fetch(...args), +})); + +const md5 = (buffer) => createHash("md5").update(buffer).digest("hex"); + +// -------------------------------------------------------------------------- +// A minimal ZIP reader. +// +// Hand-rolled rather than pulled in as a dependency, and it reads the CENTRAL +// DIRECTORY rather than local headers on purpose: archiver streams, so it sets +// the streaming bit and writes zeroed sizes into local headers with the real +// values in a trailing data descriptor. The central directory always carries +// the true sizes, so this stays correct regardless of how archiver chooses to +// emit an entry. +// -------------------------------------------------------------------------- +function readZipEntries(zip) { + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) { + throw new Error("no end-of-central-directory record: not a zip"); + } + + const count = zip.readUInt16LE(eocd + 10); + let offset = zip.readUInt32LE(eocd + 16); + const entries = new Map(); + + for (let i = 0; i < count; i += 1) { + if (zip.readUInt32LE(offset) !== 0x02014b50) { + throw new Error(`corrupt central directory entry at ${offset}`); + } + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localOffset = zip.readUInt32LE(offset + 42); + const name = zip.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + + const localNameLength = zip.readUInt16LE(localOffset + 26); + const localExtraLength = zip.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const raw = zip.subarray(dataStart, dataStart + compressedSize); + + entries.set(name, method === 0 ? Buffer.from(raw) : inflateRawSync(raw)); + offset += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +// -------------------------------------------------------------------------- +// Mock Zenodo: the four routes compaction touches. +// -------------------------------------------------------------------------- +function createMockZenodo() { + const app = express(); + app.use(express.raw({ type: () => true, limit: "200mb" })); + + const files = new Map(); // key -> Buffer + const deleteCounts = new Map(); + let corruptChecksums = false; + let failDeletes = false; + + app.get("/api/deposit/depositions", (req, res) => res.status(200).json([])); + + app.put("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + if (req.headers["content-type"] !== "application/octet-stream") { + res.status(415).json({ status: 415, message: "Invalid 'Content-Type' header." }); + return; + } + const content = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? ""); + if (!files.has(key) && files.size >= 100) { + res.status(400).json({ + status: 400, + message: "Uploading selected files will result in exceeding the max amount per record.", + }); + return; + } + files.set(key, content); + res.status(200).json({ + key, + size: content.length, + // The knob the checksum-mismatch test turns. Real Zenodo reports + // "md5:<hex>" of the stored bytes. + checksum: corruptChecksums ? "md5:deadbeef" : `md5:${md5(content)}`, + }); + }); + + app.get("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + if (!files.has(key)) { + res.status(404).json({ status: 404, message: "Object does not exist." }); + return; + } + res.status(200).send(files.get(key)); + }); + + app.delete("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + deleteCounts.set(key, (deleteCounts.get(key) || 0) + 1); + if (failDeletes) { + res.status(500).json({ status: 500, message: "Internal server error" }); + return; + } + if (!files.has(key)) { + res.status(404).json({ status: 404, message: "Object does not exist." }); + return; + } + files.delete(key); + res.status(204).send(); + }); + + app.get("/api/deposit/depositions/:id/files", (req, res) => { + res.status(200).json( + Array.from(files.entries()).map(([key, content]) => ({ + id: `file-${key}`, + filename: key, + filesize: content.length, + checksum: `md5:${md5(content)}`, + })) + ); + }); + + return new Promise((resolve, reject) => { + // EADDRINUSE retry, matching gdrive-emulator.test.js: jest may schedule a + // concurrent worker while a previous run of this suite is still releasing + // the port. + const tryListen = (retriesLeft) => { + const server = app.listen(ZENODO_PORT); + server.once("listening", () => + resolve({ + server, + seed: (key, content) => files.set(key, Buffer.from(content)), + get: (key) => files.get(key) ?? null, + has: (key) => files.has(key), + keys: () => Array.from(files.keys()), + remove: (key) => files.delete(key), + size: () => files.size, + deleteCount: (key) => deleteCounts.get(key) || 0, + setCorruptChecksums: (value) => { + corruptChecksums = value; + }, + setFailDeletes: (value) => { + failDeletes = value; + }, + reset: () => { + files.clear(); + deleteCounts.clear(); + corruptChecksums = false; + failDeletes = false; + }, + }) + ); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); + }); +} + +// Derived from the shipped constants rather than hardcoded, so tuning the +// headroom (which this feature does, to absorb bursts) does not turn every +// assertion in this file into an arithmetic puzzle. +const SESSIONS = 83; +let KEEP_LOOSE; +let ARCHIVED; // how many of those sessions one pass seals +let REMAINING; // files left on the provider afterwards + +let db; +let mock; +let compactExperiment; +let onExperimentGrew; +let onUploadQueueChanged; +let mayHaveCrossedWatermark; +let archiveNameForIndex; +let buildArchive; +let archivePathsFor; +let claimDocId; +let claimFilename; +let zenodoProvider; + +beforeAll(async () => { + mock = await createMockZenodo(); + + initializeApp({ projectId: "datapipe-test", storageBucket: "datapipe-test.appspot.com" }, "compaction-test"); + db = getFirestore(initializeApp({ projectId: "datapipe-test" }, "compaction-test-db")); + + // Deferred so the process.env assignments above are in place when app.js and + // zenodo.js first evaluate. + const compaction = await import("../../lib/compaction.js"); + compactExperiment = compaction.compactExperiment; + archiveNameForIndex = compaction.archiveNameForIndex; + buildArchive = compaction.buildArchive; + archivePathsFor = compaction.archivePathsFor; + + KEEP_LOOSE = compaction.KEEP_LOOSE; + ARCHIVED = Math.min(SESSIONS - KEEP_LOOSE, compaction.MAX_BATCH_FILES); + REMAINING = SESSIONS + 2 - ARCHIVED + 1; // sessions + 2 protected - sealed + 1 archive + + const triggers = await import("../../lib/compaction-triggers.js"); + onExperimentGrew = triggers.onExperimentGrew; + onUploadQueueChanged = triggers.onUploadQueueChanged; + mayHaveCrossedWatermark = triggers.mayHaveCrossedWatermark; + + const cache = await import("../../lib/collision-cache.js"); + claimDocId = cache.claimDocId; + claimFilename = cache.claimFilename; + + zenodoProvider = (await import("../../lib/providers/zenodo.js")).zenodoProvider; + + await db.collection("users").doc(OWNER_ID).set({ + connectedAccounts: { + zenodo: { + authMethod: "static-token", + // Plaintext: no "v1:" prefix, so decrypt() passes it through. Same + // convention as gdrive-emulator.test.js. + encryptedToken: "compaction-token", + serverUrl: ZENODO_SERVER_URL, + }, + }, + }); +}); + +afterEach(() => { + mock.reset(); +}); + +afterAll(() => { + mock.server.close(); +}); + +const SALT = "compaction-test-salt"; + +/** + * Stages a Zenodo experiment whose record already holds `sessionCount` + * sessions plus the two protected files, with a warm collision cache that has + * a confirmed claim for every one of them. + * + * Claims are written directly rather than through claimFilename: seeding ~85 + * of them through the real transaction path would dominate this suite's + * runtime, and the claim SHAPE is already covered by collision-cache.test.js. + * The hash is computed with the exported claimDocId, so this cannot drift from + * the implementation. + */ +async function seedExperiment({ sessionCount = SESSIONS, metadataActive = true, extraFiles = {} } = {}) { + const experimentID = `compaction-${randomUUID()}`; + const names = []; + + // extraFiles are seeded FIRST, so they sit at the head of the listing. + // selectBatch archives from the head and holds the tail back for + // spot-checking, so a file seeded last would land in the loose tail and + // never reach the archive a test is trying to inspect. + for (const [key, content] of Object.entries(extraFiles)) { + mock.seed(key, content); + names.push(key); + } + + for (let i = 1; i <= sessionCount; i += 1) { + const key = metadataActive ? `data_raw_subject-${i}.json` : `subject-${i}.json`; + mock.seed(key, JSON.stringify({ subject: i, rt: 400 + i })); + names.push(key); + } + mock.seed("dataset_description.json", JSON.stringify({ name: "study" })); + names.push("dataset_description.json"); + // Only a metadataActive experiment writes this, which matters: it is the + // file compaction stages a saturated archive over, so a plain experiment + // legitimately has no scratch slot. + if (metadataActive) { + mock.seed(PSYCHDS_IGNORE_FILE, PSYCHDS_IGNORE_CONTENT); + names.push(PSYCHDS_IGNORE_FILE); + } + + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + activeBase64: true, + metadataActive, + sessions: sessionCount, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: { + provider: "zenodo", + depositionId: 5551212, + bucketUrl: `http://127.0.0.1:${ZENODO_PORT}/api/files/${BUCKET_ID}`, + serverUrl: ZENODO_SERVER_URL, + }, + collisionCache: { + salt: SALT, + warmUntil: Timestamp.fromMillis(Date.now() + 86400000), + }, + }); + + const claims = db.collection("experiments").doc(experimentID).collection("filenameClaims"); + for (let i = 0; i < names.length; i += 400) { + const batch = db.batch(); + for (const name of names.slice(i, i + 400)) { + batch.set(claims.doc(claimDocId(SALT, name)), { + status: "confirmed", + ownerToken: "seed", + createdAt: Timestamp.now(), + expiresAt: Timestamp.fromMillis(Date.now() + 86400000), + }); + } + await batch.commit(); + } + + return { experimentID, names }; +} + +const PSYCHDS_IGNORE_FILE = ".psychds-ignore"; + +const archiveBytes = () => mock.get(archiveNameForIndex(1)); + +describe("C1. below the watermark", () => { + it("does nothing until the record is close to full", async () => { + // 40 files against Zenodo's 100-file cap: compacting here would cost the + // researcher the ability to preview individual sessions for no benefit. + const { experimentID } = await seedExperiment({ sessionCount: 40 }); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("below-watermark"); + expect(mock.size()).toBe(42); + expect(mock.has(archiveNameForIndex(1))).toBe(false); + }); +}); + +describe("C2. the compaction cycle", () => { + it("seals a batch, verifies it, deletes the originals, and frees room", async () => { + const { experimentID } = await seedExperiment(); + expect(mock.size()).toBe(SESSIONS + 2); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("compacted"); + expect(result.archived).toBe(ARCHIVED); + expect(result.undeleted).toBe(0); + expect(result.archiveName).toBe("datapipe-batch-0001.zip"); + + // sessions + 2 protected - sealed + 1 archive. This is the whole point of + // the feature: the record is no longer anywhere near the cap. + expect(mock.size()).toBe(REMAINING); + expect(result.fileCountAfter).toBe(REMAINING); + }); + + it("leaves the record's Psych-DS descriptor and ignore file loose", async () => { + // Burying dataset_description.json would break the metadataFileRef + // metadata-block.ts updates in place, and hide the first file a visitor to + // the record should see. + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + expect(mock.has("dataset_description.json")).toBe(true); + expect(mock.has(".psychds-ignore")).toBe(true); + }); + + it("leaves recent sessions loose for spot-checking", async () => { + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + const loose = mock.keys().filter((key) => key.startsWith("data_raw_")); + expect(loose).toHaveLength(SESSIONS - ARCHIVED); + expect(mock.has(`data_raw_subject-${SESSIONS}.json`)).toBe(true); + expect(mock.has("data_raw_subject-1.json")).toBe(false); + }); + + it("archives at most one batch per pass, and picks up the rest next time", async () => { + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + // Only the loose tail is left, which is below the watermark, so a second + // pass correctly declines. + const second = await compactExperiment(experimentID); + expect(second.status).toBe("below-watermark"); + expect(mock.has(archiveNameForIndex(2))).toBe(false); + }); +}); + +describe("C3. the archive carries the Psych-DS tree Zenodo cannot store", () => { + it("restores data/raw/ paths that the flat keyspace flattened", async () => { + // Role two of this feature. The record can only ever show + // data_raw_subject-1.json; the archive is where the real layout lives. + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + const entries = readZipEntries(archiveBytes()); + expect(entries.has("data/raw/subject-1.json")).toBe(true); + expect(entries.has("data_raw_subject-1.json")).toBe(false); + expect([...entries.keys()].every((name) => name.startsWith("data/raw/"))).toBe(true); + expect(entries.size).toBe(ARCHIVED); + }); + + it("keeps names flat for an experiment that never wrote a slashed path", async () => { + const { experimentID } = await seedExperiment({ metadataActive: false }); + await compactExperiment(experimentID); + + const entries = readZipEntries(archiveBytes()); + expect(entries.has("subject-1.json")).toBe(true); + expect([...entries.keys()].some((name) => name.includes("/"))).toBe(false); + }); + + it("stores member contents byte-for-byte", async () => { + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + const entries = readZipEntries(archiveBytes()); + expect(entries.get("data/raw/subject-1.json").toString("utf8")).toBe( + JSON.stringify({ subject: 1, rt: 401 }) + ); + }); + + it("round-trips bytes that are not valid UTF-8", async () => { + // The reason downloadFileBytes exists alongside downloadFile. /api/base64 + // submissions are images, audio and video; reading them back through + // response.text() would replace every invalid sequence with U+FFFD and + // silently corrupt the archive that is about to replace the originals. + const media = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00, 0x01, 0x80, 0xc0]); + const { experimentID } = await seedExperiment({ + sessionCount: SESSIONS - 1, + extraFiles: { "data_raw_trial-media.png": media }, + }); + + await compactExperiment(experimentID); + + const entries = readZipEntries(archiveBytes()); + const stored = entries.get("data/raw/trial-media.png"); + expect(stored).toBeDefined(); + expect(stored.equals(media)).toBe(true); + }); +}); + +describe("C4. duplicate detection survives archiving", () => { + it("seals claims so an archived filename can never be resubmitted", async () => { + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + const claimRef = db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(claimDocId(SALT, "data_raw_subject-1.json")); + const claim = (await claimRef.get()).data(); + + expect(claim.sealed).toBe(true); + // The TTL field must be GONE. A sealed claim describes a file that is no + // longer in the provider's listing, so nothing could rehydrate it -- if it + // expired, a filename collected months ago would silently become + // available again. + expect(claim.expiresAt).toBeUndefined(); + }); + + it("still rejects the filename after the cache goes cold and rehydrates", async () => { + // The end-to-end version of the assertion above, and the one that would + // actually catch data loss: force the cache cold so it rehydrates from a + // listing that no longer contains the archived sessions. + const { experimentID } = await seedExperiment(); + await compactExperiment(experimentID); + + await db + .collection("experiments") + .doc(experimentID) + .update({ "collisionCache.warmUntil": Timestamp.fromMillis(Date.now() - 1000) }); + + const expData = (await db.collection("experiments").doc(experimentID).get()).data(); + const listFiles = () => + zenodoProvider.listFiles({ token: "compaction-token" }, expData.providerContainer); + + const archived = await claimFilename(experimentID, "data_raw_subject-1.json", "new-owner", listFiles); + expect(archived).toEqual({ claimed: false, reason: "duplicate" }); + + // ...while a genuinely new filename is still accepted, so this is not just + // a cache that rejects everything. + const fresh = await claimFilename(experimentID, "data_raw_subject-999.json", "new-owner", listFiles); + expect(fresh).toEqual({ claimed: true }); + }); +}); + +describe("C5. verification gates the delete", () => { + it("keeps every original when the provider reports a bad checksum", async () => { + // The single most important assertion in this file. DataPipe keeps no copy + // of submitted data, so an archive that did not land intact must never + // authorize a delete. + const { experimentID } = await seedExperiment(); + mock.setCorruptChecksums(true); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("failed"); + expect(result.detail).toMatch(/checksum mismatch/); + expect(mock.size()).toBe(SESSIONS + 2); + expect(mock.has("data_raw_subject-1.json")).toBe(true); + // The unverified object is cleaned up rather than left looking like a + // sealed batch. + expect(mock.has(archiveNameForIndex(1))).toBe(false); + + const batches = await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .get(); + expect(batches.empty).toBe(true); + }); + + it("does not lose data when deletes fail after a verified upload", async () => { + // The archive is good and the claims are sealed, so the data is safe in + // two places; the originals just could not be removed. They must be + // reported, and must not be re-archived into a second zip next pass. + const { experimentID } = await seedExperiment(); + mock.setFailDeletes(true); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("compacted"); + expect(result.undeleted).toBe(ARCHIVED); + expect(mock.has("data_raw_subject-1.json")).toBe(true); + expect(mock.has(archiveNameForIndex(1))).toBe(true); + + // The next pass still sees the 60 undeleted originals in the listing. The + // failure that would matter is it archiving them a SECOND time, into a zip + // that then authorizes deleting files whose only other copy is in the + // first zip. + mock.setFailDeletes(false); + const second = await compactExperiment(experimentID); + + // Everything archivable is already sealed, so there is nothing to do -- + // emphatically not a second archive holding the same sessions. + expect(second.status).toBe("nothing-to-archive"); + expect(mock.has(archiveNameForIndex(2))).toBe(false); + }); +}); + +describe("C6. resuming an interrupted pass", () => { + it("finishes a batch whose archive landed before the process died", async () => { + const { experimentID } = await seedExperiment(); + + // Reproduce the crash window exactly: the batch record is written and the + // archive uploaded, but nothing has been sealed or deleted. + const members = Array.from({ length: 10 }, (_, i) => `data_raw_subject-${i + 1}.json`); + const paths = archivePathsFor(zenodoProvider, true, members); + const { zip, md5: expectedMd5 } = await buildArchive( + members.map((name) => ({ path: paths.get(name), content: mock.get(name) })) + ); + mock.seed(archiveNameForIndex(1), zip); + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: archiveNameForIndex(1), + status: "uploading", + memberHashes: members.map((name) => claimDocId(SALT, name)), + expectedMd5, + fileCount: members.length, + createdAt: Timestamp.now(), + }); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("compacted"); + expect(result.detail).toMatch(/resumed/); + // The recorded members are sealed and removed, and NO second archive is + // built -- the failure this whole mechanism exists to prevent is the same + // sessions ending up in two zips. + expect(mock.has("data_raw_subject-1.json")).toBe(false); + expect(mock.has(archiveNameForIndex(2))).toBe(false); + + const claim = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(claimDocId(SALT, "data_raw_subject-1.json")) + .get(); + expect(claim.data().sealed).toBe(true); + }); + + it("discards the record and starts over when the archive never landed", async () => { + const { experimentID } = await seedExperiment(); + + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: archiveNameForIndex(1), + status: "uploading", + memberHashes: [claimDocId(SALT, "data_raw_subject-1.json")], + expectedMd5: "never-uploaded", + fileCount: 1, + createdAt: Timestamp.now(), + }); + + const result = await compactExperiment(experimentID); + + // Nothing was deleted before the crash, so the safe move is to throw the + // record away and compact normally -- reusing index 1. + expect(result.status).toBe("compacted"); + expect(result.archived).toBe(ARCHIVED); + expect(result.archiveName).toBe(archiveNameForIndex(1)); + }); + + it("removes an archive whose bytes do not match what was recorded", async () => { + const { experimentID } = await seedExperiment(); + mock.seed(archiveNameForIndex(1), Buffer.from("a truncated upload")); + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: archiveNameForIndex(1), + status: "uploading", + memberHashes: [claimDocId(SALT, "data_raw_subject-1.json")], + expectedMd5: md5(Buffer.from("the complete upload")), + fileCount: 1, + createdAt: Timestamp.now(), + }); + + await compactExperiment(experimentID); + + // The partial object is gone, replaced by a genuine batch-0001. + expect(readZipEntries(archiveBytes()).size).toBe(ARCHIVED); + expect(mock.deleteCount(archiveNameForIndex(1))).toBeGreaterThan(0); + }); +}); + +describe("C7. eligibility", () => { + it("declines an experiment on a provider with no file cap", async () => { + const experimentID = `compaction-nocap-${randomUUID()}`; + await db.collection("experiments").doc(experimentID).set({ + owner: OWNER_ID, + sessions: 500, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId: "folder-1" }, + }); + + const result = await compactExperiment(experimentID); + expect(result.status).toBe("not-eligible"); + expect(result.detail).toMatch(/no file-count cap/); + }); + + it("declines a legacy OSF experiment with no provider container", async () => { + const experimentID = `compaction-legacy-${randomUUID()}`; + await db + .collection("experiments") + .doc(experimentID) + .set({ owner: OWNER_ID, sessions: 500, osfFilesLink: "https://osf.io/x" }); + + const result = await compactExperiment(experimentID); + expect(result.status).toBe("not-eligible"); + }); + + it("declines an experiment that has never had a filename claimed", async () => { + // No salt means nothing has been submitted, so there is nothing to seal -- + // and sealing is what keeps duplicate detection working once files leave + // the listing. + const experimentID = `compaction-nosalt-${randomUUID()}`; + await db + .collection("experiments") + .doc(experimentID) + .set({ + owner: OWNER_ID, + sessions: 90, + storageProvider: "zenodo", + providerContainer: { + provider: "zenodo", + depositionId: 5551212, + bucketUrl: `http://127.0.0.1:${ZENODO_PORT}/api/files/${BUCKET_ID}`, + serverUrl: ZENODO_SERVER_URL, + }, + }); + + const result = await compactExperiment(experimentID); + expect(result.status).toBe("nothing-to-archive"); + }); + + it("refuses to run twice at once", async () => { + const { experimentID } = await seedExperiment(); + await db + .collection("experiments") + .doc(experimentID) + .update({ "compaction.compactingUntil": Timestamp.fromMillis(Date.now() + 600000) }); + + const result = await compactExperiment(experimentID); + expect(result.status).toBe("leased-elsewhere"); + expect(mock.size()).toBe(SESSIONS + 2); + }); +}); + + +describe("C8. saturation recovers without a human", () => { + // A burst can take a record from below the watermark to the cap faster than + // any trigger can react, and a full record has no room for the archive -- + // which is itself a new file. Rather than needing a file deleted by hand, + // compaction stages the archive over one of its own batch members (an + // overwrite, which a full record does accept) and only then frees room. + + async function seedFullRecord() { + const seeded = await seedExperiment(); + for (let i = 0; i < 100 - (SESSIONS + 2); i += 1) { + mock.seed(`filler-${i}.json`, "{}"); + } + expect(mock.size()).toBe(100); + return seeded; + } + + it("compacts a record that is already at the cap", async () => { + const { experimentID } = await seedFullRecord(); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("compacted"); + expect(result.recoveredFromSaturation).toBe(true); + expect(mock.size()).toBeLessThan(100); + // The archive ends up under its real name, not the key it was staged on. + expect(mock.has(archiveNameForIndex(1))).toBe(true); + expect(mock.has("data_raw_subject-1.json")).toBe(false); + }); + + it("borrows .psychds-ignore's slot and puts it back, never a session's", async () => { + // The safety property this rests on. Giving up a session's slot would look + // tempting -- its bytes are inside the archive being written -- but if that + // upload then failed, the session's only copy would be gone from the + // provider and survive solely in the function's memory. .psychds-ignore is + // a fixed constant, so its slot costs nothing and restoring it needs no + // provider round-trip. + const { experimentID } = await seedFullRecord(); + const ignoreBefore = mock.get(".psychds-ignore").toString("utf8"); + const sessionBefore = mock.get("data_raw_subject-1.json").toString("utf8"); + + const result = await compactExperiment(experimentID); + + expect(result.recoveredFromSaturation).toBe(true); + expect(mock.get(".psychds-ignore").toString("utf8")).toBe(ignoreBefore); + const entries = readZipEntries(mock.get(archiveNameForIndex(1))); + expect(entries.get("data/raw/subject-1.json").toString("utf8")).toBe(sessionBefore); + expect(entries.size).toBe(result.archived); + }); + + it("loses nothing, and puts .psychds-ignore back, when the upload cannot be verified", async () => { + const { experimentID } = await seedFullRecord(); + const sessionBefore = mock.get("data_raw_subject-1.json").toString("utf8"); + mock.setCorruptChecksums(true); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("failed"); + // Every session survives. Only .psychds-ignore was at risk, and its + // content is a constant. + expect(mock.get("data_raw_subject-1.json").toString("utf8")).toBe(sessionBefore); + expect(mock.keys().filter((k) => k.startsWith("data_raw_"))).toHaveLength(SESSIONS); + // The borrowed slot is given back even on the failure path. + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(true); + }); + + it("declines rather than guess when there is no reproducible slot to borrow", async () => { + // A plain experiment writes no .psychds-ignore, so there is no file whose + // content DataPipe can reproduce. Giving up a session's slot instead would + // risk exactly the loss this design refuses, so it stops and asks for a + // hand. + const { experimentID } = await seedExperiment({ metadataActive: false }); + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(false); + for (let i = mock.size(); i < 100; i += 1) { + mock.seed(`filler-${i}.json`, "{}"); + } + expect(mock.size()).toBe(100); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("saturated"); + expect(result.detail).toMatch(/remove one file/); + expect(mock.size()).toBe(100); + }); + + it("resumes a pass that died after a borrowed-slot upload landed", async () => { + const { experimentID } = await seedFullRecord(); + const members = Array.from({ length: 10 }, (_, i) => `data_raw_subject-${i + 1}.json`); + const paths = archivePathsFor(zenodoProvider, true, members); + const { zip, md5: expectedMd5 } = await buildArchive( + members.map((name) => ({ path: paths.get(name), content: mock.get(name) })) + ); + // The exact crash state: .psychds-ignore's slot already given up, archive + // uploaded under its own name, nothing sealed or deleted yet. + mock.seed(archiveNameForIndex(1), zip); + mock.remove(PSYCHDS_IGNORE_FILE); + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: archiveNameForIndex(1), + status: "uploading", + memberHashes: members.map((name) => claimDocId(SALT, name)), + expectedMd5, + fileCount: members.length, + createdAt: Timestamp.now(), + }); + + const result = await compactExperiment(experimentID); + + expect(result.status).toBe("compacted"); + expect(result.detail).toMatch(/resumed/); + // Finished properly: archive under its real name, scratch key gone, and + // its contents intact inside the archive. + expect(mock.has(archiveNameForIndex(1))).toBe(true); + expect(mock.has("data_raw_subject-1.json")).toBe(false); + const entries = readZipEntries(mock.get(archiveNameForIndex(1))); + expect(entries.has("data/raw/subject-1.json")).toBe(true); + }); +}); + +describe("C9. event-driven discovery", () => { + // There is no scheduled sweep. Discovery is entirely Firestore triggers, so + // these drive the deployed handlers directly via their v2 `.run()` seam. + + const experimentEvent = (experimentID, before, after) => ({ + params: { experimentID }, + data: { before: { data: () => before }, after: { data: () => after } }, + }); + + it("compacts when a submission pushes an experiment over the watermark", async () => { + const { experimentID } = await seedExperiment(); + const data = (await db.collection("experiments").doc(experimentID).get()).data(); + + await onExperimentGrew.run( + experimentEvent(experimentID, { ...data, sessions: SESSIONS - 1 }, data) + ); + + expect(mock.has(archiveNameForIndex(1))).toBe(true); + }); + + it("ignores an update that did not change sessions", async () => { + // Compaction writes compaction.* on this same document, so without this + // guard a pass would re-trigger itself indefinitely. + const { experimentID } = await seedExperiment(); + const data = (await db.collection("experiments").doc(experimentID).get()).data(); + + await onExperimentGrew.run( + experimentEvent(experimentID, data, { ...data, compaction: { lastFileCount: 12 } }) + ); + + expect(mock.has(archiveNameForIndex(1))).toBe(false); + }); + + it("ignores providers with no file cap", async () => { + const experimentID = `compaction-gdrive-${randomUUID()}`; + await onExperimentGrew.run( + experimentEvent( + experimentID, + { sessions: 400, storageProvider: "gdrive" }, + { sessions: 401, storageProvider: "gdrive" } + ) + ); + expect(mock.has(archiveNameForIndex(1))).toBe(false); + }); + + it("skips the provider listing when the record cannot plausibly be full", () => { + // The burst guard: without it, every submission during a burst would cost + // one listing. It is deliberately pessimistic, erring toward looking early. + const justCompacted = { sessions: 500, compaction: { lastFileCount: 8, sessionsAtLastCheck: 500 } }; + expect(mayHaveCrossedWatermark(justCompacted, 100)).toBe(false); + + const grownSince = { sessions: 510, compaction: { lastFileCount: 8, sessionsAtLastCheck: 500 } }; + expect(mayHaveCrossedWatermark(grownSince, 100)).toBe(true); + + // Never examined: look rather than infer health from an absent record. + expect(mayHaveCrossedWatermark({ sessions: 1 }, 100)).toBe(true); + }); + + it("compacts and releases the queue when a submission is refused for lack of room", async () => { + // The reactive path, and the one that makes a researcher's hand-uploaded + // files eventually visible despite producing no event of their own. + const { experimentID } = await seedExperiment(); + const queueDocId = `${experimentID}:blocked.json`.replace(/[/\\]/g, "_"); + const queued = { + experimentID, + owner: OWNER_ID, + filename: "blocked.json", + status: "pending", + storageProvider: "zenodo", + providerErrorCode: "QUOTA_EXCEEDED", + nextRetryAt: Timestamp.fromMillis(Date.now() + 3600000), + createdAt: Timestamp.now(), + }; + await db.collection("uploadQueue").doc(queueDocId).set(queued); + + await onUploadQueueChanged.run({ + params: { docId: queueDocId }, + data: { before: { data: () => undefined }, after: { data: () => queued } }, + }); + + expect(mock.has(archiveNameForIndex(1))).toBe(true); + // Released to the next retry pass rather than left on slow-tier backoff + // waiting out a condition compaction has just fixed. + const after = (await db.collection("uploadQueue").doc(queueDocId).get()).data(); + expect(after.nextRetryAt.toMillis()).toBeLessThanOrEqual(Date.now()); + + await db.collection("uploadQueue").doc(queueDocId).delete(); + }); + + it("compacts when the retry worker lands a file without sessions moving", async () => { + // A draining backlog adds files but never increments `sessions` -- those + // submissions incremented it when they first arrived and failed -- so it is + // invisible to the experiment trigger. + const { experimentID } = await seedExperiment(); + const queueDocId = `${experimentID}:drained.json`.replace(/[/\\]/g, "_"); + + await onUploadQueueChanged.run({ + params: { docId: queueDocId }, + data: { + before: { data: () => ({ experimentID, status: "processing" }) }, + after: { data: () => ({ experimentID, status: "completed" }) }, + }, + }); + + expect(mock.has(archiveNameForIndex(1))).toBe(true); + }); + + it("ignores queue writes that mean nothing for capacity", async () => { + const { experimentID } = await seedExperiment(); + const queueDocId = `${experimentID}:transient.json`.replace(/[/\\]/g, "_"); + + await onUploadQueueChanged.run({ + params: { docId: queueDocId }, + data: { + before: { data: () => undefined }, + after: { data: () => ({ experimentID, status: "pending", providerErrorCode: "UNAVAILABLE" }) }, + }, + }); + + expect(mock.has(archiveNameForIndex(1))).toBe(false); + }); +}); + +describe("C10. the write gate", () => { + // The coordination that makes saturation rare rather than merely + // recoverable: DataPipe is the only writer, so while a pass holds the lease, + // submissions are diverted to the durable queue instead of the provider. + // The file count therefore cannot grow during a pass, which is what + // guarantees room for the archive it is about to upload. + + let isCompactionInFlight; + let COMPACTION_HOLD_REASON; + + beforeAll(async () => { + const gate = await import("../../lib/compaction-gate.js"); + isCompactionInFlight = gate.isCompactionInFlight; + COMPACTION_HOLD_REASON = gate.COMPACTION_HOLD_REASON; + }); + + it("is closed only while a pass actually holds the lease", () => { + expect(isCompactionInFlight({})).toBe(false); + expect( + isCompactionInFlight({ compaction: { compactingUntil: Timestamp.fromMillis(Date.now() + 60000) } }) + ).toBe(true); + // A crashed pass leaves a stale lease, which must expire rather than wedge + // the experiment into queueing every submission forever. + expect( + isCompactionInFlight({ compaction: { compactingUntil: Timestamp.fromMillis(Date.now() - 1000) } }) + ).toBe(false); + }); + + it("diverts a submission to the queue instead of the provider", async () => { + // metadataActive off: the gate sits after the metadata block in + // api-data.ts, and this test is about the gate, not that machinery. + const { experimentID } = await seedExperiment({ sessionCount: 10, metadataActive: false }); + await db + .collection("experiments") + .doc(experimentID) + .update({ "compaction.compactingUntil": Timestamp.fromMillis(Date.now() + 600000) }); + + const response = await fetch( + `http://localhost:5001/datapipe-test/us-central1/apidata`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + experimentID, + filename: "gated-session.json", + data: JSON.stringify([{ trial: 1 }]), + }), + } + ); + + // 202, not an error: the participant is unaffected and the payload is + // durable in Cloud Storage before this returns. + expect(response.status).toBe(202); + + // failureReason is the assertion that actually discriminates. A 202 alone + // would not: had the gate NOT fired, the function would have attempted a + // provider write, failed to reach one, and queued the submission anyway -- + // with "Upload exception: ..." recorded instead. + const queueDocId = `${experimentID}:gated-session.json`.replace(/[/\\]/g, "_"); + const queued = (await db.collection("uploadQueue").doc(queueDocId).get()).data(); + expect(queued.failureReason).toBe(COMPACTION_HOLD_REASON); + // Fast tier, so it drains within a minute even if the explicit release + // below is somehow missed. + expect(queued.providerErrorCode).toBe("CONTENTION"); + + await db.collection("uploadQueue").doc(queueDocId).delete(); + }); + + it("releases what it held as soon as the pass ends", async () => { + const { experimentID } = await seedExperiment(); + const queueDocId = `${experimentID}:held.json`.replace(/[/\\]/g, "_"); + await db + .collection("uploadQueue") + .doc(queueDocId) + .set({ + experimentID, + owner: OWNER_ID, + filename: "held.json", + status: "pending", + storageProvider: "zenodo", + providerErrorCode: "CONTENTION", + failureReason: COMPACTION_HOLD_REASON, + nextRetryAt: Timestamp.fromMillis(Date.now() + 60000), + createdAt: Timestamp.now(), + }); + + await compactExperiment(experimentID); + + const after = (await db.collection("uploadQueue").doc(queueDocId).get()).data(); + expect(after.nextRetryAt.toMillis()).toBeLessThanOrEqual(Date.now()); + + await db.collection("uploadQueue").doc(queueDocId).delete(); + }); +}); diff --git a/functions/src/__tests__/compaction-streaming-emulator.test.js b/functions/src/__tests__/compaction-streaming-emulator.test.js new file mode 100644 index 0000000..b389e0d --- /dev/null +++ b/functions/src/__tests__/compaction-streaming-emulator.test.js @@ -0,0 +1,174 @@ +/** + * @jest-environment node + */ + +// Emulator coverage for compaction.ts's streaming archive builder +// (buildArchiveToStorage), added for finalization (docs/finalization-spec.md). +// +// WHY THIS NEEDS THE EMULATOR, unlike compaction.test.js's coverage of +// buildArchive: buildArchive assembles the whole zip in memory and hands back +// a Buffer, so it is pure and needs nothing beyond archiver itself. +// buildArchiveToStorage instead pipes archiver straight into +// storage.bucket().file(storagePath).createWriteStream() -- that is the +// entire point, since holding a whole study's archive in memory is the wall +// this exists to remove -- so exercising it for real means writing through +// the Storage emulator, not a fake. +// +// Bootstrap mirrors compaction-emulator.test.js: process.env is set before a +// deferred dynamic import so app.js/compaction.js pick up the emulator hosts +// when they first evaluate, and node-fetch is stubbed even though this file +// never calls it, because compaction.ts pulls in the whole provider registry +// (every adapter imports node-fetch at module scope) and it is ESM-only, so +// Jest's CJS transform cannot parse it unmocked. + +const mockFetch = jest.fn(); +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => mockFetch(...args), +})); + +import { randomUUID, createHash } from "crypto"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +jest.setTimeout(60000); + +const md5 = (buffer) => createHash("md5").update(buffer).digest("hex"); + +let storage; +let buildArchive; +let buildArchiveToStorage; + +beforeAll(async () => { + // Deferred so the process.env assignments above are in place when app.js + // first evaluates (same reasoning as compaction-emulator.test.js). + const app = await import("../../lib/app.js"); + storage = app.storage; + + const compaction = await import("../../lib/compaction.js"); + buildArchive = compaction.buildArchive; + buildArchiveToStorage = compaction.buildArchiveToStorage; +}); + +function scratchPath(name) { + return `finalization-test/${randomUUID()}-${name}`; +} + +async function readBack(storagePath) { + const [contents] = await storage.bucket().file(storagePath).download(); + return contents; +} + +async function* toAsyncIterable(items) { + for (const item of items) { + yield item; + } +} + +const SAMPLE_ENTRIES = [ + { path: "dataset_description.json", content: Buffer.from(JSON.stringify({ name: "study" })) }, + { path: "data/raw/subject-1.json", content: Buffer.from(JSON.stringify({ subject: 1, rt: 400 })) }, + // A larger, non-UTF-8-safe entry, so the comparison isn't only exercising + // tiny JSON payloads. + { path: "data/raw/subject-2.json", content: Buffer.concat([Buffer.from("x".repeat(4096)), Buffer.from([0x00, 0xff, 0xfe])]) }, +]; + +describe("buildArchiveToStorage", () => { + it("is byte-identical to buildArchive for the same input, including the pinned entry dates", async () => { + const inMemory = await buildArchive(SAMPLE_ENTRIES); + + const storagePath = scratchPath("identical.zip"); + const streamed = await buildArchiveToStorage(toAsyncIterable(SAMPLE_ENTRIES), storagePath); + + const uploaded = await readBack(storagePath); + + // The whole point of the pinned `date: new Date(0)` on every entry: two + // builders driven by the same archiver library should be able to produce + // byte-for-byte identical zips for the same input, not just + // content-equivalent ones. + expect(uploaded.equals(inMemory.zip)).toBe(true); + expect(streamed.size).toBe(inMemory.zip.length); + expect(streamed.md5).toBe(inMemory.md5); + }); + + it("computes the md5 of the emitted bytes in-flight, matching the uploaded object", async () => { + const storagePath = scratchPath("md5.zip"); + const result = await buildArchiveToStorage(toAsyncIterable(SAMPLE_ENTRIES), storagePath); + const uploaded = await readBack(storagePath); + + expect(result.md5).toBe(md5(uploaded)); + expect(result.size).toBe(uploaded.length); + }); + + // The whole reason this function exists: entries.downloadFileBytes is + // called just-in-time by the caller's generator, so at most one member's + // bytes should ever be resident here. A generator that gates production of + // its NEXT item on an external release proves the consumer requests items + // one at a time rather than draining the iterable up front (e.g. into an + // array) before archiving/uploading anything. + it("consumes entries lazily, one at a time, rather than draining the iterable up front", async () => { + const TOTAL = 6; + let yielded = 0; + let release; + const nextGate = () => new Promise((resolve) => { release = resolve; }); + + async function* controlledEntries() { + for (let i = 1; i <= TOTAL; i += 1) { + yielded += 1; + yield { path: `item-${i}.txt`, content: Buffer.from(`content-${i}`) }; + // Blocks producing the NEXT item until the test releases it -- if + // buildArchiveToStorage ever collected the whole iterable before + // doing any work, this generator would never advance past item 1 and + // the assertions below would time out rather than pass. + // eslint-disable-next-line no-await-in-loop + await nextGate(); + } + } + + const storagePath = scratchPath("lazy.zip"); + const resultPromise = buildArchiveToStorage(controlledEntries(), storagePath); + + await new Promise((resolve) => setImmediate(resolve)); + expect(yielded).toBe(1); + + for (let i = 1; i < TOTAL; i += 1) { + const currentRelease = release; + currentRelease(); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setImmediate(resolve)); + expect(yielded).toBe(i + 1); + } + release(); + + const result = await resultPromise; + expect(result.size).toBeGreaterThan(0); + }); + + // A batch download failing mid-stream (the real-world trigger: a provider + // read errors out while finalization is re-emitting a batch's members) must + // reject the promise, not leave it pending forever. + it("propagates an error thrown by the entries iterable as a rejection", async () => { + async function* failingEntries() { + yield { path: "a.txt", content: Buffer.from("a") }; + throw new Error("download exploded mid-stream"); + } + + await expect(buildArchiveToStorage(failingEntries(), scratchPath("failure.zip"))).rejects.toThrow( + /download exploded mid-stream/ + ); + }); + + it("handles an empty iterable without hanging", async () => { + const storagePath = scratchPath("empty.zip"); + const result = await buildArchiveToStorage(toAsyncIterable([]), storagePath); + const uploaded = await readBack(storagePath); + expect(result.size).toBe(uploaded.length); + expect(result.md5).toBe(md5(uploaded)); + }); +}); diff --git a/functions/src/__tests__/compaction.test.js b/functions/src/__tests__/compaction.test.js new file mode 100644 index 0000000..929c5f1 --- /dev/null +++ b/functions/src/__tests__/compaction.test.js @@ -0,0 +1,289 @@ +/** + * @jest-environment node + */ + +// Unit coverage for compaction.ts's pure helpers -- batch selection, Psych-DS +// path reconstruction, archive building and checksum comparison. +// +// The end-to-end cycle (upload, verify, seal, delete, resume) lives in +// compaction-emulator.test.js, which also parses the produced zip to prove +// byte fidelity. What is here is the logic that decides WHICH files get +// archived and WHERE they land inside it -- the two things that, if wrong, +// would either lose data or produce an archive that is not a valid Psych-DS +// tree. + +import { createHash } from "crypto"; + +// compaction.js imports app.js (initializeApp with no args) transitively, so +// these have to be set before the dynamic import below -- same bootstrap as +// upload-queue.test.js. +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); + +// compaction.js pulls in providers/index.js and therefore every adapter, each +// of which imports the ESM-only node-fetch at module scope. Nothing in this +// suite makes an HTTP call. +jest.mock("node-fetch", () => ({ __esModule: true, default: jest.fn() })); + +let selectBatch; +let archivePathsFor; +let buildArchive; +let checksumMatches; +let archiveNameForIndex; +let isArchiveName; +let zenodoProvider; +let fromZenodoKey; + +beforeAll(async () => { + const compaction = await import("../../lib/compaction.js"); + selectBatch = compaction.selectBatch; + archivePathsFor = compaction.archivePathsFor; + buildArchive = compaction.buildArchive; + checksumMatches = compaction.checksumMatches; + archiveNameForIndex = compaction.archiveNameForIndex; + isArchiveName = compaction.isArchiveName; + + const zenodo = await import("../../lib/providers/zenodo.js"); + zenodoProvider = zenodo.zenodoProvider; + fromZenodoKey = zenodo.fromZenodoKey; +}); + +const file = (name, size) => ({ name, id: name, size }); + +describe("1. archive naming", () => { + test("indexes are zero-padded and round-trip through isArchiveName", () => { + expect(archiveNameForIndex(1)).toBe("datapipe-batch-0001.zip"); + expect(archiveNameForIndex(42)).toBe("datapipe-batch-0042.zip"); + expect(isArchiveName(archiveNameForIndex(1))).toBe(true); + }); + + test("researcher files are never mistaken for archives", () => { + // The consequence of a false positive here is silent data loss: an archive + // is excluded from batch selection, so a session misread as one would + // never be compacted -- and, worse, a session named to LOOK like an + // archive would be skipped forever while counting against the cap. + expect(isArchiveName("datapipe-batch-1.zip")).toBe(false); + expect(isArchiveName("datapipe-batch-0001.zip.json")).toBe(false); + expect(isArchiveName("my-datapipe-batch-0001.zip")).toBe(false); + expect(isArchiveName("subject-1.json")).toBe(false); + }); +}); + +describe("2. Psych-DS path reconstruction (Zenodo's flat keyspace)", () => { + test("the four shapes DataPipe writes are restored exactly", () => { + expect(fromZenodoKey("data_raw_subject-1.json")).toBe("data/raw/subject-1.json"); + expect(fromZenodoKey("data_subject-1_data.csv")).toBe("data/subject-1_data.csv"); + expect(fromZenodoKey("dataset_description.json")).toBe("dataset_description.json"); + expect(fromZenodoKey(".psychds-ignore")).toBe(".psychds-ignore"); + }); + + test("dataset_description.json is not dragged into a data/ folder", () => { + // The near-miss that makes this worth an explicit test: the key begins + // with "data", and only the absence of an underscore at index 4 keeps it + // out of the `data_` branch. Burying the record's own Psych-DS descriptor + // inside data/ would invalidate the tree. + expect(fromZenodoKey("dataset_description.json").startsWith("data/")).toBe(false); + }); + + test("underscores inside a leaf survive, because only the prefix is consumed", () => { + expect(fromZenodoKey("data_raw_my_subject_01.json")).toBe("data/raw/my_subject_01.json"); + expect(fromZenodoKey("data_condition-A-run_2_data.csv")).toBe("data/condition-A-run_2_data.csv"); + }); + + test("the round trip from a real Psych-DS path is lossless", () => { + // toZenodoKey is what the write path applies; archivePathFor has to undo + // exactly it, for exactly the paths metadata-derived-files.ts produces. + for (const path of ["data/raw/subject-1.json", "data/subject-1_data.csv", "data/s_measure-rt_data.csv"]) { + const stored = zenodoProvider.storedNameFor(path); + expect(stored).not.toContain("/"); + expect(zenodoProvider.archivePathFor(stored)).toBe(path); + } + }); +}); + +describe("3. archivePathsFor", () => { + const names = ["data_raw_subject-1.json", "data_subject-1_data.csv"]; + + test("reconstructs paths for a metadataActive experiment", () => { + const paths = archivePathsFor(zenodoProvider, true, names); + expect(paths.get("data_raw_subject-1.json")).toBe("data/raw/subject-1.json"); + expect(paths.get("data_subject-1_data.csv")).toBe("data/subject-1_data.csv"); + }); + + test("leaves names alone when metadata is off", () => { + // The gate that makes the reconstruction safe. A plain experiment writes + // no slashed path at all, so a researcher's own `data_notes.json` must not + // be "restored" into a data/ folder that never existed. + const paths = archivePathsFor(zenodoProvider, false, ["data_notes.json", ...names]); + expect(paths.get("data_notes.json")).toBe("data_notes.json"); + expect(paths.get("data_raw_subject-1.json")).toBe("data_raw_subject-1.json"); + }); + + test("providers with real folders are identity even when metadata is on", () => { + const noReverse = { ...zenodoProvider, archivePathFor: undefined }; + const paths = archivePathsFor(noReverse, true, names); + expect(paths.get("data_raw_subject-1.json")).toBe("data_raw_subject-1.json"); + }); + + test("a path collision falls back to the flat name rather than duplicating", () => { + // Cannot happen with the shipped Zenodo mapping, but two zip members at + // one path is silent corruption, so the guard is asserted rather than + // assumed for future adapters. + const collidingProvider = { ...zenodoProvider, archivePathFor: () => "data/same.json" }; + const paths = archivePathsFor(collidingProvider, true, ["a.json", "b.json"]); + expect(paths.get("a.json")).toBe("data/same.json"); + expect(paths.get("b.json")).toBe("b.json"); + expect(new Set([...paths.values()]).size).toBe(2); + }); +}); + +describe("4. selectBatch", () => { + const sessions = (n, size = 1000) => + Array.from({ length: n }, (_, i) => file(`data_raw_subject-${i + 1}.json`, size)); + + test("never archives the record's own descriptor or the ignore file", () => { + // dataset_description.json is what metadata-block.ts holds a ref to and + // rewrites per submission; .psychds-ignore is rewritten per submission + // too. Archiving either would break a ref or immediately churn. + const batch = selectBatch( + [...sessions(30), file("dataset_description.json", 100), file(".psychds-ignore", 20)], + { keepLoose: 0 } + ); + const names = batch.map((f) => f.name); + expect(names).not.toContain("dataset_description.json"); + expect(names).not.toContain(".psychds-ignore"); + expect(batch).toHaveLength(30); + }); + + test("previously sealed archives are never re-archived", () => { + const batch = selectBatch([file("datapipe-batch-0001.zip", 5000), ...sessions(10)], { keepLoose: 0 }); + expect(batch.map((f) => f.name)).not.toContain("datapipe-batch-0001.zip"); + expect(batch).toHaveLength(10); + }); + + test("files whose claims are already sealed are skipped", () => { + // The resume case: a previous pass archived these and died before + // deleting them. Re-archiving would put the same session in two zips. + const alreadySealed = new Set(["data_raw_subject-1.json", "data_raw_subject-2.json"]); + const batch = selectBatch(sessions(10), { keepLoose: 0, alreadySealed }); + expect(batch).toHaveLength(8); + expect(batch.map((f) => f.name)).not.toContain("data_raw_subject-1.json"); + }); + + test("the tail of the listing stays loose for spot-checking", () => { + const batch = selectBatch(sessions(50), { keepLoose: 20 }); + expect(batch).toHaveLength(30); + expect(batch.map((f) => f.name)).not.toContain("data_raw_subject-50.json"); + expect(batch.map((f) => f.name)).toContain("data_raw_subject-1.json"); + }); + + test("keepLoose counts real sessions, not archives or protected files", () => { + // Counting a zip toward keepLoose would leave fewer sessions loose than + // promised, and eventually make a full record un-compactable. + const files = [ + file("datapipe-batch-0001.zip", 5000), + file("dataset_description.json", 100), + ...sessions(25), + ]; + const batch = selectBatch(files, { keepLoose: 20 }); + expect(batch).toHaveLength(5); + }); + + test("nothing is archived when there is nothing beyond the loose tail", () => { + expect(selectBatch(sessions(15), { keepLoose: 20 })).toHaveLength(0); + }); + + test("the file count bound applies", () => { + expect(selectBatch(sessions(200), { keepLoose: 0, maxFiles: 60 })).toHaveLength(60); + }); + + test("the byte budget stops a batch early", () => { + // The memory guard: a base64 experiment collecting video has files orders + // of magnitude larger than a JSON session, and the whole batch is held in + // memory at once. + const batch = selectBatch(sessions(20, 10 * 1024 * 1024), { + keepLoose: 0, + maxBytes: 45 * 1024 * 1024, + }); + expect(batch).toHaveLength(4); + }); + + test("a single oversized file is still archived rather than wedging the study", () => { + // If the first file alone blows the budget, skipping it would mean this + // experiment could never compact and would stay stuck at the cap forever. + const batch = selectBatch([file("huge.dat", 900 * 1024 * 1024), ...sessions(3)], { + keepLoose: 0, + maxBytes: 45 * 1024 * 1024, + }); + expect(batch).toHaveLength(1); + expect(batch[0].name).toBe("huge.dat"); + }); + + test("an unreported size does not block a batch", () => { + const batch = selectBatch([file("a.json"), file("b.json")], { keepLoose: 0 }); + expect(batch).toHaveLength(2); + }); +}); + +describe("5. checksumMatches", () => { + const md5 = createHash("md5").update("hello").digest("hex"); + + test("accepts the provider's decorated form", () => { + expect(checksumMatches(`md5:${md5}`, md5)).toBe(true); + expect(checksumMatches(md5, md5)).toBe(true); + expect(checksumMatches(`MD5:${md5.toUpperCase()}`, md5)).toBe(true); + }); + + test("a missing checksum is a mismatch, never a pass", () => { + // This is the gate that decides whether the originals get deleted. An + // unverifiable upload has to read as unverified, or a provider that + // silently stopped reporting checksums would start authorizing deletes. + expect(checksumMatches(undefined, md5)).toBe(false); + expect(checksumMatches("", md5)).toBe(false); + }); + + test("a wrong checksum is rejected", () => { + expect(checksumMatches("md5:0000", md5)).toBe(false); + }); +}); + +describe("6. buildArchive", () => { + test("is deterministic, so a rebuilt archive has the same md5", () => { + // Timestamps are pinned in the zip entries for this reason: a resumed pass + // compares against a recorded md5, and a clock-dependent archive would + // never match itself. + return Promise.all([ + buildArchive([{ path: "data/raw/a.json", content: Buffer.from('{"x":1}') }]), + buildArchive([{ path: "data/raw/a.json", content: Buffer.from('{"x":1}') }]), + ]).then(([first, second]) => { + expect(first.md5).toBe(second.md5); + expect(first.zip.equals(second.zip)).toBe(true); + }); + }); + + test("the reported md5 is of the archive bytes themselves", async () => { + const { zip, md5 } = await buildArchive([{ path: "a.json", content: Buffer.from("hi") }]); + expect(md5).toBe(createHash("md5").update(zip).digest("hex")); + }); + + test("member paths are written into the archive", async () => { + // A light structural check; compaction-emulator.test.js parses the zip + // properly and asserts byte-for-byte member fidelity. + const { zip } = await buildArchive([ + { path: "data/raw/subject-1.json", content: Buffer.from("{}") }, + { path: "data/subject-1_data.csv", content: Buffer.from("a,b\n1,2\n") }, + ]); + expect(zip.includes(Buffer.from("data/raw/subject-1.json"))).toBe(true); + expect(zip.includes(Buffer.from("data/subject-1_data.csv"))).toBe(true); + }); + + test("an empty archive is still a valid zip", async () => { + const { zip } = await buildArchive([]); + expect(zip.length).toBeGreaterThan(0); + expect(zip.subarray(zip.length - 22, zip.length - 18)).toEqual(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + }); +}); diff --git a/functions/src/__tests__/dataverse-emulator.test.js b/functions/src/__tests__/dataverse-emulator.test.js new file mode 100644 index 0000000..0b86054 --- /dev/null +++ b/functions/src/__tests__/dataverse-emulator.test.js @@ -0,0 +1,621 @@ +/** + * @jest-environment node + */ + +// End-to-end coverage for the Dataverse adapter, driving the REAL deployed +// apidata/apibase64 functions inside the Functions emulator against a +// self-contained mock Dataverse installation -- the house pattern established +// by gdrive-emulator.test.js. +// +// HOW THE MOCK IS REACHED, AND WHY NO ENV WIRING IS NEEDED. Dataverse is +// federated, so serverUrl is per-connection data rather than a provider +// constant: dataverse.ts's resolveServerUrl reads it off the container first +// and the connection second, and (unlike connect-provider.ts, which gates +// researcher-supplied URLs through isAllowedServerUrl at CONNECT time) it +// does not re-validate. Seeding http://127.0.0.1:3582 straight into the +// experiment's providerContainer is therefore all it takes to point the +// adapter at this file's mock. Zenodo needed an env-gated override for +// exactly the reason Dataverse does not -- see zenodo-emulator.test.js. +// +// TOKENS ARE PLAINTEXT (no "v1:" prefix -> crypto-utils.ts's decrypt() passes +// them through), so this jest process and the separate Functions-emulator +// process need not agree on TOKEN_ENCRYPTION_KEY. Same as gdrive's suite. +// +// The mock reproduces the contract corrections the live spike found rather +// than the published guides: /add returns 200 (not the documented 201), and +// duplicate filenames are SILENTLY RENAMED rather than rejected. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test", storageBucket: "datapipe-test.appspot.com" }; +const DATAVERSE_OWNER_ID = "dataverse-emulator-owner"; +const DATAVERSE_PORT = 3582; +const SERVER_URL = `http://127.0.0.1:${DATAVERSE_PORT}`; +const DATASET_ID = 42; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +// Dataverse's real contention rejection, live-verified against +// demo.dataverse.org (2026-07-26): a generic 400, NOT the 403 dataset-lock +// the design originally anticipated. Reproduced verbatim because the +// adapter's CONTENTION mapping keys off this prose. +const CONTENTION_MESSAGE = "Failed to add file to dataset."; +// The other 400 that contains the same phrase but is NOT transient. The +// adapter excludes it from CONTENTION deliberately; retrying it fast would +// spin uselessly. +const SAME_CONTENT_MESSAGE = + "This file has the same content as prior.json that is in the dataset. \nFailed to add file to dataset."; + +async function postTo(fn, body) { + const response = await fetch(`http://localhost:5001/datapipe-test/us-central1/${fn}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + const text = await response.text(); + let message; + try { + message = JSON.parse(text); + } catch { + message = { rawBody: text }; + } + return { status: response.status, body: message }; +} + +const saveData = (body) => postTo("apidata", body); +const saveBase64 = (body) => postTo("apibase64", body); + +// Buffer-based multipart parser. Deliberately NOT the split-on-string +// approach gdrive's suite uses: the base64 case below uploads bytes that are +// invalid UTF-8, and round-tripping those through a string would corrupt +// them silently -- making a genuine transport bug look like a passing test. +function parseMultipart(buf, contentTypeHeader) { + const match = /boundary=("?)([^;"]+)\1/.exec(contentTypeHeader || ""); + if (!match) return []; + const boundary = Buffer.from(`--${match[2]}`); + const parts = []; + + let idx = buf.indexOf(boundary); + while (idx !== -1) { + const start = idx + boundary.length; + const next = buf.indexOf(boundary, start); + if (next === -1) break; + + let segment = buf.subarray(start, next); + if (segment[0] === 0x0d && segment[1] === 0x0a) segment = segment.subarray(2); + if ( + segment.length >= 2 && + segment[segment.length - 2] === 0x0d && + segment[segment.length - 1] === 0x0a + ) { + segment = segment.subarray(0, segment.length - 2); + } + + const sep = segment.indexOf("\r\n\r\n"); + if (sep !== -1) { + parts.push({ + headers: segment.subarray(0, sep).toString("utf8"), + content: segment.subarray(sep + 4), + }); + } + idx = next; + } + return parts; +} + +function createMockDataverseServer() { + const app = express(); + app.use(express.raw({ type: () => true, limit: "20mb" })); + + // fileId -> {label, directoryLabel, content, contentType, tabIngest} + const filesById = new Map(); + const addCountsByName = new Map(); + const deleteCountsById = new Map(); + const forcedStatus = new Map(); + let nextFileId = 1000; + + const fullName = (f) => (f.directoryLabel ? `${f.directoryLabel}/${f.label}` : f.label); + + app.get("/api/users/:me", (req, res) => { + res.status(200).json({ status: "OK", data: { id: 1, displayName: "Mock Researcher" } }); + }); + + app.get("/api/info/version", (req, res) => { + // 6.11, comfortably past the 5.11 tabIngest floor, so setupWarnings + // produces no version warning. + res.status(200).json({ status: "OK", data: { version: "6.11" } }); + }); + + app.post("/api/dataverses/:alias/datasets", (req, res) => { + // 201, which is what demo.dataverse.org actually returns -- the guides + // say 200, and hardcoding that made every real creation throw. + res.status(201).json({ + status: "OK", + data: { id: DATASET_ID, persistentId: "doi:10.5072/FK2/MOCKED" }, + }); + }); + + app.post("/api/datasets/:id/add", (req, res) => { + const parts = parseMultipart(req.body, req.headers["content-type"]); + const filePart = parts.find((p) => /name="file"/.test(p.headers)); + const jsonPart = parts.find((p) => /name="jsonData"/.test(p.headers)); + + if (!filePart) { + res.status(400).json({ status: "ERROR", message: "No file part in request." }); + return; + } + + const filenameMatch = /filename="([^"]*)"/.exec(filePart.headers); + const requestedLabel = filenameMatch ? filenameMatch[1] : "unnamed"; + const contentTypeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(filePart.headers); + let jsonData = {}; + try { + jsonData = JSON.parse(jsonPart.content.toString("utf8")); + } catch { + jsonData = {}; + } + const directoryLabel = jsonData.directoryLabel; + + addCountsByName.set(requestedLabel, (addCountsByName.get(requestedLabel) || 0) + 1); + + const forced = forcedStatus.get(requestedLabel); + if (forced) { + res.status(forced.status).json({ status: "ERROR", message: forced.message }); + return; + } + + // SILENT RENAME. IQSS's own DuplicateFilesIT asserts a second README.md + // comes back as README-1.md; Dataverse never returns a name conflict. + // This is the behavior WriteResult.storedFilename exists to detect. + let label = requestedLabel; + const collides = (candidate) => + Array.from(filesById.values()).some( + (f) => f.label === candidate && f.directoryLabel === directoryLabel + ); + if (collides(label)) { + const dot = requestedLabel.lastIndexOf("."); + const base = dot === -1 ? requestedLabel : requestedLabel.slice(0, dot); + const ext = dot === -1 ? "" : requestedLabel.slice(dot); + let n = 1; + while (collides(`${base}-${n}${ext}`)) n++; + label = `${base}-${n}${ext}`; + } + + const id = nextFileId++; + filesById.set(id, { + id, + label, + directoryLabel, + content: Buffer.from(filePart.content), + contentType: contentTypeMatch ? contentTypeMatch[1].trim() : null, + tabIngest: jsonData.tabIngest, + }); + + // 200, not the documented 201 -- the Java source returns ok() and the + // IQSS integration suite asserts 200. + res.status(200).json({ + status: "OK", + data: { + files: [ + { + label, + ...(directoryLabel ? { directoryLabel } : {}), + dataFile: { id, filename: label, contentType: contentTypeMatch?.[1]?.trim() }, + }, + ], + }, + }); + }); + + app.delete("/api/files/:id", (req, res) => { + const id = parseInt(req.params.id, 10); + deleteCountsById.set(id, (deleteCountsById.get(id) || 0) + 1); + const forced = forcedStatus.get(`delete:${id}`); + if (forced) { + res.status(forced.status).json({ status: "ERROR", message: forced.message }); + return; + } + // DELETE physically removes the file while the dataset is unpublished, + // which is the case DataPipe always operates in (datasets stay in draft). + filesById.delete(id); + res.status(200).json({ status: "OK" }); + }); + + // The literal ":draft" in the adapter's URL is matched here as a route + // parameter value -- express reads ":version" from the pattern, and the + // incoming path segment is the literal text ":draft". + app.get("/api/datasets/:id/versions/:version/files", (req, res) => { + const all = Array.from(filesById.values()); + const limit = parseInt(req.query.limit, 10) || all.length || 1; + const offset = parseInt(req.query.offset, 10) || 0; + const page = all.slice(offset, offset + limit); + res.status(200).json({ + status: "OK", + totalCount: all.length, + data: page.map((f) => ({ + label: f.label, + ...(f.directoryLabel ? { directoryLabel: f.directoryLabel } : {}), + dataFile: { id: f.id, filename: f.label }, + })), + }); + }); + + return new Promise((resolve, reject) => { + const tryListen = (retriesLeft) => { + const server = app.listen(DATAVERSE_PORT); + server.once("listening", () => { + resolve({ + server, + getAddCount: (label) => addCountsByName.get(label) || 0, + getDeleteCount: (id) => deleteCountsById.get(Number(id)) || 0, + getFile: (name) => Array.from(filesById.values()).find((f) => fullName(f) === name) || null, + getStoredNames: () => Array.from(filesById.values()).map(fullName), + seedFile: (label, directoryLabel, content = "seeded") => { + const id = nextFileId++; + filesById.set(id, { + id, + label, + directoryLabel, + content: Buffer.from(content), + contentType: "application/json", + tabIngest: "false", + }); + return id; + }, + forceStatus: (key, status, message) => forcedStatus.set(key, { status, message }), + reset: () => { + filesById.clear(); + addCountsByName.clear(); + deleteCountsById.clear(); + forcedStatus.clear(); + nextFileId = 1000; + }, + }); + }); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); + }); +} + +let db; +let mockDataverse; + +beforeAll(async () => { + mockDataverse = await createMockDataverseServer(); + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(DATAVERSE_OWNER_ID).set({ + connectedAccounts: { + dataverse: { + authMethod: "static-token", + encryptedToken: "dataverse-integration-token", // plaintext fallback + serverUrl: SERVER_URL, + }, + }, + }); +}); + +afterEach(() => { + mockDataverse.reset(); +}); + +afterAll(() => { + mockDataverse.server.close(); +}); + +async function createDataverseExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + activeBase64: true, + metadataActive: false, + owner: DATAVERSE_OWNER_ID, + storageProvider: "dataverse", + providerContainer: { + provider: "dataverse", + datasetId: DATASET_ID, + persistentId: "doi:10.5072/FK2/MOCKED", + serverUrl: SERVER_URL, + }, + ...overrides, + }); +} + +describe("D1. dataverse experiment: apidata POST succeeds, suppresses tabular ingest, warms the cache", () => { + it("returns 201, sends tabIngest=false, stores the bytes, and leaves collisionCache warm", async () => { + const experimentID = `dataverse-e2e-1-${randomUUID()}`; + const filename = `d1-${randomUUID()}.json`; + await createDataverseExperiment(experimentID); + + const before = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + expect(mockDataverse.getAddCount(filename)).toBe(1); + + const stored = mockDataverse.getFile(filename); + expect(stored).not.toBeNull(); + expect(stored.content.toString("utf8")).toBe(sampleData); + // The STRING "false", not the boolean. Omitting it (or sending a boolean + // the server ignores) makes Dataverse convert CSVs to archival .tab + // files, mangling researchers' data with no error anywhere. + expect(stored.tabIngest).toBe("false"); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(typeof expDataAfter.collisionCache.salt).toBe("string"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(before); + }); +}); + +describe("D2. duplicate filename is rejected without a second provider write", () => { + it("second POST gets OSF_FILE_EXISTS and the mock sees exactly one add", async () => { + const experimentID = `dataverse-e2e-2-${randomUUID()}`; + const filename = `d2-dup-${randomUUID()}.json`; + await createDataverseExperiment(experimentID); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + // Dataverse cannot return a name conflict -- it would have silently + // stored a SECOND file as "<name>-1.json" -- so the Firestore cache is + // the only duplicate gate this provider has. + expect(mockDataverse.getAddCount(filename)).toBe(1); + expect(mockDataverse.getStoredNames().filter((n) => n === filename)).toHaveLength(1); + }); +}); + +describe("D3. metadata on dataverse is delete-then-re-add", () => { + it("creates dataset_description.json, stores the ref, then DELETEs that id and re-adds, leaving one file", async () => { + const experimentID = `dataverse-e2e-3-${randomUUID()}`; + await createDataverseExperiment(experimentID, { metadataActive: true }); + + const first = await saveData({ + experimentID, + data: sampleData, + filename: `d3-a-${randomUUID()}.json`, + }); + expect(first.status).toBe(201); + expect(mockDataverse.getAddCount("dataset_description.json")).toBe(1); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + const refId = metadataDoc.metadataFileRef.id; + // Must be a real id, never the truthy string "undefined" -- that would + // sail through metadata-block.ts's guard and address /api/files/undefined + // on every later update. + expect(refId).toBeDefined(); + expect(refId).not.toBe("undefined"); + + const second = await saveData({ + experimentID, + data: sampleData, + filename: `d3-b-${randomUUID()}.json`, + }); + expect(second.status).toBe(201); + + // /api/files/{id}/replace is unavailable on a never-published draft, so + // updateFile is DELETE + re-add -- non-atomic by design, with a window + // where the metadata file does not exist. + expect(mockDataverse.getDeleteCount(refId)).toBe(1); + expect(mockDataverse.getAddCount("dataset_description.json")).toBe(2); + // And exactly one survives: if the delete had been skipped, Dataverse + // would have silently renamed the re-add to dataset_description-1.json. + expect( + mockDataverse.getStoredNames().filter((n) => n === "dataset_description.json") + ).toHaveLength(1); + expect(mockDataverse.getStoredNames()).not.toContain("dataset_description-1.json"); + }); +}); + +describe("D4. directoryLabel round-trip for Psych-DS paths", () => { + it("splits data/raw/<name> into directoryLabel + label on write, and the cache claims the rejoined path", async () => { + const experimentID = `dataverse-e2e-4-${randomUUID()}`; + const filename = `d4-${randomUUID()}.json`; + await createDataverseExperiment(experimentID, { metadataActive: true }); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + // Unlike Zenodo's flat keyspace, Dataverse keeps the real Psych-DS + // structure via a flat directoryLabel string. + const stored = mockDataverse.getFile(`data/raw/${filename}`); + expect(stored).not.toBeNull(); + expect(stored.directoryLabel).toBe("data/raw"); + expect(stored.label).toBe(filename); + + // The claim namespace is the REJOINED path (this adapter's storedNameFor + // is identity, and listFiles rejoins directoryLabel + label), so a repeat + // submission is caught. If listFiles returned the bare label instead, + // rehydration would miss and Dataverse would silently duplicate. + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual(expect.objectContaining(MESSAGES.OSF_FILE_EXISTS)); + expect(mockDataverse.getAddCount(filename)).toBe(1); + }); +}); + +describe("D5. contention is classified as transient and retried on the fast tier", () => { + it("a 400 'Failed to add file to dataset.' queues with providerErrorCode CONTENTION and a ~60s first retry", async () => { + const experimentID = `dataverse-e2e-5-${randomUUID()}`; + const filename = `d5-${randomUUID()}.json`; + await createDataverseExperiment(experimentID); + + mockDataverse.forceStatus(filename, 400, CONTENTION_MESSAGE); + + const queuedAt = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(202); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueData = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(queueData.providerErrorCode).toBe("CONTENTION"); + expect(queueData.storageProvider).toBe("dataverse"); + expect(queueData.providerContainer.datasetId).toBe(DATASET_ID); + // The fast tier is the whole condition of Dataverse's CONDITIONAL PASS: + // a 30-student burst collides ~30% of the time, and without this those + // submissions waited 1-2 hours instead of ~1 minute. + expect(queueData.nextRetryAt.toMillis()).toBeLessThan(queuedAt + 10 * 60 * 1000); + }); +}); + +describe("D6. duplicate CONTENT is not mistaken for contention", () => { + it("the same-content 400 queues on the slow tier, not the 60-second one", async () => { + const experimentID = `dataverse-e2e-6-${randomUUID()}`; + const filename = `d6-${randomUUID()}.json`; + await createDataverseExperiment(experimentID); + + // Contains "Failed to add file to dataset." too, so a naive regex would + // classify this as CONTENTION and spin on it -- re-uploading identical + // bytes gets the identical rejection every time. + mockDataverse.forceStatus(filename, 400, SAME_CONTENT_MESSAGE); + + const queuedAt = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(202); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueData = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(queueData.providerErrorCode).toBe("UNAVAILABLE"); + expect(queueData.nextRetryAt.toMillis()).toBeGreaterThan(queuedAt + 30 * 60 * 1000); + }); +}); + +describe("D7. auth failure maps to AUTH_EXPIRED", () => { + it("a 401 from the installation queues the submission tagged AUTH_EXPIRED", async () => { + const experimentID = `dataverse-e2e-7-${randomUUID()}`; + const filename = `d7-${randomUUID()}.json`; + await createDataverseExperiment(experimentID); + + // Dataverse returns 401 for both an invalid token and permission-denied. + mockDataverse.forceStatus(filename, 401, "Bad api key"); + + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(202); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueData = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(queueData.providerErrorCode).toBe("AUTH_EXPIRED"); + }); +}); + +describe("D8. base64 media path", () => { + it("apibase64 stores the decoded bytes intact through the multipart add", async () => { + const experimentID = `dataverse-e2e-8-${randomUUID()}`; + const filename = `d8-${randomUUID()}.bin`; + await createDataverseExperiment(experimentID); + + // Invalid UTF-8 on purpose: anything that stringifies the payload en + // route (including a careless multipart implementation on either side) + // corrupts these bytes detectably. + const raw = Buffer.from([0x00, 0x01, 0xfe, 0xff, 0x42, 0x00, 0x7f]); + const response = await saveBase64({ experimentID, data: raw.toString("base64"), filename }); + + expect(response.status).toBe(201); + const stored = mockDataverse.getFile(filename); + expect(stored).not.toBeNull(); + expect(Buffer.compare(stored.content, raw)).toBe(0); + }); +}); + +describe("D9. cold collision cache rehydrates from the draft file listing", () => { + it("a path-prefixed file already in the dataset is caught as a duplicate after the cache goes cold", async () => { + const experimentID = `dataverse-e2e-9-${randomUUID()}`; + const filename = `d9-${randomUUID()}.json`; + await createDataverseExperiment(experimentID, { + metadataActive: true, + collisionCache: { + salt: "d9-retained-salt", + warmUntil: Timestamp.fromMillis(Date.now() - 60 * 60 * 1000), + }, + }); + // Present on the provider with a directoryLabel, unknown to Firestore -- + // rehydration has to rejoin the two halves to recognise it. + mockDataverse.seedFile(filename, "data/raw", "an earlier session"); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(400); + expect(response.body).toEqual(expect.objectContaining(MESSAGES.OSF_FILE_EXISTS)); + expect(mockDataverse.getAddCount(filename)).toBe(0); + expect(mockDataverse.getFile(`data/raw/${filename}`).content.toString("utf8")).toBe( + "an earlier session" + ); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expDataAfter.collisionCache.salt).toBe("d9-retained-salt"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(Date.now()); + }); +}); + +describe("D10. silent rename when the cache does not know about an existing file", () => { + // Documents CURRENT behavior, which is not obviously the desired behavior. + // + // The cache is the only duplicate gate on Dataverse, so this needs a file + // the cache cannot know about: seeded on the provider AFTER the cache was + // warmed, which is the shape of a researcher uploading into the dataset by + // hand mid-study. DataPipe writes, and Dataverse silently renames. + // + // The adapter does its part -- writeSessionFile reads storedFilename back + // off the response's `label` rather than assuming it. But NOTHING CONSUMES + // storedFilename: api-data.ts confirms the claim under the requested name + // (claimName, api-data.ts:190/300/341), so Firestore records a claim for + // "<name>.json" while the dataset actually holds "<name>-1.json". A later + // rehydration lists the renamed file and the two never reconcile. + // + // Asserted as-is rather than as a bug so the suite is honest about what + // ships today; if the mismatch is ever surfaced or reconciled, this test + // should be updated deliberately rather than silently. + it("the write succeeds under a renamed label while the cache still holds the requested name", async () => { + const experimentID = `dataverse-e2e-10-${randomUUID()}`; + const filename = `d10-${randomUUID()}.json`; + await createDataverseExperiment(experimentID); + + // Warm the cache with an unrelated submission first, so the cold-cache + // rehydration path (D9) is not what runs here. + await saveData({ experimentID, data: sampleData, filename: `d10-warm-${randomUUID()}.json` }); + mockDataverse.seedFile(filename, undefined, "uploaded by hand"); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + // Accepted, not rejected: the cache had no claim for this name. + expect(response.status).toBe(201); + // Dataverse renamed it rather than reporting a conflict, and the + // hand-uploaded file is untouched. + expect(mockDataverse.getFile(filename).content.toString("utf8")).toBe("uploaded by hand"); + const renamed = mockDataverse.getStoredNames().filter((n) => n.startsWith(`${filename.slice(0, -5)}-1`)); + expect(renamed).toHaveLength(1); + + // The gap: the claim is recorded under the REQUESTED name, which is not + // the name the provider holds for DataPipe's file. + const claims = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .get(); + expect(claims.size).toBeGreaterThan(0); + }); +}); diff --git a/functions/src/__tests__/finalization-emulator.test.js b/functions/src/__tests__/finalization-emulator.test.js new file mode 100644 index 0000000..92ff42a --- /dev/null +++ b/functions/src/__tests__/finalization-emulator.test.js @@ -0,0 +1,1039 @@ +/** + * @jest-environment node + */ + +// End-to-end coverage for finalization (finalization.ts, +// docs/finalization-spec.md) against a self-contained mock Zenodo. +// +// Structured exactly like compaction-emulator.test.js, which this reuses +// heavily: same in-process approach (finalizeExperiment is called directly, +// there is no HTTP endpoint for it in this phase -- Phase 4), same mock +// Zenodo shape (computes real md5s, since finalization deletes research data +// on the strength of a checksum comparison), same env bootstrap ordering +// (process.env before the deferred dynamic import), same node-fetch handling. +// +// Port 3590, distinct from every fixed mock-server port already in use +// (3579-3583 -- see compaction-emulator.test.js, gdrive-emulator.test.js, +// oauth-connect-emulator.test.js, dataverse-emulator.test.js, +// zenodo-emulator.test.js) so this suite can run concurrently with them. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore, Timestamp, FieldValue } from "firebase-admin/firestore"; +import { randomUUID, createHash } from "crypto"; +import { inflateRawSync } from "zlib"; +import express from "express"; +import { PSYCHDS_IGNORE_CONTENT } from "@jspsych/metadata"; + +const ZENODO_PORT = 3590; +const BUCKET_ID = "finalization-bucket"; +const ZENODO_SERVER_URL = "https://zenodo.org"; +const OWNER_ID = "finalization-emulator-owner"; +const PSYCHDS_IGNORE_FILE = ".psychds-ignore"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; +process.env.GCLOUD_PROJECT = "datapipe-test"; +process.env.FIREBASE_CONFIG = JSON.stringify({ + projectId: "datapipe-test", + storageBucket: "datapipe-test.appspot.com", +}); +// See the header of compaction-emulator.test.js: zenodo.ts ignores +// ZENODO_API_BASE without this. +process.env.FUNCTIONS_EMULATOR = "true"; +process.env.ZENODO_API_BASE = `http://127.0.0.1:${ZENODO_PORT}`; + +jest.setTimeout(120000); + +// node-fetch is ESM-only; every adapter imports it at module scope, and this +// suite's whole point is real requests reaching the mock below, so it is +// aliased to Node's built-in fetch rather than faked -- same as +// compaction-emulator.test.js, and required for writeStreamedFile's +// duplex:"half" streamed body to actually work (node-fetch itself would +// tolerate it, but the point is exercising what zenodo.ts really sends). +jest.mock("node-fetch", () => ({ + __esModule: true, + default: (...args) => globalThis.fetch(...args), +})); + +const md5 = (buffer) => createHash("md5").update(buffer).digest("hex"); + +// -------------------------------------------------------------------------- +// A minimal ZIP reader (same hand-rolled reader as compaction-emulator.test.js +// -- reads the central directory, not local headers, for the same reason: +// archiver streams and zeroes local-header sizes). +// -------------------------------------------------------------------------- +function readZipEntries(zip) { + let eocd = -1; + for (let i = zip.length - 22; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) { + throw new Error("no end-of-central-directory record: not a zip"); + } + + const count = zip.readUInt16LE(eocd + 10); + let offset = zip.readUInt32LE(eocd + 16); + const entries = new Map(); + + for (let i = 0; i < count; i += 1) { + if (zip.readUInt32LE(offset) !== 0x02014b50) { + throw new Error(`corrupt central directory entry at ${offset}`); + } + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localOffset = zip.readUInt32LE(offset + 42); + const name = zip.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + + const localNameLength = zip.readUInt16LE(localOffset + 26); + const localExtraLength = zip.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const raw = zip.subarray(dataStart, dataStart + compressedSize); + + entries.set(name, method === 0 ? Buffer.from(raw) : inflateRawSync(raw)); + offset += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +// -------------------------------------------------------------------------- +// Mock Zenodo: the same four routes compaction's mock exposes. +// -------------------------------------------------------------------------- +function createMockZenodo() { + const app = express(); + app.use(express.raw({ type: () => true, limit: "200mb" })); + + const files = new Map(); // key -> Buffer + const deleteCounts = new Map(); + let corruptChecksums = false; + + app.get("/api/deposit/depositions", (req, res) => res.status(200).json([])); + + app.put("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + if (req.headers["content-type"] !== "application/octet-stream") { + res.status(415).json({ status: 415, message: "Invalid 'Content-Type' header." }); + return; + } + const content = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? ""); + if (!files.has(key) && files.size >= 100) { + res.status(400).json({ + status: 400, + message: "Uploading selected files will result in exceeding the max amount per record.", + }); + return; + } + files.set(key, content); + res.status(200).json({ + key, + size: content.length, + checksum: corruptChecksums ? "md5:deadbeef" : `md5:${md5(content)}`, + }); + }); + + app.get("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + if (!files.has(key)) { + res.status(404).json({ status: 404, message: "Object does not exist." }); + return; + } + res.status(200).send(files.get(key)); + }); + + app.delete("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + deleteCounts.set(key, (deleteCounts.get(key) || 0) + 1); + if (!files.has(key)) { + res.status(404).json({ status: 404, message: "Object does not exist." }); + return; + } + files.delete(key); + res.status(204).send(); + }); + + app.get("/api/deposit/depositions/:id/files", (req, res) => { + res.status(200).json( + Array.from(files.entries()).map(([key, content]) => ({ + id: `file-${key}`, + filename: key, + filesize: content.length, + checksum: `md5:${md5(content)}`, + })) + ); + }); + + return new Promise((resolve, reject) => { + // Same EADDRINUSE retry as compaction-emulator.test.js: jest may schedule + // a concurrent worker while a previous run of this suite is still + // releasing the port. + const tryListen = (retriesLeft) => { + const server = app.listen(ZENODO_PORT); + server.once("listening", () => + resolve({ + server, + seed: (key, content) => files.set(key, Buffer.from(content)), + get: (key) => files.get(key) ?? null, + has: (key) => files.has(key), + keys: () => Array.from(files.keys()), + size: () => files.size, + deleteCount: (key) => deleteCounts.get(key) || 0, + setCorruptChecksums: (value) => { + corruptChecksums = value; + }, + reset: () => { + files.clear(); + deleteCounts.clear(); + corruptChecksums = false; + }, + }) + ); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); + }); +} + +let db; +let storage; +let mock; +let finalizeExperiment; +let buildArchive; +let archivePathsFor; +let claimDocId; +let zenodoProvider; +let retryPendingUploads; + +beforeAll(async () => { + mock = await createMockZenodo(); + + initializeApp({ projectId: "datapipe-test", storageBucket: "datapipe-test.appspot.com" }, "finalization-test"); + db = getFirestore(initializeApp({ projectId: "datapipe-test" }, "finalization-test-db")); + + // Deferred so the process.env assignments above are in place when app.js + // and zenodo.js first evaluate (same reasoning as compaction-emulator.test.js). + const app = await import("../../lib/app.js"); + storage = app.storage; + + const finalization = await import("../../lib/finalization.js"); + finalizeExperiment = finalization.finalizeExperiment; + + const compaction = await import("../../lib/compaction.js"); + buildArchive = compaction.buildArchive; + archivePathsFor = compaction.archivePathsFor; + + const cache = await import("../../lib/collision-cache.js"); + claimDocId = cache.claimDocId; + + zenodoProvider = (await import("../../lib/providers/zenodo.js")).zenodoProvider; + + // The retry worker's own seam (see scheduled-upload-retry.ts's header on + // `ownerScope`) -- used below to prove it never writes into a finalized + // experiment's container. + retryPendingUploads = (await import("../../lib/scheduled-upload-retry.js")).retryPendingUploads; + + await db.collection("users").doc(OWNER_ID).set({ + connectedAccounts: { + zenodo: { + authMethod: "static-token", + encryptedToken: "finalization-token", + serverUrl: ZENODO_SERVER_URL, + }, + }, + }); +}); + +afterEach(() => { + mock.reset(); +}); + +afterAll(() => { + mock.server.close(); +}); + +const SALT = "finalization-test-salt"; + +function containerFor() { + return { + provider: "zenodo", + depositionId: 9991234, + bucketUrl: `http://127.0.0.1:${ZENODO_PORT}/api/files/${BUCKET_ID}`, + serverUrl: ZENODO_SERVER_URL, + }; +} + +/** + * Stages a Zenodo experiment that has already been through one or more + * compaction passes: two batch archives (each holding real Psych-DS-shaped + * members, built the same way compaction itself would build them) plus a + * handful of loose (not yet compacted) sessions, .psychds-ignore, and + * dataset_description.json. + * + * Everything is seeded directly (files on the mock, claims written straight + * to Firestore) rather than driven through compactExperiment/claimFilename, + * for the same reason seedExperiment in compaction-emulator.test.js does: + * the shape of a claim or a batch archive is already covered elsewhere, and + * this suite is about the MERGE, not about reproducing compaction itself. + */ +async function seedFinalizableExperiment({ metadataActive = true } = {}) { + const experimentID = `finalization-${randomUUID()}`; + const names = []; + + async function seedBatch(batchName, subjectNumbers) { + const memberNames = subjectNumbers.map((n) => + metadataActive ? `data_raw_subject-${n}.json` : `subject-${n}.json` + ); + const contents = new Map( + memberNames.map((name, i) => [ + name, + Buffer.from(JSON.stringify({ subject: subjectNumbers[i], rt: 400 + subjectNumbers[i] })), + ]) + ); + const paths = archivePathsFor(zenodoProvider, metadataActive, memberNames); + const { zip } = await buildArchive(memberNames.map((name) => ({ path: paths.get(name), content: contents.get(name) }))); + mock.seed(batchName, zip); + names.push(batchName); + return { memberNames, contents }; + } + + const batch1 = await seedBatch("datapipe-batch-0001.zip", [1, 2, 3]); + const batch2 = await seedBatch( + "datapipe-batch-0002.zip", + [4, 5], + ); + + // Loose (not yet compacted) sessions -- what a pass since the last + // compaction has collected. + const looseNames = metadataActive + ? ["data_raw_subject-6.json", "data_raw_subject-7.json"] + : ["subject-6.json", "subject-7.json"]; + const looseContents = new Map(); + looseNames.forEach((name, i) => { + const content = Buffer.from(JSON.stringify({ subject: i + 6, rt: 500 + i })); + mock.seed(name, content); + looseContents.set(name, content); + names.push(name); + }); + + mock.seed("dataset_description.json", JSON.stringify({ name: "study" })); + names.push("dataset_description.json"); + + if (metadataActive) { + mock.seed(PSYCHDS_IGNORE_FILE, PSYCHDS_IGNORE_CONTENT); + names.push(PSYCHDS_IGNORE_FILE); + } + + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + activeBase64: true, + metadataActive, + sessions: 7, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: containerFor(), + collisionCache: { + salt: SALT, + warmUntil: Timestamp.fromMillis(Date.now() + 86400000), + }, + }); + + const claims = db.collection("experiments").doc(experimentID).collection("filenameClaims"); + const batch = db.batch(); + for (const name of names) { + batch.set(claims.doc(claimDocId(SALT, name)), { + status: "confirmed", + ownerToken: "seed", + createdAt: Timestamp.now(), + expiresAt: Timestamp.fromMillis(Date.now() + 86400000), + }); + } + await batch.commit(); + + return { + experimentID, + names, + batch1, + batch2, + looseContents, + looseNames, + }; +} + +const finalArchiveBytes = () => mock.get("datapipe-final.zip"); + +describe("F1. the full merge", () => { + it("merges every batch and every loose file into one archive carrying the complete Psych-DS tree", async () => { + const { experimentID } = await seedFinalizableExperiment(); + + const result = await finalizeExperiment(experimentID); + + expect(result.status).toBe("finalized"); + expect(result.archiveName).toBe("datapipe-final.zip"); + // archived counts top-level provider files consumed by the merge: the 2 + // batch archives + 2 loose sessions + .psychds-ignore = 5. Each batch + // then explodes into its own members once unpacked, which is what the + // archive's entry count below checks. + expect(result.archived).toBe(5); + + const entries = readZipEntries(finalArchiveBytes()); + // 3 (batch1) + 2 (batch2) + 2 loose + .psychds-ignore = 8 exploded entries. + expect(entries.size).toBe(8); + for (let i = 1; i <= 7; i += 1) { + expect(entries.has(`data/raw/subject-${i}.json`)).toBe(true); + } + expect(entries.has(".psychds-ignore")).toBe(true); + // The record's descriptor is excluded from the merge and never appears + // inside the archive. + expect(entries.has("dataset_description.json")).toBe(false); + }); + + it("leaves dataset_description.json loose and moves .psychds-ignore inside the archive", async () => { + const { experimentID } = await seedFinalizableExperiment(); + await finalizeExperiment(experimentID); + + expect(mock.has("dataset_description.json")).toBe(true); + // Nothing regenerates it once finalized, so the loose copy is gone. + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(false); + const entries = readZipEntries(finalArchiveBytes()); + expect(entries.get(".psychds-ignore").toString("utf8")).toBe(PSYCHDS_IGNORE_CONTENT); + }); + + it("deletes every merged member, leaving only the archive and the descriptor", async () => { + const { experimentID } = await seedFinalizableExperiment(); + await finalizeExperiment(experimentID); + + expect(mock.keys().sort()).toEqual(["dataset_description.json", "datapipe-final.zip"].sort()); + }); + + it("keeps names flat for an experiment that never wrote a slashed path", async () => { + const { experimentID } = await seedFinalizableExperiment({ metadataActive: false }); + await finalizeExperiment(experimentID); + + const entries = readZipEntries(finalArchiveBytes()); + expect(entries.has("subject-1.json")).toBe(true); + expect([...entries.keys()].some((name) => name.includes("/"))).toBe(false); + }); +}); + +describe("F2. byte fidelity through the batch -> unzip -> rezip round trip", () => { + it("round-trips a batch member's bytes exactly, including non-UTF-8 content", async () => { + const experimentID = `finalization-${randomUUID()}`; + const media = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00, 0x01, 0x80, 0xc0, 0x00, 0x00]); + const memberNames = ["data_raw_trial-media.png"]; + const paths = archivePathsFor(zenodoProvider, true, memberNames); + const { zip } = await buildArchive([{ path: paths.get(memberNames[0]), content: media }]); + mock.seed("datapipe-batch-0001.zip", zip); + mock.seed("dataset_description.json", JSON.stringify({ name: "study" })); + + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + metadataActive: true, + sessions: 1, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: containerFor(), + collisionCache: { salt: SALT, warmUntil: Timestamp.fromMillis(Date.now() + 86400000) }, + }); + const claims = db.collection("experiments").doc(experimentID).collection("filenameClaims"); + await claims.doc(claimDocId(SALT, "datapipe-batch-0001.zip")).set({ + status: "confirmed", + ownerToken: "seed", + createdAt: Timestamp.now(), + expiresAt: Timestamp.fromMillis(Date.now() + 86400000), + }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + + const entries = readZipEntries(finalArchiveBytes()); + const stored = entries.get("data/raw/trial-media.png"); + expect(stored).toBeDefined(); + expect(stored.equals(media)).toBe(true); + }); + + it("stores loose member contents byte-for-byte", async () => { + const { experimentID, looseContents } = await seedFinalizableExperiment(); + await finalizeExperiment(experimentID); + + const entries = readZipEntries(finalArchiveBytes()); + for (const [name, content] of looseContents) { + const path = name.startsWith("data_raw_") ? `data/raw/${name.slice("data_raw_".length)}` : name; + expect(entries.get(path).equals(content)).toBe(true); + } + }); +}); + +describe("F3. verification gates the delete", () => { + it("keeps every original when the provider reports a bad checksum", async () => { + const { experimentID } = await seedFinalizableExperiment(); + mock.setCorruptChecksums(true); + + const result = await finalizeExperiment(experimentID); + + expect(result.status).toBe("failed"); + expect(result.detail).toMatch(/checksum mismatch/); + + // Nothing merged was removed. + expect(mock.has("datapipe-batch-0001.zip")).toBe(true); + expect(mock.has("datapipe-batch-0002.zip")).toBe(true); + expect(mock.has("data_raw_subject-6.json")).toBe(true); + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(true); + expect(mock.has("dataset_description.json")).toBe(true); + // The unverified object is cleaned up rather than left looking sealed. + expect(mock.has("datapipe-final.zip")).toBe(false); + + const runs = await db + .collection("experiments") + .doc(experimentID) + .collection("finalizationRuns") + .get(); + expect(runs.empty).toBe(true); + + const expData = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expData.finalized).not.toBe(true); + }); +}); + +describe("F4. resuming an interrupted pass", () => { + async function computeMergedArchive({ experimentID, names, batch1, batch2, looseContents, looseNames }) { + // Reproduce exactly what runFinalization's merge would produce: batch + // members re-emitted at their recorded paths, loose files at their + // reconstructed paths, .psychds-ignore included, dataset_description.json + // excluded. + const entries = []; + for (const [name, content] of batch1.contents) { + const paths = archivePathsFor(zenodoProvider, true, [name]); + entries.push({ path: paths.get(name), content }); + } + for (const [name, content] of batch2.contents) { + const paths = archivePathsFor(zenodoProvider, true, [name]); + entries.push({ path: paths.get(name), content }); + } + const loosePaths = archivePathsFor(zenodoProvider, true, looseNames); + for (const name of looseNames) { + entries.push({ path: loosePaths.get(name), content: looseContents.get(name) }); + } + entries.push({ path: ".psychds-ignore", content: Buffer.from(PSYCHDS_IGNORE_CONTENT) }); + + const members = names.filter((n) => n !== "dataset_description.json"); + return { ...(await buildArchive(entries)), members }; + } + + it("finishes a pass whose merged archive was verified but not yet sealed or deleted", async () => { + const seeded = await seedFinalizableExperiment(); + const { zip, md5: expectedMd5, members } = await computeMergedArchive(seeded); + + mock.seed("datapipe-final.zip", zip); + await db + .collection("experiments") + .doc(seeded.experimentID) + .collection("finalizationRuns") + .doc("current") + .set({ + archiveName: "datapipe-final.zip", + storagePath: `finalization/${seeded.experimentID}/datapipe-final.zip`, + status: "uploading", + memberHashes: members.map((name) => claimDocId(SALT, name)), + expectedMd5, + fileCount: members.length, + createdAt: Timestamp.now(), + }); + + const result = await finalizeExperiment(seeded.experimentID); + + expect(result.status).toBe("finalized"); + expect(result.detail).toMatch(/resumed/); + // Every original member is gone; no second merge was built. + expect(mock.has("datapipe-batch-0001.zip")).toBe(false); + expect(mock.has("data_raw_subject-6.json")).toBe(false); + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(false); + expect(mock.has("datapipe-final.zip")).toBe(true); + + const expData = (await db.collection("experiments").doc(seeded.experimentID).get()).data(); + expect(expData.finalized).toBe(true); + + const claim = await db + .collection("experiments") + .doc(seeded.experimentID) + .collection("filenameClaims") + .doc(claimDocId(SALT, "datapipe-batch-0001.zip")) + .get(); + expect(claim.data().sealed).toBe(true); + }); + + it("resumes after the record was sealed but before the finalized flag was set", async () => { + const seeded = await seedFinalizableExperiment(); + const { members } = await computeMergedArchive(seeded); + + // Reproduce the exact crash point: everything already merged, uploaded, + // sealed and deleted -- only the experiment's finalized flag never got + // written. + const result0 = await finalizeExperiment(seeded.experimentID); + expect(result0.status).toBe("finalized"); + + // Simulate the crash by manually reverting just the flag, leaving the + // (now sealed) finalizationRuns record in place. + await db.collection("experiments").doc(seeded.experimentID).update({ + finalized: FieldValue.delete(), + finalizedAt: FieldValue.delete(), + }); + + const result = await finalizeExperiment(seeded.experimentID); + expect(result.status).toBe("finalized"); + expect(result.detail).toMatch(/resumed after seal/); + expect(result.archived).toBe(members.length); + + const expData = (await db.collection("experiments").doc(seeded.experimentID).get()).data(); + expect(expData.finalized).toBe(true); + }); + + it("discards the record and starts fresh when the merged archive never landed", async () => { + const seeded = await seedFinalizableExperiment(); + + await db + .collection("experiments") + .doc(seeded.experimentID) + .collection("finalizationRuns") + .doc("current") + .set({ + archiveName: "datapipe-final.zip", + storagePath: `finalization/${seeded.experimentID}/datapipe-final.zip`, + status: "uploading", + memberHashes: [claimDocId(SALT, "datapipe-batch-0001.zip")], + expectedMd5: "never-uploaded", + fileCount: 1, + createdAt: Timestamp.now(), + }); + + const result = await finalizeExperiment(seeded.experimentID); + + // Nothing was deleted before the crash, so the safe move is to throw the + // record away and finalize normally. + expect(result.status).toBe("finalized"); + expect(result.archived).toBe(5); + expect(mock.has("datapipe-final.zip")).toBe(true); + }); +}); + +describe("F5. finalization is permanent", () => { + it("refuses a second finalization", async () => { + const { experimentID } = await seedFinalizableExperiment(); + const first = await finalizeExperiment(experimentID); + expect(first.status).toBe("finalized"); + + const second = await finalizeExperiment(experimentID); + expect(second.status).toBe("already-finalized"); + // Nothing else moved -- the archive from the first pass is untouched. + expect(mock.keys().sort()).toEqual(["dataset_description.json", "datapipe-final.zip"].sort()); + }); + + it("refuses a new /api/data submission once finalized", async () => { + const experimentID = `finalization-reject-${randomUUID()}`; + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + activeBase64: true, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: containerFor(), + finalized: true, + finalizedAt: Timestamp.now(), + }); + + const response = await fetch(`http://localhost:5001/datapipe-test/us-central1/apidata`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + experimentID, + filename: "late-session.json", + data: JSON.stringify([{ trial: 1 }]), + }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("EXPERIMENT_FINALIZED"); + }); + + it("refuses a new /api/base64 submission once finalized", async () => { + const experimentID = `finalization-reject-b64-${randomUUID()}`; + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + activeBase64: true, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: containerFor(), + finalized: true, + finalizedAt: Timestamp.now(), + }); + + const response = await fetch(`http://localhost:5001/datapipe-test/us-central1/apibase64`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + experimentID, + filename: "late-media.png", + data: "data:application/octet-stream;base64,AAAA", + }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("EXPERIMENT_FINALIZED"); + }); +}); + +describe("F6. eligibility", () => { + it("declines an experiment on a provider with no file cap", async () => { + const experimentID = `finalization-nocap-${randomUUID()}`; + await db.collection("experiments").doc(experimentID).set({ + owner: OWNER_ID, + sessions: 500, + storageProvider: "gdrive", + providerContainer: { provider: "gdrive", folderId: "folder-1" }, + }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("not-eligible"); + expect(result.detail).toMatch(/no file-count cap/); + }); + + it("declines a legacy OSF experiment with no provider container", async () => { + const experimentID = `finalization-legacy-${randomUUID()}`; + await db + .collection("experiments") + .doc(experimentID) + .set({ owner: OWNER_ID, sessions: 500, osfFilesLink: "https://osf.io/x" }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("not-eligible"); + }); + + it("declines an experiment that has never had a filename claimed", async () => { + const experimentID = `finalization-nosalt-${randomUUID()}`; + await db + .collection("experiments") + .doc(experimentID) + .set({ + owner: OWNER_ID, + sessions: 5, + storageProvider: "zenodo", + providerContainer: containerFor(), + }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("nothing-to-archive"); + }); + + it("refuses to run while a compaction (or another finalization) pass holds the lease", async () => { + const { experimentID } = await seedFinalizableExperiment(); + await db + .collection("experiments") + .doc(experimentID) + .update({ "compaction.compactingUntil": Timestamp.fromMillis(Date.now() + 600000) }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("leased-elsewhere"); + expect(mock.has("datapipe-final.zip")).toBe(false); + }); + + it("finalizes an experiment that has nothing but its descriptor", async () => { + // A salt exists (something was submitted at some point) but every session + // has since been removed by hand -- an edge case, not the common path, + // but finalizing (with nothing to merge) is still the right terminal + // state. + const experimentID = `finalization-empty-${randomUUID()}`; + mock.seed("dataset_description.json", JSON.stringify({ name: "study" })); + await db + .collection("experiments") + .doc(experimentID) + .set({ + owner: OWNER_ID, + sessions: 0, + storageProvider: "zenodo", + providerContainer: containerFor(), + collisionCache: { salt: SALT, warmUntil: Timestamp.fromMillis(Date.now() + 86400000) }, + }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + expect(result.archived).toBe(0); + expect(mock.has("dataset_description.json")).toBe(true); + }); +}); + +describe("F7. the lease is released on every exit path", () => { + async function leaseIsClear(experimentID) { + const expData = (await db.collection("experiments").doc(experimentID).get()).data(); + return !expData.compaction?.compactingUntil; + } + + it("releases the lease after a successful finalization", async () => { + const { experimentID } = await seedFinalizableExperiment(); + await finalizeExperiment(experimentID); + expect(await leaseIsClear(experimentID)).toBe(true); + }); + + it("releases the lease after a failed (unverifiable) finalization", async () => { + const { experimentID } = await seedFinalizableExperiment(); + mock.setCorruptChecksums(true); + await finalizeExperiment(experimentID); + expect(await leaseIsClear(experimentID)).toBe(true); + }); + + it("releases the lease after a resumed finalization", async () => { + const seeded = await seedFinalizableExperiment(); + await db + .collection("experiments") + .doc(seeded.experimentID) + .collection("finalizationRuns") + .doc("current") + .set({ + archiveName: "datapipe-final.zip", + storagePath: `finalization/${seeded.experimentID}/datapipe-final.zip`, + status: "uploading", + memberHashes: [claimDocId(SALT, "datapipe-batch-0001.zip")], + expectedMd5: "never-uploaded", + fileCount: 1, + createdAt: Timestamp.now(), + }); + await finalizeExperiment(seeded.experimentID); + expect(await leaseIsClear(seeded.experimentID)).toBe(true); + }); +}); + +describe("F8. refuses to finalize while uploads are still queued", () => { + async function seedQueueEntry(experimentID, status) { + const docId = `${experimentID}:queued-${status}-${randomUUID()}.json`.replace(/[/\\]/g, "_"); + await db + .collection("uploadQueue") + .doc(docId) + .set({ + experimentID, + owner: OWNER_ID, + filename: `queued-${status}.json`, + status, + storageProvider: "zenodo", + providerErrorCode: "CONTENTION", + nextRetryAt: Timestamp.fromMillis(Date.now() + 60000), + createdAt: Timestamp.now(), + }); + return docId; + } + + it("refuses when an entry is pending, naming the count, and touches nothing on the provider", async () => { + const { experimentID } = await seedFinalizableExperiment(); + const queueDocId = await seedQueueEntry(experimentID, "pending"); + + const result = await finalizeExperiment(experimentID); + + expect(result.status).toBe("queued-uploads-pending"); + expect(result.detail).toMatch(/1 upload/); + + // Nothing was touched: no archive uploaded, nothing deleted, no + // finalizationRuns record, the lease was never taken (nothing to + // release), and the experiment is not finalized. + expect(mock.has("datapipe-final.zip")).toBe(false); + expect(mock.has("datapipe-batch-0001.zip")).toBe(true); + expect(mock.has("datapipe-batch-0002.zip")).toBe(true); + expect(mock.has("data_raw_subject-6.json")).toBe(true); + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(true); + + const runs = await db.collection("experiments").doc(experimentID).collection("finalizationRuns").get(); + expect(runs.empty).toBe(true); + + const expData = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expData.finalized).not.toBe(true); + expect(expData.compaction?.compactingUntil).toBeUndefined(); + + await db.collection("uploadQueue").doc(queueDocId).delete(); + }); + + it("refuses when an entry is processing (mid-retry), not just pending", async () => { + const { experimentID } = await seedFinalizableExperiment(); + const queueDocId = await seedQueueEntry(experimentID, "processing"); + + const result = await finalizeExperiment(experimentID); + + expect(result.status).toBe("queued-uploads-pending"); + expect(mock.has("datapipe-final.zip")).toBe(false); + + await db.collection("uploadQueue").doc(queueDocId).delete(); + }); + + it("proceeds normally once the queue has drained", async () => { + const { experimentID } = await seedFinalizableExperiment(); + const queueDocId = await seedQueueEntry(experimentID, "pending"); + await db.collection("uploadQueue").doc(queueDocId).update({ status: "completed" }); + + const result = await finalizeExperiment(experimentID); + + expect(result.status).toBe("finalized"); + + await db.collection("uploadQueue").doc(queueDocId).delete(); + }); +}); + +describe("F9. the retry worker never writes into a finalized record", () => { + async function seedRetryableQueueEntry(experimentID, filename, payload) { + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const storagePath = `upload-queue/${docId}`; + await storage.bucket().file(storagePath).save(payload, { contentType: "text/plain" }); + await db + .collection("uploadQueue") + .doc(docId) + .set({ + experimentID, + owner: OWNER_ID, + filename, + storagePath, + dataType: "data", + status: "pending", + storageProvider: "zenodo", + providerContainer: containerFor(), + errorCode: 0, + retryCount: 0, + maxRetries: 5, + createdAt: Timestamp.now(), + lastAttemptAt: null, + // Already due -- retryPendingUploads' query gates on nextRetryAt <= now. + nextRetryAt: Timestamp.fromMillis(Date.now() - 1000), + completedAt: null, + failureReason: "Provider error 500: Internal server error", + deduplicationKey: `${experimentID}:${filename}`, + sessionIncremented: false, + }); + return docId; + } + + it("marks the entry failed with a finalization-specific reason instead of writing to the provider", async () => { + const experimentID = `finalization-retry-${randomUUID()}`; + mock.seed("dataset_description.json", JSON.stringify({ name: "study" })); + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: containerFor(), + finalized: true, + finalizedAt: Timestamp.now(), + }); + + const docId = await seedRetryableQueueEntry(experimentID, "stranded-session.json", JSON.stringify([{ trial: 1 }])); + const filesBefore = mock.size(); + + // Scoped to this suite's own owner -- see the ownerScope seam's doc + // comment in scheduled-upload-retry.ts. The query behind it is global, so + // this keeps the assertion from being affected by other suites' queue + // entries running concurrently against the shared emulator. + await retryPendingUploads(OWNER_ID); + + // The whole point: no PUT ever reached the provider for this file. + expect(mock.size()).toBe(filesBefore); + expect(mock.has("stranded-session.json")).toBe(false); + + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(after.status).toBe("failed"); + expect(after.failureReason).toMatch(/finalized/i); + expect(after.failureReason).toMatch(/download it from the queue panel/i); + // The stale provider-error code from before finalization must not + // survive -- it would tell QueuePanel this is still a provider problem, + // which it no longer is. + expect(after.providerErrorCode).toBeNull(); + + // The payload itself must still be recoverable via api-queue-status.ts's + // download path -- this guard must not also delete it. + const [stillThere] = await storage.bucket().file(`upload-queue/${docId}`).exists(); + expect(stillThere).toBe(true); + + await db.collection("uploadQueue").doc(docId).delete(); + await storage.bucket().file(`upload-queue/${docId}`).delete().catch(() => undefined); + }); + + it("writes normally when the experiment is not finalized", async () => { + // Control: the guard must not fire for an ordinary in-flight experiment, + // or every retry would start failing. + const experimentID = `finalization-retry-control-${randomUUID()}`; + mock.seed("dataset_description.json", JSON.stringify({ name: "study" })); + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + owner: OWNER_ID, + storageProvider: "zenodo", + providerContainer: containerFor(), + }); + + const docId = await seedRetryableQueueEntry(experimentID, "normal-session.json", JSON.stringify([{ trial: 1 }])); + + await retryPendingUploads(OWNER_ID); + + expect(mock.has("normal-session.json")).toBe(true); + const after = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(after.status).toBe("completed"); + + await db.collection("uploadQueue").doc(docId).delete(); + }); +}); + +describe("F10. archive-too-large", () => { + it("refuses a merge that exceeds the provider's per-file limit, uploading and deleting nothing", async () => { + const { experimentID } = await seedFinalizableExperiment(); + const originalMax = zenodoProvider.capabilities.maxFileSizeBytes; + // Absurdly small -- any real merge of this suite's fixtures exceeds it, + // without needing to actually build gigabytes of data to prove the gate. + zenodoProvider.capabilities.maxFileSizeBytes = 10; + + let result; + try { + result = await finalizeExperiment(experimentID); + } finally { + zenodoProvider.capabilities.maxFileSizeBytes = originalMax; + } + + expect(result.status).toBe("archive-too-large"); + expect(result.detail).toMatch(/per-file limit/); + + // Nothing was uploaded... + expect(mock.has("datapipe-final.zip")).toBe(false); + // ...and nothing was deleted. + expect(mock.has("datapipe-batch-0001.zip")).toBe(true); + expect(mock.has("datapipe-batch-0002.zip")).toBe(true); + expect(mock.has("data_raw_subject-6.json")).toBe(true); + expect(mock.has("data_raw_subject-7.json")).toBe(true); + expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(true); + expect(mock.has("dataset_description.json")).toBe(true); + + const runs = await db.collection("experiments").doc(experimentID).collection("finalizationRuns").get(); + expect(runs.empty).toBe(true); + + const expData = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(expData.finalized).not.toBe(true); + expect(expData.compaction?.compactingUntil).toBeUndefined(); + }); +}); diff --git a/functions/src/__tests__/providers-zenodo.test.js b/functions/src/__tests__/providers-zenodo.test.js index 632b0a1..e5b0b62 100644 --- a/functions/src/__tests__/providers-zenodo.test.js +++ b/functions/src/__tests__/providers-zenodo.test.js @@ -220,7 +220,11 @@ describe("4. writeSessionFile", () => { expect(result).toEqual({ success: true, - fileRef: { name: "session-1.json", id: "session-1.json" }, + // size/checksum are passed through for compaction.ts, which will not + // delete a batch's originals unless the checksum the provider reports + // for the uploaded archive matches the one it computed locally. An + // adapter that dropped them here would make every archive unverifiable. + fileRef: { name: "session-1.json", id: "session-1.json", size: 12, checksum: "md5:abc" }, storedFilename: "session-1.json", }); @@ -523,15 +527,216 @@ describe("9. validateStaticToken", () => { }); }); -// The 100-file cap is real today because compaction is not built. These -// assertions are expected to be DELETED along with setupWarnings when it ships. -describe("9b. setupWarnings", () => { - it("warns about the 100-file cap without making a request", async () => { - const warnings = await zenodoProvider.setupWarnings(auth); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toMatch(/100 files/); - // Unconditional and offline: the cap is a property of Zenodo, not of an - // installation, so unlike dataverse.ts there is nothing to probe. +// 9b was a setupWarnings block warning researchers to keep Zenodo experiments +// under 100 submissions because DataPipe could not combine sessions into +// archives. It said it should be deleted along with setupWarnings once +// compaction shipped, and compaction.ts is that. What replaces it is the +// assertion below that the provider still declares its cap, since that is now +// what enrols it in compaction rather than what warns researchers away. +describe("9b. compaction eligibility", () => { + it("declares the cap and the methods compaction needs to act on it", () => { + // capabilities.maxFileCount is documented in types.ts as a contract that + // these three exist. Declaring the cap without them would fail a pass + // partway through -- possibly after uploading an archive it then cannot + // clean up behind. + expect(zenodoProvider.capabilities.maxFileCount).toBe(100); + expect(typeof zenodoProvider.deleteFile).toBe("function"); + expect(typeof zenodoProvider.downloadFileBytes).toBe("function"); + expect(typeof zenodoProvider.archivePathFor).toBe("function"); + }); + + it("no longer warns at setup, because there is nothing to act on", () => { + expect(zenodoProvider.setupWarnings).toBeUndefined(); + }); +}); + +describe("9c. deleteFile", () => { + it("DELETEs the object by key", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 204 })); + + const result = await zenodoProvider.deleteFile(auth, container, { name: "data_raw_s-1.json" }); + + expect(result).toEqual({ success: true }); + const { url, options } = callArgs(0); + expect(url).toBe(`${BUCKET_URL}/data_raw_s-1.json`); + expect(options.method).toBe("DELETE"); + }); + + it("treats an already-missing object as success", async () => { + // Compaction resumes an interrupted pass by re-deleting whatever is left, + // so a 404 means "already in the state we wanted". Reporting it as a + // failure would wedge an experiment that got interrupted mid-delete. + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 404, jsonBody: { message: "Object does not exist." } }) + ); + expect(await zenodoProvider.deleteFile(auth, container, { name: "gone.json" })).toEqual({ + success: true, + }); + }); + + it("maps a real failure into the shared taxonomy", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, jsonBody: { message: "Bad token" } })); + const result = await zenodoProvider.deleteFile(auth, container, { name: "s.json" }); + expect(result.success).toBe(false); + expect(result.error).toBe("AUTH_EXPIRED"); + }); +}); + +describe("9d. downloadFileBytes", () => { + it("returns raw bytes rather than decoded text", async () => { + // The reason this exists alongside downloadFile: /api/base64 submissions + // are images and audio, and reading them through response.text() would + // replace every invalid UTF-8 sequence with U+FFFD -- silently corrupting + // the archive that is about to replace the originals. + const media = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00, 0x80]); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => media.buffer.slice(media.byteOffset, media.byteOffset + media.length), + }); + + const result = await zenodoProvider.downloadFileBytes(auth, container, { name: "m.png" }); + + expect(result.success).toBe(true); + expect(Buffer.isBuffer(result.content)).toBe(true); + expect(result.content.equals(media)).toBe(true); + }); + + it("reports failures as a result rather than throwing", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 404, jsonBody: { message: "Object does not exist." } }) + ); + const result = await zenodoProvider.downloadFileBytes(auth, container, { name: "gone.json" }); + expect(result.success).toBe(false); + expect(result.providerStatus).toBe(404); + }); +}); + +// Finalization streams a merged archive straight from Cloud Storage instead +// of buffering it, because writeSessionFile's Buffer signature caps the +// largest file an adapter can move at function memory -- fine for one +// session, wrong for a study's entire archive (docs/finalization-spec.md). +// Same contract as writeSessionFile otherwise: same bucket PUT, same +// error mapping, same defensive read of key/checksum off the response. +describe("9e. writeStreamedFile", () => { + // Reads a Node Readable to completion, the same way a real HTTP client + // consumes a request body -- proves the bytes fetch was handed are the + // bytes that would actually go over the wire, not just a stream reference. + function drain(stream) { + return new Promise((resolve, reject) => { + const chunks = []; + stream.on("data", (c) => chunks.push(c)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); + } + + function streamOf(content) { + const { Readable } = require("stream"); + return Readable.from([Buffer.from(content)]); + } + + it("PUTs the stream to the bucket URL with an explicit Content-Length and octet-stream type", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, jsonBody: { key: "final.zip", size: 9, checksum: "md5:abc" } }) + ); + + const payload = "streamed!"; + const result = await zenodoProvider.writeStreamedFile( + auth, + container, + "final.zip", + streamOf(payload), + Buffer.byteLength(payload), + { size: Buffer.byteLength(payload), contentType: "application/zip" } + ); + + const { url, options } = callArgs(0); + expect(url).toBe(`${BUCKET_URL}/final.zip`); + expect(options.method).toBe("PUT"); + // Same hard requirement as writeSessionFile: a real mimetype here is a + // 415. `meta.contentType` deliberately says application/zip, so this + // guards against that leaking through for the streamed path too. + expect(header(options.headers, "Content-Type")).toBe("application/octet-stream"); + expect(header(options.headers, "Content-Length")).toBe(String(Buffer.byteLength(payload))); + + const bodyBytes = await drain(options.body); + expect(bodyBytes.toString()).toBe(payload); + + expect(result).toEqual({ + success: true, + fileRef: { name: "final.zip", id: "final.zip", size: 9, checksum: "md5:abc" }, + storedFilename: "final.zip", + }); + }); + + // Node's built-in fetch (undici) -- what the emulator suites alias + // node-fetch to for real HTTP calls -- throws "duplex option is required + // when sending a body" for any stream body. Verified live against a real + // local server (not assumed): node-fetch's own implementation tolerates the + // option fine, but only undici enforces it, so it must always be sent for + // the streamed path to work under both. + it("sends duplex: half so a stream body works under Node's native fetch too", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: { key: "final.zip" } })); + await zenodoProvider.writeStreamedFile(auth, container, "final.zip", streamOf("x"), 1, meta); + expect(callArgs(0).options.duplex).toBe("half"); + }); + + it("flattens path separators into the key, same as writeSessionFile", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: { key: "data_raw_x.zip" } })); + await zenodoProvider.writeStreamedFile(auth, container, "data/raw/x.zip", streamOf("x"), 1, meta); + expect(callArgs(0).url).toBe(`${BUCKET_URL}/data_raw_x.zip`); + }); + + it("reports a server-renamed key rather than the requested name", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: { key: "renamed.zip" } })); + const result = await zenodoProvider.writeStreamedFile(auth, container, "asked.zip", streamOf("x"), 1, meta); + expect(result.storedFilename).toBe("renamed.zip"); + }); + + it("falls back to the flattened requested name when the response has no key", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 201, jsonBody: {} })); + const result = await zenodoProvider.writeStreamedFile( + auth, + container, + "data/raw/x.zip", + streamOf("x"), + 1, + meta + ); + expect(result.storedFilename).toBe("data_raw_x.zip"); + }); + + // Same taxonomy as writeSessionFile -- the retry queue and compaction's + // callers must not need to special-case the streamed path. + it("maps failures through the same error taxonomy as writeSessionFile", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ status: 413, jsonBody: { message: "Too large" } })); + const result = await zenodoProvider.writeStreamedFile(auth, container, "final.zip", streamOf("x"), 1, meta); + expect(result.success).toBe(false); + expect(result.error).toBe("QUOTA_EXCEEDED"); + expect(result.providerStatus).toBe(413); + }); + + it("survives a non-JSON error body", async () => { + mockFetch.mockResolvedValueOnce({ + status: 502, + statusText: "Bad Gateway", + json: () => Promise.reject(new Error("not json")), + headers: { get: () => null }, + }); + const result = await zenodoProvider.writeStreamedFile(auth, container, "final.zip", streamOf("x"), 1, meta); + expect(result.success).toBe(false); + expect(result.error).toBe("UNAVAILABLE"); + expect(result.providerMessage).toBe("Bad Gateway"); + }); + + it("rejects a tampered bucketUrl the same way writeSessionFile does", async () => { + const tampered = { ...container, bucketUrl: "https://evil.test/api/files/abc-123" }; + const result = await zenodoProvider + .writeStreamedFile(auth, tampered, "final.zip", streamOf("x"), 1, meta) + .catch((e) => e); + expect(result).toBeInstanceOf(Error); + expect(result.message).toMatch(/bucketurl origin does not match/i); expect(mockFetch).not.toHaveBeenCalled(); }); }); diff --git a/functions/src/__tests__/zenodo-emulator.test.js b/functions/src/__tests__/zenodo-emulator.test.js new file mode 100644 index 0000000..6433579 --- /dev/null +++ b/functions/src/__tests__/zenodo-emulator.test.js @@ -0,0 +1,501 @@ +/** + * @jest-environment node + */ + +// End-to-end coverage for the Zenodo adapter, driving the REAL deployed +// apidata/apibase64 functions inside the Functions emulator against a +// self-contained mock Zenodo -- the same shape as gdrive-emulator.test.js, +// which is the house pattern for provider write-path coverage. +// +// HOW THE MOCK IS REACHED. Zenodo's adapter allowlists zenodo.org and +// sandbox.zenodo.org (providers/zenodo.ts's ALLOWED_HOSTS), so unlike +// Dataverse there is no address a same-machine mock could bind to that the +// adapter would accept. zenodo.ts therefore reads ZENODO_API_BASE, but only +// when FUNCTIONS_EMULATOR === "true" -- set for us by the emulator, never set +// on a deployed function. functions/.env.local wires it to 127.0.0.1:3581, +// which is the port this file's mock binds. That override REPLACES the +// resolved serverUrl, so nothing in this suite can accidentally reach real +// zenodo.org even though the seeded connection/container carry a +// realistic-looking https://zenodo.org. +// +// TOKENS ARE PLAINTEXT here, exactly as in gdrive-emulator.test.js: the +// seeded encryptedToken has no "v1:" prefix, so crypto-utils.ts's decrypt() +// passes it through unchanged. That avoids needing this jest process and the +// separate Functions-emulator process to agree on TOKEN_ENCRYPTION_KEY. +// +// WHAT THIS DELIBERATELY DOES NOT COVER: createDataContainer (exercised by +// create-experiment-emulator.test.js's own path) and the response-shape +// mapping already covered in-process by providers-zenodo.test.js. What lives +// here is everything that only appears once the full stack is involved -- +// the collision cache, the queue, metadata refs, and the flat-keyspace +// hazard that spans the adapter and the cache together. + +import { initializeApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import express from "express"; +import MESSAGES from "../api-messages"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199"; + +jest.setTimeout(30000); + +const config = { projectId: "datapipe-test", storageBucket: "datapipe-test.appspot.com" }; +const ZENODO_OWNER_ID = "zenodo-emulator-owner"; +const ZENODO_PORT = 3581; +// Deliberately the REAL production host. The emulator override redirects +// every call to the mock regardless, so seeding this proves the redirect is +// what's carrying the traffic -- if the override ever silently stopped +// applying, these tests would try to reach zenodo.org and fail loudly rather +// than passing against a mock they were never actually using. +const ZENODO_SERVER_URL = "https://zenodo.org"; +const BUCKET_ID = "mock-bucket-0000"; + +const sampleData = `[{"trial_type":"html-keyboard-response","trial_index":1,"time_elapsed":776}]`; + +// Zenodo's real 100-file-per-record cap message, captured live at file 101 +// (sandbox, spike gate E, 2026-08-11). Reproduced verbatim because the +// adapter's QUOTA_EXCEEDED mapping keys off this prose -- an approximation +// here would let a regression in that regex pass unnoticed. +const CAP_MESSAGE = "Uploading selected files will result in exceeding the max amount per record."; + +async function postTo(fn, body) { + const response = await fetch(`http://localhost:5001/datapipe-test/us-central1/${fn}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "*/*" }, + body: JSON.stringify(body), + }); + // Parse defensively: an uncaught exception in the function yields a + // plain-text "Internal Server Error", and letting response.json() throw + // would disguise a real behavior failure as a harness bug. + const text = await response.text(); + let message; + try { + message = JSON.parse(text); + } catch { + message = { rawBody: text }; + } + return { status: response.status, body: message }; +} + +const saveData = (body) => postTo("apidata", body); +const saveBase64 = (body) => postTo("apibase64", body); + +// A self-contained mock Zenodo covering the legacy deposit API plus the +// files-REST bucket endpoint -- the exact pair the adapter targets, and only +// the routes it actually calls. +function createMockZenodoServer() { + const app = express(); + // One raw parser for every content type: the bucket PUT sends raw bytes + // (text or binary) and must arrive untouched for the round-trip assertions. + app.use(express.raw({ type: () => true, limit: "20mb" })); + + // key -> {content, contentType} + const filesByKey = new Map(); + const putCountsByKey = new Map(); + const putContentTypes = new Map(); + const forcedStatus = new Map(); + let fileCap = 100; + + function bucketPath(bucketId, key) { + return `${bucketId}/${key}`; + } + + // GET /api/deposit/depositions -- validateStaticToken's probe. + app.get("/api/deposit/depositions", (req, res) => { + res.status(200).json([]); + }); + + // POST /api/deposit/depositions -- createDataContainer. + app.post("/api/deposit/depositions", (req, res) => { + const id = 5551212; + res.status(201).json({ + id, + links: { + // Same origin as the server URL the adapter resolved, which + // resolveBucketUrl re-checks before every write. + bucket: `http://127.0.0.1:${ZENODO_PORT}/api/files/${BUCKET_ID}`, + html: `http://127.0.0.1:${ZENODO_PORT}/deposit/${id}`, + }, + }); + }); + + // PUT /api/files/:bucketId/:key -- the one-request-per-file write path. + // + // Note the route has exactly ONE :key segment. That is not a simplification + // -- it reproduces real Zenodo, where a key containing a literal slash + // addresses a bucket path that does not exist and 404s (spike gate B). If + // the adapter ever stopped flattening slashes, this mock would 404 exactly + // as production does, rather than quietly accepting a nested key. + app.put("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + putCountsByKey.set(key, (putCountsByKey.get(key) || 0) + 1); + putContentTypes.set(key, req.headers["content-type"] || null); + + const forced = forcedStatus.get(key); + if (forced) { + res.status(forced.status).json({ status: forced.status, message: forced.message }); + return; + } + + // The bucket endpoint accepts application/octet-stream and nothing else + // -- a real mimetype draws a hard 415 (live sandbox, spike gate A). This + // bug shipped once already and every write failed, so the mock enforces + // it rather than trusting the header. + if (req.headers["content-type"] !== "application/octet-stream") { + res.status(415).json({ + status: 415, + message: "Invalid 'Content-Type' header. Expected one of: application/octet-stream", + }); + return; + } + + const path = bucketPath(req.params.bucketId, key); + // The cap counts files already in the record; replacing an existing key + // is not a new file, matching the overwrite semantics gate A confirmed. + if (!filesByKey.has(path) && filesByKey.size >= fileCap) { + res.status(400).json({ status: 400, message: CAP_MESSAGE }); + return; + } + + const content = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? ""); + filesByKey.set(path, { content, key }); + res.status(200).json({ key, size: content.length, checksum: `md5:mock-${content.length}` }); + }); + + app.get("/api/files/:bucketId/:key", (req, res) => { + const key = decodeURIComponent(req.params.key); + const file = filesByKey.get(bucketPath(req.params.bucketId, key)); + if (!file) { + res.status(404).json({ status: 404, message: "Object does not exist." }); + return; + } + res.status(200).send(file.content); + }); + + // GET /api/deposit/depositions/:id/files -- the listing the collision cache + // rehydrates from. Reports names as `filename`, the legacy shape. + app.get("/api/deposit/depositions/:id/files", (req, res) => { + const files = Array.from(filesByKey.entries()).map(([path, file]) => ({ + id: `file-${file.key}`, + filename: file.key, + filesize: file.content.length, + checksum: `mock-${path.length}`, + })); + res.status(200).json(files); + }); + + return new Promise((resolve, reject) => { + // Retry on EADDRINUSE with backoff, matching gdrive-emulator.test.js: + // jest may schedule another suite holding a fixed port onto a concurrent + // worker. Defensive only -- no other suite binds 3581 today. + const tryListen = (retriesLeft) => { + const server = app.listen(ZENODO_PORT); + server.once("listening", () => { + resolve({ + server, + getPutCount: (key) => putCountsByKey.get(key) || 0, + getContentType: (key) => putContentTypes.get(key) ?? null, + getStoredKeys: () => Array.from(filesByKey.values()).map((f) => f.key), + getContent: (key) => { + const entry = filesByKey.get(bucketPath(BUCKET_ID, key)); + return entry ? entry.content : null; + }, + // Seeds a file directly, without going through a PUT -- used to + // stage a container the collision cache has never seen so + // rehydration has something to find. + seedFile: (key, content = "seeded") => { + filesByKey.set(bucketPath(BUCKET_ID, key), { content: Buffer.from(content), key }); + }, + forceStatus: (key, status, message) => forcedStatus.set(key, { status, message }), + setFileCap: (n) => { + fileCap = n; + }, + reset: () => { + filesByKey.clear(); + putCountsByKey.clear(); + putContentTypes.clear(); + forcedStatus.clear(); + fileCap = 100; + }, + }); + }); + server.once("error", (err) => { + if (err.code === "EADDRINUSE" && retriesLeft > 0) { + setTimeout(() => tryListen(retriesLeft - 1), 500); + } else { + reject(err); + } + }); + }; + tryListen(60); + }); +} + +let db; +let mockZenodo; + +beforeAll(async () => { + mockZenodo = await createMockZenodoServer(); + + initializeApp(config); + db = getFirestore(); + + await db.collection("users").doc(ZENODO_OWNER_ID).set({ + connectedAccounts: { + zenodo: { + authMethod: "static-token", + encryptedToken: "zenodo-integration-token", // plaintext fallback, see header + serverUrl: ZENODO_SERVER_URL, + }, + }, + }); +}); + +afterEach(() => { + mockZenodo.reset(); +}); + +afterAll(() => { + mockZenodo.server.close(); +}); + +async function createZenodoExperiment(experimentID, overrides = {}) { + await db + .collection("experiments") + .doc(experimentID) + .set({ + active: true, + activeBase64: true, + metadataActive: false, + owner: ZENODO_OWNER_ID, + storageProvider: "zenodo", + providerContainer: { + provider: "zenodo", + depositionId: 5551212, + bucketUrl: `http://127.0.0.1:${ZENODO_PORT}/api/files/${BUCKET_ID}`, + serverUrl: ZENODO_SERVER_URL, + }, + ...overrides, + }); +} + +describe("Z1. zenodo experiment: apidata POST succeeds and warms the collision cache", () => { + it("returns 201, PUTs the file once as application/octet-stream, and leaves collisionCache warm", async () => { + const experimentID = `zenodo-e2e-1-${randomUUID()}`; + const filename = `z1-${randomUUID()}.json`; + await createZenodoExperiment(experimentID); + + const before = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(201); + expect(mockZenodo.getPutCount(filename)).toBe(1); + // Guards the shipped-once regression the spike caught: sending the real + // mimetype here is a hard 415 and every write fails. + expect(mockZenodo.getContentType(filename)).toBe("application/octet-stream"); + expect(mockZenodo.getContent(filename).toString("utf8")).toBe(sampleData); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(typeof expDataAfter.collisionCache.salt).toBe("string"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(before); + }); +}); + +describe("Z2. duplicate filename is rejected without a second provider write", () => { + it("second POST for the same filename gets OSF_FILE_EXISTS and the mock sees exactly one PUT", async () => { + const experimentID = `zenodo-e2e-2-${randomUUID()}`; + const filename = `z2-dup-${randomUUID()}.json`; + await createZenodoExperiment(experimentID); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + expect(second.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + + // This assertion is the whole point on Zenodo specifically. Its write is + // an OVERWRITING PUT with no NAME_CONFLICT to fall back on, so a second + // PUT here would have silently destroyed the first session's data -- + // the cache is the only thing standing between a duplicate name and + // data loss. + expect(mockZenodo.getPutCount(filename)).toBe(1); + expect(mockZenodo.getContent(filename).toString("utf8")).toBe(sampleData); + }); +}); + +describe("Z3. metadata on zenodo", () => { + it("creates dataset_description.json, stores the ref, then overwrites it in place with no second listing entry", async () => { + const experimentID = `zenodo-e2e-3-${randomUUID()}`; + await createZenodoExperiment(experimentID, { metadataActive: true }); + + const first = await saveData({ + experimentID, + data: sampleData, + filename: `z3-a-${randomUUID()}.json`, + }); + expect(first.status).toBe(201); + expect(mockZenodo.getPutCount("dataset_description.json")).toBe(1); + + const metadataDoc = (await db.collection("metadata").doc(experimentID).get()).data(); + expect(metadataDoc.metadataFileRef).toBeDefined(); + expect(metadataDoc.metadataFileRef).not.toBeNull(); + // Zenodo addresses every object by key, so the ref's id IS the key. + expect(metadataDoc.metadataFileRef.id).toBe("dataset_description.json"); + + const second = await saveData({ + experimentID, + data: sampleData, + filename: `z3-b-${randomUUID()}.json`, + }); + expect(second.status).toBe(201); + + // Two PUTs to the same key, but still ONE file -- this is the atomic + // in-place replace gate A established, and the reason zenodo.ts's + // updateFile has no delete step (unlike dataverse.ts and Figshare). + expect(mockZenodo.getPutCount("dataset_description.json")).toBe(2); + const descriptionEntries = mockZenodo + .getStoredKeys() + .filter((k) => k === "dataset_description.json"); + expect(descriptionEntries).toHaveLength(1); + }); +}); + +describe("Z4. flat keyspace: Psych-DS paths are flattened consistently across write, cache and listing", () => { + it("a metadataActive submission stores data_raw_<name>, never a slashed key, and the cache hashes the flattened name", async () => { + const experimentID = `zenodo-e2e-4-${randomUUID()}`; + const filename = `z4-${randomUUID()}.json`; + await createZenodoExperiment(experimentID, { metadataActive: true }); + + const first = await saveData({ experimentID, data: sampleData, filename }); + expect(first.status).toBe(201); + + // metadata-derived-files.ts routes a metadataActive raw upload to + // data/raw/<name>; Zenodo cannot hold a slash, so the stored key is the + // flattened form. If the adapter stopped flattening, the mock's + // single-segment bucket route would 404 exactly as production does. + const storedKeys = mockZenodo.getStoredKeys(); + expect(storedKeys).toContain(`data_raw_${filename}`); + expect(storedKeys.every((k) => !k.includes("/"))).toBe(true); + // The derived Psych-DS CSVs land flattened too, not just the raw file. + expect(storedKeys.some((k) => k.startsWith("data_") && k.endsWith("_data.csv"))).toBe(true); + + // And the claim went into the FLATTENED namespace: resubmitting the same + // name is caught as a duplicate. If claimNameFor/storedNameFor disagreed, + // this second PUT would overwrite the first session's data instead. + const second = await saveData({ experimentID, data: sampleData, filename }); + expect(second.status).toBe(400); + // objectContaining, not toEqual: a metadataActive experiment also reports + // its metadata state alongside the duplicate error, which the + // metadata-off cases above (Z2) do not. + expect(second.body).toEqual(expect.objectContaining(MESSAGES.OSF_FILE_EXISTS)); + expect(mockZenodo.getPutCount(`data_raw_${filename}`)).toBe(1); + }); +}); + +describe("Z5. provider failure queues the upload and tags it with the zenodo container", () => { + it("a forced 500 yields a 202 queued response with claimToken, storageProvider and providerContainer on the queue doc", async () => { + const experimentID = `zenodo-e2e-5-${randomUUID()}`; + const filename = `z5-${randomUUID()}.json`; + await createZenodoExperiment(experimentID); + + mockZenodo.forceStatus(filename, 500, "Internal server error"); + + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(202); + expect(response.body).toEqual( + expect.objectContaining({ ...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage: "" }) + ); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueData = (await db.collection("uploadQueue").doc(docId).get()).data(); + expect(queueData).toBeDefined(); + expect(typeof queueData.claimToken).toBe("string"); + expect(queueData.claimToken.length).toBeGreaterThan(0); + expect(queueData.storageProvider).toBe("zenodo"); + expect(queueData.providerContainer.depositionId).toBe(5551212); + expect(queueData.providerErrorCode).toBe("UNAVAILABLE"); + }); +}); + +describe("Z6. the 100-file cap surfaces as QUOTA_EXCEEDED, not an endlessly-retried outage", () => { + it("a full record queues the submission tagged QUOTA_EXCEEDED on the slow tier", async () => { + const experimentID = `zenodo-e2e-6-${randomUUID()}`; + const filename = `z6-${randomUUID()}.json`; + await createZenodoExperiment(experimentID); + + // Cap of 0: the very next write is the 101st file as far as the mock is + // concerned, returning Zenodo's real cap prose. + mockZenodo.setFileCap(0); + + const queuedAt = Date.now(); + const response = await saveData({ experimentID, data: sampleData, filename }); + expect(response.status).toBe(202); + + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + const queueData = (await db.collection("uploadQueue").doc(docId).get()).data(); + // "exceeding", not "exceeds" -- the inflection that originally sent a + // permanently-full record to UNAVAILABLE, where the queue would have + // retried it forever. + expect(queueData.providerErrorCode).toBe("QUOTA_EXCEEDED"); + // Slow tier: only CONTENTION gets the 60-second retry. A full record + // needs human action (compaction), so it must not spin on the fast tier. + expect(queueData.nextRetryAt.toMillis()).toBeGreaterThan(queuedAt + 30 * 60 * 1000); + }); +}); + +describe("Z7. base64 media path", () => { + it("apibase64 stores the decoded bytes intact under the requested key", async () => { + const experimentID = `zenodo-e2e-7-${randomUUID()}`; + const filename = `z7-${randomUUID()}.bin`; + await createZenodoExperiment(experimentID); + + // Bytes that are NOT valid UTF-8 text, so a transport that stringified + // the payload anywhere along the way would corrupt them detectably. + const raw = Buffer.from([0x00, 0x01, 0xfe, 0xff, 0x42, 0x00, 0x7f]); + const response = await saveBase64({ + experimentID, + data: raw.toString("base64"), + filename, + }); + + expect(response.status).toBe(201); + expect(mockZenodo.getPutCount(filename)).toBe(1); + expect(mockZenodo.getContentType(filename)).toBe("application/octet-stream"); + expect(Buffer.compare(mockZenodo.getContent(filename), raw)).toBe(0); + }); +}); + +describe("Z8. cold collision cache rehydrates from the deposition listing", () => { + it("a filename already present in the deposition is caught as a duplicate after the cache goes cold", async () => { + const experimentID = `zenodo-e2e-8-${randomUUID()}`; + const filename = `z8-${randomUUID()}.json`; + await createZenodoExperiment(experimentID, { + // An experiment that collected data, went cold, and had its claims + // expire -- the salt is retained permanently, warmUntil is not. + collisionCache: { + salt: "z8-retained-salt", + warmUntil: Timestamp.fromMillis(Date.now() - 60 * 60 * 1000), + }, + }); + // The file exists on the provider but has no claim in Firestore, which + // is exactly the state rehydration exists to recover from. + mockZenodo.seedFile(filename, "an earlier session"); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ ...MESSAGES.OSF_FILE_EXISTS, metadataMessage: "" }); + // Never overwritten: the earlier session's bytes are still there. + expect(mockZenodo.getPutCount(filename)).toBe(0); + expect(mockZenodo.getContent(filename).toString("utf8")).toBe("an earlier session"); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + // Rehydration re-warms the cache and keeps the original salt (claims + // hashed under a new salt would never match the old ones). + expect(expDataAfter.collisionCache.salt).toBe("z8-retained-salt"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(Date.now()); + }); +}); diff --git a/functions/src/api-base64.ts b/functions/src/api-base64.ts index daa2770..91ca63b 100644 --- a/functions/src/api-base64.ts +++ b/functions/src/api-base64.ts @@ -11,6 +11,7 @@ import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment, claimNameFor } from "./providers/index.js"; import { WriteResult, ResolvedAuth } from "./providers/types.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; +import { isCompactionInFlight, COMPACTION_HOLD_REASON } from "./compaction-gate.js"; import { ExperimentData, UserData } from './interfaces'; export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { @@ -40,6 +41,15 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: return; } + // Finalization is permanent (docs/finalization-spec.md) -- see the matching + // comment in api-data.ts for why this is checked ahead of, and independent + // from, the ordinary activeBase64 flag. + if (exp_data.finalized) { + res.status(400).json(MESSAGES.EXPERIMENT_FINALIZED); + await writeLog(experimentID, "logError", MESSAGES.EXPERIMENT_FINALIZED); + return; + } + if (!exp_data.activeBase64) { res.status(400).json(MESSAGES.BASE64DATA_COLLECTION_NOT_ACTIVE); await writeLog(experimentID, "logError", MESSAGES.BASE64DATA_COLLECTION_NOT_ACTIVE); @@ -187,6 +197,35 @@ export const apiBase64 = onRequest({ cors: true, memory: "512MiB", concurrency: return; } } + // A compaction pass is rearranging this container right now. Holding this + // submission back is what guarantees the pass has room for the archive it is + // about to upload -- see compaction-gate.ts. The queue is the same durable + // buffer that absorbs provider outages, and compaction releases these + // entries as soon as its pass ends. + if (isCompactionInFlight(exp_data)) { + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename, data, + dataType: "base64", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, + // false, matching every other queue branch here: a base64 upload is a + // supplementary media file, not a session. + errorCode: 0, sessionIncremented: false, + failureReason: COMPACTION_HOLD_REASON, + // CONTENTION is precisely this case as types.ts defines it, and puts + // the entry on the 60-second fast tier. + providerErrorCode: "CONTENTION", + claimToken, + }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json(MESSAGES.OSF_UPLOAD_QUEUED); + return; + } catch { + res.status(500).json(MESSAGES.OSF_UPLOAD_EXCEPTION); + return; + } + } + let result: WriteResult; try { diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index 4a3edc2..c06e746 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -15,6 +15,7 @@ import { persistPending, cleanupPending } from "./persist-pending.js"; import { getProviderForExperiment, claimNameFor } from "./providers/index.js"; import { WriteResult, ResolvedAuth } from "./providers/types.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; +import { isCompactionInFlight, COMPACTION_HOLD_REASON } from "./compaction-gate.js"; import { ExperimentData, UserData, RequestBody } from './interfaces'; export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }, async (req, res) => { @@ -45,6 +46,19 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 return; } + // Finalization is permanent (docs/finalization-spec.md): once an experiment + // is finalized, every remaining file has been merged into one archive and + // the originals deleted. A session accepted after that would sit outside + // the archive and quietly make the record non-Psych-DS again, so this is + // checked ahead of (and independent from) the ordinary `active` flag -- + // finalizing does not require a researcher to also turn data collection + // off, and this message is the one that should surface either way. + if (exp_data.finalized) { + res.status(400).json(MESSAGES.EXPERIMENT_FINALIZED); + await writeLog(experimentID, "logError", MESSAGES.EXPERIMENT_FINALIZED); + return; + } + if (!exp_data.active) { res.status(400).json(MESSAGES.DATA_COLLECTION_NOT_ACTIVE); await writeLog(experimentID, "logError", MESSAGES.DATA_COLLECTION_NOT_ACTIVE); @@ -256,6 +270,36 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 } } + // A compaction pass is rearranging this container right now. DataPipe is its + // only writer, so holding this submission back is what guarantees the pass + // has room for the archive it is about to upload -- see compaction-gate.ts. + // The queue is the same durable buffer that absorbs provider outages, and + // compaction releases these entries as soon as its pass ends. + if (isCompactionInFlight(exp_data)) { + try { + await queueUpload({ + experimentID, owner: exp_data.owner, filename: uploadFilename, data, + dataType: "data", osfFilesLink: exp_data.osfFilesLink, + storageProvider: exp_data.storageProvider, providerContainer: exp_data.providerContainer, + errorCode: 0, sessionIncremented: true, + failureReason: COMPACTION_HOLD_REASON, + // CONTENTION is exactly this situation as types.ts defines it -- + // "another write to this same container is already in flight" -- and it + // puts the entry on the 60-second fast tier, so it drains promptly even + // if the explicit release is missed. + providerErrorCode: "CONTENTION", + claimToken, + }); + await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); + await cleanupPending(pendingPath); // queue-upload has its own copy + res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); + return; + } catch { + res.status(500).json({...MESSAGES.OSF_UPLOAD_EXCEPTION, metadataMessage}); + return; + } + } + let result: WriteResult; try { result = await provider.writeSessionFile( diff --git a/functions/src/api-finalize.ts b/functions/src/api-finalize.ts new file mode 100644 index 0000000..8fa43c5 --- /dev/null +++ b/functions/src/api-finalize.ts @@ -0,0 +1,234 @@ +// Phase 4 of docs/finalization-spec.md: the surface that lets a researcher +// actually trigger finalizeExperiment (finalization.ts). +// +// WHY THIS IS TWO FUNCTIONS, NOT ONE, AND WHY THAT IS NOT NEGOTIABLE: +// every DataPipe endpoint is reached through a Firebase Hosting rewrite (see +// firebase.json), and hosting rewrites to Cloud Functions have a HARD 60 +// SECOND TIMEOUT regardless of what the function itself is configured for. +// finalizeExperiment routinely runs longer than that -- it streams a merged +// archive for an entire study through Cloud Storage and back out to the +// provider. Running it inline here would mean the hosting layer 504s the +// researcher while the merge keeps running unseen, which is the worst +// possible UX for an operation that permanently deletes the loose files it +// consumes. +// +// So apiFinalize does only cheap, synchronous work -- verify the caller, +// verify ownership, run pre-checks that need no network I/O -- and then +// enqueues a Cloud Task and returns 202 immediately, well inside the 60s +// budget. finalizeTask (onTaskDispatched) is a separate function, invoked by +// the Cloud Tasks service itself rather than through hosting, so it is free +// to run long. Its own ceiling is lower than the 3600s an onRequest function +// could in principle have: task-queue functions cap at 1800s (30 minutes) -- +// see firebase-functions's TaskQueueOptions.timeoutSeconds doc comment. That +// is still far more than any hosting rewrite tolerates, which is the only +// property this split actually needs. +// +// Returning 202 and continuing the work in THIS invocation (skip the task +// entirely) was considered and rejected: Cloud Functions throttles CPU after +// the response is sent and may kill the instance shortly after, so anything +// still running past that point is not guaranteed to finish -- and an +// interrupted finalization pass is exactly what finalization.ts's crash-safe +// resume logic exists to make survivable, not something to invite casually. +// +// PROGRESS REPORTING: finalizeExperiment itself is silent about progress -- +// it just returns a FinalizationResult when it's done. The dashboard needs +// something to poll before then, so this module owns writing +// experiments/{id}.finalization (FinalizationState, interfaces.ts) around the +// call: "queued" the instant the task is handed to Cloud Tasks, "running" the +// instant the task starts executing, then one of FinalizationResult's own +// status values once it resolves. firestore.rules blocks a client from ever +// writing this field, so its presence is a genuine signal, not something a +// researcher could forge to make the dashboard show a finalized state that +// never happened. +import { onRequest } from "firebase-functions/v2/https"; +import { onTaskDispatched } from "firebase-functions/v2/tasks"; +import { Timestamp, FieldValue } from "firebase-admin/firestore"; +import { db, auth, functions } from "./app.js"; +import { finalizeExperiment } from "./finalization.js"; +import { ExperimentData, FinalizationState } from "./interfaces.js"; + +// Task-queue functions cap at 1800s (see module header). finalizeExperiment +// streams rather than buffers (docs/finalization-spec.md's "why streaming, +// not splitting"), so its running time scales with provider round trips, not +// with memory -- 1800s is the most headroom this surface can give it. +const TASK_TIMEOUT_SECONDS = 1800; + +function experimentRef(experimentID: string) { + return db.collection("experiments").doc(experimentID); +} + +function isInFlight(status: FinalizationState["status"] | undefined): boolean { + return status === "queued" || status === "running"; +} + +export const apiFinalize = onRequest({ cors: true }, async (req, res) => { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + // Same Bearer/verifyIdToken shape as api-queue-status.ts, per + // docs/finalization-spec.md's Phase 4 instructions. + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + let uid: string; + try { + const idToken = authHeader.split("Bearer ")[1]; + const decodedToken = await auth.verifyIdToken(idToken); + uid = decodedToken.uid; + } catch { + res.status(401).json({ error: "Invalid authentication token" }); + return; + } + + const experimentID = req.body?.experimentID as string | undefined; + if (!experimentID) { + res.status(400).json({ error: "experimentID is required" }); + return; + } + + const expRef = experimentRef(experimentID); + const expSnap = await expRef.get(); + // Same 403-for-both convention as api-queue-status.ts: a nonexistent + // experiment and someone else's experiment get the identical response, so + // this endpoint never confirms which experiment IDs exist to a caller who + // does not already own one. + if (!expSnap.exists || expSnap.data()?.owner !== uid) { + res.status(403).json({ error: "Access denied" }); + return; + } + const expData = expSnap.data() as ExperimentData; + + // Permanent, and the one pre-check worth short-circuiting on even though + // finalizeExperiment would also catch it -- a second click after + // finalization already landed must not enqueue a task (there is nothing + // left for it to do) or overwrite the finalization record that already + // describes what happened. + if (expData.finalized === true) { + res.status(200).json({ status: "already-finalized" }); + return; + } + + // The other pre-check cheap enough to run here: it reads fields already in + // hand, no I/O. Everything else finalizeExperiment refuses on (provider + // capabilities, queued uploads, the compaction lease) needs a provider + // lookup or a Firestore query beyond what's already been read, so it is + // deliberately left to finalizeTask -- duplicating that logic here would + // just be a second, divergent copy of finalization.ts's own eligibility + // rules. + if (!expData.storageProvider || !expData.providerContainer) { + res.status(400).json({ + status: "not-eligible", + detail: "legacy experiment with no provider container", + }); + return; + } + + // Idempotent against a duplicate click (a second tab, a double-tap on a + // slow connection): if a pass is already in flight, report that instead of + // enqueueing a second task. A second concurrent finalizeExperiment call + // would just bounce off the compaction lease as "leased-elsewhere," which + // is a confusing thing to surface for what the researcher experienced as + // clicking one button once. + const inFlightStatus = expData.finalization?.status; + if (isInFlight(inFlightStatus)) { + res.status(202).json({ status: inFlightStatus }); + return; + } + + const now = Timestamp.now(); + await expRef.update({ + "finalization.status": "queued", + "finalization.startedAt": now, + "finalization.finishedAt": FieldValue.delete(), + "finalization.detail": FieldValue.delete(), + }); + + try { + await functions.taskQueue<{ experimentID: string }>("finalizetask").enqueue({ experimentID }); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + await expRef + .update({ + "finalization.status": "failed", + "finalization.finishedAt": Timestamp.now(), + "finalization.detail": `failed to enqueue finalization task: ${detail}`, + }) + .catch(() => undefined); + res.status(500).json({ error: "Failed to enqueue finalization" }); + return; + } + + res.status(202).json({ status: "queued" }); +}); + +export const finalizeTask = onTaskDispatched<{ experimentID: string }>( + { + timeoutSeconds: TASK_TIMEOUT_SECONDS, + memory: "1GiB", + retryConfig: { maxAttempts: 3 }, + rateLimits: { maxConcurrentDispatches: 5 }, + }, + async (request) => { + const { experimentID } = request.data; + if (!experimentID) { + // Malformed payload -- nothing to retry into, and nothing to do. + console.error("finalizeTask: dispatched with no experimentID"); + return; + } + + const expRef = experimentRef(experimentID); + const expSnap = await expRef.get(); + if (!expSnap.exists) { + // The experiment was deleted between enqueue and dispatch (or this is + // a stale Cloud Tasks retry of a task whose experiment is long gone). + // There is no document left to write progress onto. + console.error(`finalizeTask: experiment ${experimentID} no longer exists`); + return; + } + + await expRef + .update({ + "finalization.status": "running", + "finalization.finishedAt": FieldValue.delete(), + "finalization.detail": FieldValue.delete(), + }) + .catch((e) => console.error(`finalizeTask: failed to record running state for ${experimentID}`, e)); + + let result; + try { + result = await finalizeExperiment(experimentID); + } catch (e) { + // finalizeExperiment already catches everything it can attribute to a + // specific step and returns a "failed" FinalizationResult instead of + // throwing (see its own top-level try/catch); reaching here means + // something failed OUTSIDE that -- e.g. this very write above. Recorded + // as "failed" and NOT rethrown: see the module header on why this task + // never asks Cloud Tasks to retry a business-logic outcome. A genuine + // crash (the process dying mid-await) never reaches this catch at all, + // so Cloud Tasks' own retry still covers that case via retryConfig. + const detail = e instanceof Error ? e.message : String(e); + await expRef + .update({ + "finalization.status": "failed", + "finalization.finishedAt": Timestamp.now(), + "finalization.detail": detail, + }) + .catch((writeErr) => console.error(`finalizeTask: failed to record failure for ${experimentID}`, writeErr)); + return; + } + + const patch: Record<string, unknown> = { + "finalization.status": result.status, + "finalization.finishedAt": Timestamp.now(), + }; + patch["finalization.detail"] = result.detail ?? FieldValue.delete(); + await expRef + .update(patch) + .catch((e) => console.error(`finalizeTask: failed to record terminal state for ${experimentID}`, e)); + } +); diff --git a/functions/src/api-messages.ts b/functions/src/api-messages.ts index 4bf818d..b2daa8e 100644 --- a/functions/src/api-messages.ts +++ b/functions/src/api-messages.ts @@ -11,6 +11,10 @@ const MESSAGES = { error: "BASE64DATA_COLLECTION_NOT_ACTIVE", message: "Base64 data collection is not active for this experiment", }, + EXPERIMENT_FINALIZED: { + error: "EXPERIMENT_FINALIZED", + message: "This experiment has been finalized and no longer accepts submissions", + }, CONDITION_ASSIGNMENT_NOT_ACTIVE: { error: "CONDITION_ASSIGNMENT_NOT_ACTIVE", message: "Condition assignment is not active for this experiment", diff --git a/functions/src/app.ts b/functions/src/app.ts index 00ce8bd..96bf059 100644 --- a/functions/src/app.ts +++ b/functions/src/app.ts @@ -2,10 +2,15 @@ import { initializeApp } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; import { getAuth } from "firebase-admin/auth"; import { getStorage } from "firebase-admin/storage"; +import { getFunctions } from "firebase-admin/functions"; const app = initializeApp(); const db = getFirestore(app); const auth = getAuth(app); const storage = getStorage(app); +// Used by api-finalize.ts to enqueue the finalizeTask Cloud Task. Respects +// CLOUD_TASKS_EMULATOR_HOST the same way the other services above respect +// their own *_EMULATOR_HOST vars, so this needs no test-only branching. +const functions = getFunctions(app); -export { db, auth, storage }; +export { db, auth, storage, functions }; diff --git a/functions/src/archive-reader.ts b/functions/src/archive-reader.ts new file mode 100644 index 0000000..66c85ce --- /dev/null +++ b/functions/src/archive-reader.ts @@ -0,0 +1,201 @@ +// Reads a zip built by buildArchive (compaction.ts) back into its member +// paths and bytes -- the inverse operation, and Phase 3's way of re-emitting a +// batch archive's contents into a merged finalization archive without ever +// materializing the intermediate batch on disk unzipped. +// +// WHY THE CENTRAL DIRECTORY, NOT THE LOCAL FILE HEADERS: archiver streams its +// output rather than seeking back to patch headers once a member's true size +// is known, so it sets the "data descriptor follows" bit in the general +// purpose flag and writes zeroed crc32/compressed/uncompressed sizes into +// each local file header; the real values only show up in a data descriptor +// trailer after the member's bytes, and in the central directory at the end +// of the archive. Reading local headers naively -- as if this were a +// non-streamed zip -- would see size 0 for every member and either read +// nothing or misalign into the next entry. The central directory carries true +// sizes unconditionally, so it is the only part of the format this can trust. +// (buildArchive's own zlib level 9 does not change any of this: the streaming +// bit is about not knowing sizes in advance, independent of compression.) + +import { inflateRawSync, crc32 } from "zlib"; + +// zlib.crc32 was added in Node 22.2.0 (verified against the installed +// runtime, not assumed). Firebase Functions here run Node 22 +// (functions/package.json engines.node), so it's available, but confirming +// this on module load rather than hoping is cheap and turns a silent +// "content mismatches every archive" failure on an older runtime into a +// startup error that says what's actually wrong. +if (typeof crc32 !== "function") { + throw new Error( + "archive-reader.ts requires zlib.crc32 (Node >= 22.2.0) to verify member integrity before " + + "callers delete the originals; the running Node version does not provide it" + ); +} + +const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; +const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; +const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; + +// End-of-central-directory record is fixed-size (22 bytes) plus a variable +// comment; scanning stops as soon as one is found, so a comment that happens +// to contain the signature bytes could in principle confuse this, but that is +// the same limitation every zip reader has and buildArchive never sets a +// comment. +const EOCD_MIN_SIZE = 22; + +const COMPRESSION_STORED = 0; +const COMPRESSION_DEFLATE = 8; + +/** + * Reads a byte range, throwing rather than returning a truncated/garbage + * slice when the archive is too short to hold it. Buffer's own readUInt* + * methods already throw out-of-range, but subarray does not, so anything read + * from a computed offset needs this instead of a bare subarray call to avoid + * silently returning a shorter-than-expected (or empty) result. + */ +function requireBytes(zip: Buffer, start: number, end: number, what: string): Buffer { + if (start < 0 || end > zip.length || end < start) { + throw new Error( + `truncated archive: cannot read ${what} (bytes ${start}-${end}, archive is ${zip.length} bytes)` + ); + } + return zip.subarray(start, end); +} + +/** + * Locates the end-of-central-directory record by scanning backward from the + * end of the buffer. Backward, not forward, because the only anchor a zip + * offers is this record's fixed position relative to EOF -- everything else + * is only reachable by first finding this. + */ +function findEndOfCentralDirectory(zip: Buffer): number { + if (zip.length < EOCD_MIN_SIZE) { + throw new Error( + `truncated archive: ${zip.length} bytes is smaller than the minimum end-of-central-directory record (${EOCD_MIN_SIZE} bytes)` + ); + } + for (let i = zip.length - EOCD_MIN_SIZE; i >= 0; i -= 1) { + if (zip.readUInt32LE(i) === END_OF_CENTRAL_DIRECTORY_SIGNATURE) { + return i; + } + } + throw new Error("no end-of-central-directory record found: not a zip file, or the archive is corrupt"); +} + +/** + * Parses a zip's central directory into a map of archive path to file + * content. Local file headers are never consulted for sizes (see the module + * comment); the local header is only used to find where a member's raw bytes + * begin, which central-directory entries do carry as a byte offset but do not + * carry the length of the local header preceding the data itself. + */ +export function readArchive(zip: Buffer): Map<string, Buffer> { + const eocd = findEndOfCentralDirectory(zip); + + const entryCount = zip.readUInt16LE(eocd + 10); + const centralDirectoryOffset = zip.readUInt32LE(eocd + 16); + + const entries = new Map<string, Buffer>(); + let offset = centralDirectoryOffset; + + for (let i = 0; i < entryCount; i += 1) { + if (offset + 46 > zip.length) { + throw new Error( + `truncated archive: central directory entry ${i} of ${entryCount} starts at byte ${offset} but only ${zip.length} bytes are present` + ); + } + + const signature = zip.readUInt32LE(offset); + if (signature !== CENTRAL_DIRECTORY_SIGNATURE) { + throw new Error( + `corrupt central directory: entry ${i} of ${entryCount} at byte ${offset} has signature ` + + `0x${signature.toString(16).padStart(8, "0")}, expected 0x${CENTRAL_DIRECTORY_SIGNATURE.toString(16)}` + ); + } + + const method = zip.readUInt16LE(offset + 10); + const declaredCrc32 = zip.readUInt32LE(offset + 16); + const compressedSize = zip.readUInt32LE(offset + 20); + const declaredUncompressedSize = zip.readUInt32LE(offset + 24); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localHeaderOffset = zip.readUInt32LE(offset + 42); + + const nameBytes = requireBytes(zip, offset + 46, offset + 46 + nameLength, `entry ${i} filename`); + const name = nameBytes.toString("utf8"); + + // Jump to the local header purely to learn how long ITS name/extra fields + // are -- they are not required to match the central directory's (in + // practice they do for archiver's output, but nothing in the format + // guarantees it), so the local header has to be read to find where the + // member's raw bytes actually start. + if (localHeaderOffset + 30 > zip.length) { + throw new Error( + `truncated archive: local file header for "${name}" starts at byte ${localHeaderOffset} but only ${zip.length} bytes are present` + ); + } + const localSignature = zip.readUInt32LE(localHeaderOffset); + if (localSignature !== LOCAL_FILE_HEADER_SIGNATURE) { + throw new Error( + `corrupt archive: local file header for "${name}" at byte ${localHeaderOffset} has signature ` + + `0x${localSignature.toString(16).padStart(8, "0")}, expected 0x${LOCAL_FILE_HEADER_SIGNATURE.toString(16)}` + ); + } + const localNameLength = zip.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = zip.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength; + const raw = requireBytes(zip, dataStart, dataStart + compressedSize, `data for "${name}"`); + + let content: Buffer; + if (method === COMPRESSION_STORED) { + // Copy rather than alias: raw is a subarray view into zip, and handing + // that out would let a caller's mutation of the returned Buffer corrupt + // the archive buffer underneath other entries still to be read. + content = Buffer.from(raw); + } else if (method === COMPRESSION_DEFLATE) { + try { + content = inflateRawSync(raw); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`failed to inflate "${name}" (deflate, method 8): ${reason}`); + } + } else { + throw new Error( + `unsupported compression method ${method} for entry "${name}": only 0 (stored) and 8 (deflate) are supported` + ); + } + + // Verify BEFORE handing content back, not after: the caller this is built + // for (Phase 3's finalization pass) re-emits this content into a merged + // archive and then deletes the archive being read here, on the strength + // of the MERGED archive's checksum alone. Nothing re-reads a batch + // archive's own bytes to double-check them, so this is the only gate that + // ever compares decoded content back against what the archive itself + // claims. Size first because it's free (already have both numbers) and + // catches gross truncation without spending a crc32 pass on data already + // known to be wrong; crc32 catches the case size alone can't -- same + // length, wrong bytes, which is exactly what a corrupted STORED entry + // looks like, since STORED has no decode step of its own to fail on. + if (content.length !== declaredUncompressedSize) { + throw new Error( + `size mismatch for "${name}": central directory declares ${declaredUncompressedSize} ` + + `uncompressed bytes but decoding produced ${content.length} -- the archive is corrupt ` + + `or was truncated after it was written` + ); + } + const actualCrc32 = crc32(content); + if (actualCrc32 !== declaredCrc32) { + throw new Error( + `CRC-32 mismatch for "${name}": central directory declares ` + + `0x${declaredCrc32.toString(16).padStart(8, "0")} but the decoded content hashes to ` + + `0x${actualCrc32.toString(16).padStart(8, "0")} -- the archive is corrupt` + ); + } + + entries.set(name, content); + + offset += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} diff --git a/functions/src/collision-cache.ts b/functions/src/collision-cache.ts index b9d999f..36bddd0 100644 --- a/functions/src/collision-cache.ts +++ b/functions/src/collision-cache.ts @@ -275,6 +275,104 @@ export async function confirmClaim( } } +// Reads the per-experiment salt without creating one. Compaction needs the +// hash of names it already knows about, and an experiment that has never had +// a claim made against it has nothing to seal — so returning null here is a +// normal outcome, not an error, and must not lazily mint a salt the way +// ensureSalt does. +export async function getSalt(experimentID: string): Promise<string | null> { + const snap = await experimentRef(experimentID).get(); + return (snap.data()?.collisionCache?.salt as string | undefined) ?? null; +} + +// The claim document ID for a stored filename. Exported so compaction can +// address claims for files it only knows by name from a provider listing, +// without the raw name ever being written to Firestore (see the header note). +export function claimDocId(salt: string, storedName: string): string { + return hashFilename(salt, storedName); +} + +// Marks claims as belonging to files that now live inside a compaction archive +// rather than as loose files on the provider, and REMOVES their expiresAt so +// Firestore's TTL leaves them alone. +// +// Dropping the TTL is the whole point, and without it compaction quietly +// breaks duplicate detection. A confirmed claim normally expires after +// CLAIM_TTL_MS and that is safe, because a cold cache rehydrates from the +// provider's own listing and re-learns every name. An archived file is no +// longer in that listing — it is a member of a zip — so once its claim +// expires nothing can bring it back, and a resubmission of a filename already +// collected months ago would be accepted as new. These claims therefore have +// to outlive the cache that would otherwise reconstruct them. +// +// Idempotent: re-sealing an already-sealed claim is a no-op write, which +// matters because compaction re-runs this when resuming an interrupted pass. +// A claim that is missing entirely is created as sealed rather than skipped — +// the file provably exists on the provider (compaction just read it out of a +// listing), so the safe state is "claimed", and a claim whose TTL already +// lapsed is exactly the case that would otherwise stay lost. +// +// Takes hashes rather than names on purpose. Compaction records a batch's +// membership BEFORE uploading its archive so an interrupted pass can be +// resumed, and that record lives in Firestore — so it has to be hashes, or +// the header note above ("the raw filename is never stored anywhere") would +// stop being true. Both callers already hold hashes: the first pass derives +// them from the provider listing via claimDocId, and the resume path reads +// them straight off the batch document. +export async function sealClaimHashes(experimentID: string, hashes: string[]): Promise<void> { + const claims = claimsCollection(experimentID); + const now = Timestamp.now(); + + for (let i = 0; i < hashes.length; i += 500) { + const chunk = hashes.slice(i, i + 500); + const batch = db.batch(); + for (const hash of chunk) { + batch.set( + claims.doc(hash), + { + status: "confirmed", + sealed: true, + sealedAt: now, + ownerToken: "compaction", + expiresAt: FieldValue.delete(), + }, + { merge: true } + ); + } + await batch.commit(); + } +} + +// Which of these stored names are already inside an archive. Compaction calls +// this before selecting a batch so an interrupted pass — archive uploaded, +// originals not yet all deleted — never seals the same session twice into two +// different zips. +export async function readSealedNames( + experimentID: string, + salt: string, + storedNames: string[] +): Promise<Set<string>> { + const sealed = new Set<string>(); + const claims = claimsCollection(experimentID); + + // getAll caps at 500 documents per call, well above a single provider + // listing, but chunked anyway so this cannot become a latent limit. + for (let i = 0; i < storedNames.length; i += 300) { + const chunk = storedNames.slice(i, i + 300); + if (chunk.length === 0) { + continue; + } + const snaps = await db.getAll(...chunk.map((name) => claims.doc(claimDocId(salt, name)))); + snaps.forEach((snap, index) => { + if (snap.exists && snap.data()?.sealed === true) { + sealed.add(chunk[index]); + } + }); + } + + return sealed; +} + // Deletes a claim only if it is still pending and owned by the given token — // a no-op with the wrong token or for a confirmed claim. export async function releaseClaim( diff --git a/functions/src/compaction-gate.ts b/functions/src/compaction-gate.ts new file mode 100644 index 0000000..c2c379f --- /dev/null +++ b/functions/src/compaction-gate.ts @@ -0,0 +1,53 @@ +// The write-side half of archive compaction (compaction.ts). +// +// While a compaction pass is in flight, submissions are diverted into the +// upload queue instead of being written to the provider. DataPipe is the only +// writer to these containers, so holding writes for the duration of a pass +// means the file count CANNOT grow while the pass is running -- which is what +// guarantees there is room for the archive it is about to upload. +// +// This is deliberately a separate module from compaction.ts, which imports +// archiver and every provider adapter. api-data.ts is the hottest path in the +// codebase and pays that import cost on every cold start; this file has no +// dependencies beyond a type. +// +// The diverted submission is not delayed by much and is never at risk: the +// queue writes its payload to Cloud Storage first, the participant gets a 202 +// immediately, and compaction releases the entries the moment its pass ends +// (see releaseHeldUploads). It is the same durable buffer that already absorbs +// provider outages. +// +// RESIDUAL RACE, stated rather than hidden: callers test the experiment +// document they already loaded for their own reasons, so a pass that starts +// between that read and the provider write is not seen. Re-reading would close +// it at the cost of a Firestore read on every submission, which is not worth +// it -- the window shrinks from "the whole pass" to "one request", and +// compaction can still recover if a record does fill (see the saturation +// handling in compaction.ts). + +import type { ExperimentData } from "./interfaces.js"; + +/** + * True while a compaction pass holds the lease on this experiment. + * + * Takes an already-loaded experiment document rather than an ID, precisely so + * that gating a write costs nothing: every caller has one in hand. + */ +export function isCompactionInFlight(expData: Pick<ExperimentData, "compaction">): boolean { + const until = expData.compaction?.compactingUntil; + // The typeof guard is not defensive padding: this runs on the participant + // submission path, and a `compactingUntil` that is anything other than a + // Timestamp -- a malformed document, a hand-edit, a future writer that + // stores a number -- would throw here and turn every submission to that + // experiment into a 500. Treating an unreadable lease as "not held" fails + // toward accepting data, which is the right direction: the worst case is a + // write landing during a pass, which compaction already tolerates. + if (!until || typeof (until as { toMillis?: unknown }).toMillis !== "function") { + return false; + } + return until.toMillis() > Date.now(); +} + +// Reason recorded on entries held back by the gate, and the marker compaction +// looks for when releasing them afterwards. +export const COMPACTION_HOLD_REASON = "Compaction in progress"; diff --git a/functions/src/compaction-triggers.ts b/functions/src/compaction-triggers.ts new file mode 100644 index 0000000..181ed9e --- /dev/null +++ b/functions/src/compaction-triggers.ts @@ -0,0 +1,196 @@ +// Event-driven discovery for archive compaction (compaction.ts). +// +// THERE IS NO SCHEDULED SWEEP, and that is the design rather than an omission. +// DataPipe is the only writer to these containers in normal operation, so it +// already knows the moment one has grown and never has to ask on a timer. A +// cron would poll idle experiments forever and still react up to a full +// interval late to the one case that matters -- a burst, which can fill a +// record in under a minute. +// +// Every path that can fill a record terminates in a Firestore write we already +// make: +// +// normal submission -> `sessions` increments on experiments/{id} +// retry backlog draining -> uploadQueue/{id} moves to completed +// record already full -> uploadQueue/{id} written with QUOTA_EXCEEDED +// +// The one thing that produces no event is a researcher uploading to the +// provider by hand mid-study. That is documented as unsupported (see the FAQ) +// rather than engineered around -- it also desynchronizes the collision cache, +// so a background sweep would not make it safe, only later-detected. Even +// then it is not silent: the next submission that finds the record full writes +// a QUOTA_EXCEEDED queue entry, which is the third row above. +// +// Firestore triggers are at-least-once with retries for up to 7 days, so +// delivery is durable. Duplicate delivery is harmless: compaction takes a +// lease and a second invocation returns "leased-elsewhere". + +import { onDocumentUpdated, onDocumentWritten } from "firebase-functions/v2/firestore"; +import { getProvider } from "./providers/index.js"; +import { StorageProviderId } from "./providers/types.js"; +import { compactExperiment, WATERMARK_RATIO } from "./compaction.js"; + +// Runtime shared by both triggers: a pass holds one batch in memory at once, +// bounded by compaction.ts's MAX_BATCH_BYTES plus the assembled zip. +const RUNTIME = { memory: "1GiB" as const, timeoutSeconds: 540 }; + +// Deliberately high. It is the per-submission file count the watermark +// estimate assumes, used only to decide whether examining the record is worth +// a listing, and over-estimating means looking too EARLY -- which costs one +// cheap listing. Under-estimating means looking too late, which is how a +// record fills. A metadataActive submission writes a raw file, a main CSV and +// one sidecar per extracted column, so there is no true upper bound to derive. +const ASSUMED_FILES_PER_SUBMISSION = 10; + +/** + * Whether an experiment could plausibly have crossed the compaction watermark, + * judged without touching the provider. + * + * This exists to keep a burst from turning into one provider listing per + * submission. It is deliberately pessimistic: it estimates the file count + * high, so it errs toward looking when it need not, never toward skipping when + * it should look. + * + * The estimate cannot be derived from `lastFileCount / sessionsAtLastCheck` -- + * that ratio collapses after a pass, when the file count has been reset to + * near zero while `sessions` keeps climbing. Hence the flat assumption above. + */ +export function mayHaveCrossedWatermark( + data: FirebaseFirestore.DocumentData, + cap: number +): boolean { + const lastFileCount = data.compaction?.lastFileCount as number | undefined; + const sessionsAtLastCheck = data.compaction?.sessionsAtLastCheck as number | undefined; + + // Never examined: nothing is known, so look rather than infer health from an + // absent record. + if (lastFileCount === undefined || sessionsAtLastCheck === undefined) { + return true; + } + + const growth = Math.max(0, (data.sessions ?? 0) - sessionsAtLastCheck); + const estimate = lastFileCount + growth * ASSUMED_FILES_PER_SUBMISSION; + return estimate >= Math.floor(cap * WATERMARK_RATIO); +} + +// The provider's cap, or null when this experiment is not eligible for +// compaction at all. Reads only the document already in the event payload -- +// no Firestore reads -- so an ineligible experiment costs nothing. +function capFor(data: FirebaseFirestore.DocumentData | undefined): number | null { + const provider = data?.storageProvider as StorageProviderId | undefined; + if (!provider) { + return null; + } + try { + return getProvider(provider).capabilities.maxFileCount; + } catch { + return null; + } +} + +function leaseHeld(data: FirebaseFirestore.DocumentData | undefined): boolean { + const until = data?.compaction?.compactingUntil as FirebaseFirestore.Timestamp | undefined; + return !!until && until.toMillis() > Date.now(); +} + +/** + * Fires on every experiment document update, which means every submission. + * + * Everything before the compactExperiment call is decided from the event + * payload alone, so a submission to a non-capped provider -- the overwhelming + * majority -- costs one invocation that returns without a single read. + */ +export const onExperimentGrew = onDocumentUpdated( + { document: "experiments/{experimentID}", ...RUNTIME }, + async (event) => { + const before = event.data?.before.data(); + const after = event.data?.after.data(); + if (!after) { + return; + } + + // Compaction writes compaction.* on the same document and never touches + // `sessions`, so this is also what stops a pass from re-triggering itself. + if ((before?.sessions ?? 0) === (after.sessions ?? 0)) { + return; + } + + const cap = capFor(after); + if (cap === null) { + return; + } + if (leaseHeld(after)) { + return; + } + if (!mayHaveCrossedWatermark(after, cap)) { + return; + } + + const result = await compactExperiment(event.params.experimentID); + logResult(result); + } +); + +/** + * Fires on upload-queue writes, covering the two cases `sessions` cannot see. + * + * QUOTA_EXCEEDED means the provider has already refused a write for lack of + * room — the most urgent signal there is, and the one that makes a researcher's + * hand-uploaded files eventually visible to us despite producing no event of + * their own. + * + * A completed entry means the retry worker just landed a file WITHOUT + * `sessions` moving, because that submission incremented it when it first + * arrived and failed. A draining backlog is otherwise invisible. + */ +export const onUploadQueueChanged = onDocumentWritten( + { document: "uploadQueue/{docId}", ...RUNTIME }, + async (event) => { + const before = event.data?.before.data(); + const after = event.data?.after.data(); + if (!after) { + return; + } + + const blocked = after.status === "pending" && after.providerErrorCode === "QUOTA_EXCEEDED"; + const justLanded = after.status === "completed" && before?.status !== "completed"; + if (!blocked && !justLanded) { + return; + } + + const experimentID = after.experimentID as string | undefined; + if (!experimentID) { + return; + } + + // Unlike the experiment trigger there is no document in hand to pre-filter + // on, so eligibility is settled inside compactExperiment. A blocked entry + // is worth the read regardless: it is proof the record is already full. + const result = await compactExperiment(experimentID); + logResult(result); + } +); + +function logResult(result: ReturnType<typeof compactExperiment> extends Promise<infer R> ? R : never) { + if (result.status === "compacted") { + console.log( + `compaction: ${result.experimentID} sealed ${result.archived} file(s) into ${result.archiveName} ` + + `(${result.fileCountBefore} -> ${result.fileCountAfter} files)` + ); + if (result.recoveredFromSaturation) { + // Not a failure -- the pass succeeded by staging the archive over one of + // its own batch members. Worth seeing, because it means a burst outran + // the watermark and the headroom constants may need revisiting. + console.warn( + `compaction: ${result.experimentID} was at the file cap and recovered via a staged archive` + ); + } + if (result.undeleted) { + console.warn( + `compaction: ${result.experimentID} left ${result.undeleted} original(s) undeleted; they will be skipped next pass` + ); + } + } else if (result.status === "failed") { + console.error(`compaction: ${result.experimentID} failed: ${result.detail}`); + } +} diff --git a/functions/src/compaction.ts b/functions/src/compaction.ts new file mode 100644 index 0000000..6990b21 --- /dev/null +++ b/functions/src/compaction.ts @@ -0,0 +1,897 @@ +// Archive compaction for providers with a hard file-count cap +// (docs/provider-migration-design.md, "The 100-file cap is handled by +// end-of-study compaction, not rollover"). +// +// WHAT THIS DOES, AND THE TWO REASONS IT EXISTS +// +// 1. It keeps a study collecting. Zenodo allows 100 files per record and +// refuses the 101st, so without this a study simply stops at session ~100: +// the write maps to QUOTA_EXCEEDED, which is a slow-tier queue failure that +// no amount of retrying can clear. Sealing older sessions into one zip +// turns N files into 1 and returns the headroom. +// +// 2. It is where the Psych-DS directory structure lives. Zenodo's keyspace is +// flat -- a slash cannot be stored by any route (zenodo.ts's toZenodoKey +// documents the live evidence) -- so a metadataActive experiment's record +// shows `data_raw_subject-1.json` where the layout calls for +// `data/raw/subject-1.json`. Inside a zip the paths are ours to choose, so +// the archive carries the real tree even though the record cannot. +// +// THE ORDERING RULE THAT MATTERS MOST: upload the archive, verify the checksum +// the provider reports back, seal the claims, and only then delete the +// originals. Never the reverse. DataPipe keeps no copy of submitted data, so a +// delete that runs before a verified upload is unrecoverable loss. +// +// Everything here is crash-safe by construction rather than by luck: a batch's +// membership is recorded before its archive is uploaded, so an interrupted +// pass resumes instead of sealing the same sessions into a second zip. + +import archiver from "archiver"; +import { PSYCHDS_IGNORE_FILENAME, PSYCHDS_IGNORE_CONTENT } from "@jspsych/metadata"; +import { createHash } from "crypto"; +import { Timestamp, FieldValue } from "firebase-admin/firestore"; +import { db, storage } from "./app.js"; +import { getProvider } from "./providers/index.js"; +import { StorageProvider, ContainerRef, ResolvedAuth, FileRef } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; +import resolveToken from "./resolve-token.js"; +import { getSalt, claimDocId, sealClaimHashes, readSealedNames } from "./collision-cache.js"; + +// Compact once the record is this full. The gap to the cap is deliberate +// headroom: compaction takes tens of seconds during which submissions keep +// arriving, and a study that only compacted at 100/100 would already be +// rejecting writes before this ran. +export const WATERMARK_RATIO = 0.8; + +// Sessions left loose after a pass, so researchers can still open and +// spot-check recent data in the provider's own UI without downloading an +// archive. Purely a convenience -- correctness does not depend on it. +// +// Kept small deliberately. Every file held back is headroom given up, and +// headroom is what absorbs a burst: requirement 6 is 30-100 submissions inside +// a minute, and a metadataActive experiment writes several files per +// submission. Holding 20 loose left only ~74 files of room after a pass, which +// a single burst can exhaust. Five is enough to spot-check recent data, and +// the rest is available in the archives. +export const KEEP_LOOSE = 5; + +// Upper bounds on one batch. MAX_BATCH_FILES keeps an archive small enough to +// stay quick to build and download; MAX_BATCH_BYTES is the real guard, since +// the whole batch is held in memory at once (see buildArchive) and an +// /api/base64 experiment collecting video has files orders of magnitude bigger +// than a JSON session. Whichever binds first wins, and a batch that hits the +// byte budget early simply seals fewer files -- the next run takes the rest. +// Sized to reclaim as much of the cap as one pass can, for the same +// burst-headroom reason as KEEP_LOOSE above: a pass that leaves the record +// half full has bought much less time than one that empties it. +export const MAX_BATCH_FILES = 95; +export const MAX_BATCH_BYTES = 150 * 1024 * 1024; + +// A pass is a handful of provider round-trips per file, so this is generous; +// it exists to release the experiment if the function dies mid-pass, not to +// bound normal work. +const LEASE_MS = 10 * 60 * 1000; + +const ARCHIVE_PREFIX = "datapipe-batch-"; +const ARCHIVE_PATTERN = /^datapipe-batch-\d{4}\.zip$/; + +// Files that must never be swept into an archive. +// +// dataset_description.json is the record's own Psych-DS descriptor: it is what +// metadata-block.ts holds a metadataFileRef to and updates in place on every +// submission, and burying it inside a zip would both break that ref and hide +// the one file a visitor to the record should see first. +// +// .psychds-ignore is rewritten by metadata-derived-upload.ts on every +// submission, so archiving it would just churn -- it would reappear loose +// moments later. +const NEVER_ARCHIVE = new Set(["dataset_description.json", ".psychds-ignore"]); + +export function archiveNameForIndex(index: number): string { + return `${ARCHIVE_PREFIX}${String(index).padStart(4, "0")}.zip`; +} + +export function isArchiveName(name: string): boolean { + return ARCHIVE_PATTERN.test(name); +} + +export interface CompactionResult { + // Which experiment this describes. The trigger handlers log results without + // otherwise holding the id, so carrying it here keeps every log line and + // test assertion attributable. + experimentID: string; + status: + | "compacted" + | "below-watermark" + | "nothing-to-archive" + | "not-eligible" + | "leased-elsewhere" + // The record is at the cap AND has no reproducible file whose slot could + // be borrowed, so compaction cannot make room for itself. Needs one file + // removed by hand. See the saturation handling in runCompaction. + | "saturated" + | "failed"; + archived?: number; + archiveName?: string; + fileCountBefore?: number; + fileCountAfter?: number; + undeleted?: number; + // True when the record was already at the cap and a slot had to be borrowed + // to fit the archive in (see runCompaction). Not a failure — the pass + // succeeded — but it means a burst outran the watermark, which is worth + // seeing in logs. + recoveredFromSaturation?: boolean; + detail?: string; +} + +// -------------------------------------------------------------------------- +// Pure helpers (unit-testable without a provider or Firestore) +// -------------------------------------------------------------------------- + +/** + * Chooses which of a container's files go into the next archive. + * + * Order is the provider's listing order, which Zenodo returns in insertion + * order in practice but does not document as a guarantee. Nothing correctness- + * bearing rests on it: the only thing order decides is WHICH files stay loose + * for spot-checking, and every file is archived eventually either way. + */ +export function selectBatch( + files: FileRef[], + opts: { keepLoose?: number; maxFiles?: number; maxBytes?: number; alreadySealed?: Set<string> } = {} +): FileRef[] { + const keepLoose = opts.keepLoose ?? KEEP_LOOSE; + const maxFiles = opts.maxFiles ?? MAX_BATCH_FILES; + const maxBytes = opts.maxBytes ?? MAX_BATCH_BYTES; + const alreadySealed = opts.alreadySealed ?? new Set<string>(); + + const candidates = files.filter( + (file) => + !NEVER_ARCHIVE.has(file.name) && !isArchiveName(file.name) && !alreadySealed.has(file.name) + ); + + // Hold back the tail of the listing, not of the whole set: previously + // archived batches and the protected files are already excluded, so this + // keeps `keepLoose` real sessions loose rather than counting a zip as one. + const archivable = keepLoose > 0 ? candidates.slice(0, Math.max(0, candidates.length - keepLoose)) : candidates; + + const batch: FileRef[] = []; + let bytes = 0; + for (const file of archivable) { + if (batch.length >= maxFiles) { + break; + } + // An unknown size counts as zero rather than blocking the batch. The + // budget is a memory guard, and a provider that does not report sizes + // would otherwise be unable to compact at all; buildArchive is still + // bounded by maxFiles in that case. + const size = file.size ?? 0; + if (batch.length > 0 && bytes + size > maxBytes) { + break; + } + batch.push(file); + bytes += size; + } + + return batch; +} + +/** + * Maps each stored name to the path it takes inside the archive. + * + * The reconstruction is skipped entirely unless the experiment is + * metadataActive, because that is the only mode in which DataPipe writes + * slashed paths at all. Without that gate, a researcher's own file named + * `data_x.json` in a plain experiment would be "restored" into a `data/` + * folder that never existed. See StorageProvider.archivePathFor. + */ +export function archivePathsFor( + provider: StorageProvider, + metadataActive: boolean, + names: string[] +): Map<string, string> { + const paths = new Map<string, string>(); + const seen = new Set<string>(); + + for (const name of names) { + const path = metadataActive && provider.archivePathFor ? provider.archivePathFor(name) : name; + // Distinct keys cannot collide under any archivePathFor DataPipe ships + // (the Zenodo one only inserts separators at fixed prefixes), but a zip + // with two members at one path is silent corruption, so fall back to the + // flat name rather than trusting that property to hold for a future + // adapter. + if (seen.has(path)) { + paths.set(name, name); + continue; + } + seen.add(path); + paths.set(name, path); + } + + return paths; +} + +/** + * Builds the zip in memory and returns its bytes plus an md5 of exactly those + * bytes — the same value the provider is expected to report back, and the + * basis for deciding whether deleting the originals is safe. + * + * In-memory rather than streamed because the archive has to be uploaded with a + * Content-Length (Zenodo's bucket PUT) and its checksum has to be known before + * anything is deleted. MAX_BATCH_BYTES is what keeps that honest. + */ +export async function buildArchive( + entries: { path: string; content: Buffer }[] +): Promise<{ zip: Buffer; md5: string }> { + const archive = archiver("zip", { zlib: { level: 9 } }); + const chunks: Buffer[] = []; + + const collected = new Promise<Buffer>((resolve, reject) => { + archive.on("data", (chunk: Buffer) => chunks.push(chunk)); + archive.on("warning", reject); + archive.on("error", reject); + archive.on("end", () => resolve(Buffer.concat(chunks))); + }); + + for (const entry of entries) { + // A fixed date keeps the archive byte-reproducible for a given input, + // which is what lets a resumed pass re-derive the same md5 rather than + // having to trust a recorded one. + archive.append(entry.content, { name: entry.path, date: new Date(0) }); + } + await archive.finalize(); + + const zip = await collected; + return { zip, md5: createHash("md5").update(zip).digest("hex") }; +} + +/** + * buildArchive's streaming sibling, for finalization + * (docs/finalization-spec.md), which merges an entire study into ONE archive + * -- Psych-DS compatibility requires it -- with no size ceiling but the + * provider's own (Zenodo: 50 GB per file). buildArchive's in-memory Buffer + * would put that ceiling back at function memory, so this pipes archiver + * straight into Cloud Storage instead and never holds a built zip in memory + * at all. + * + * `entries` is consumed LAZILY: a `for await` loop pulls exactly one member + * at a time, so the caller's generator (which downloads each member just in + * time) never has more than one member's bytes resident here. This is the + * whole point of taking an AsyncIterable instead of an array. + * + * The md5 is computed from the bytes archiver actually emits, via a + * passthrough `data` listener alongside the pipe to Cloud Storage -- not by + * re-reading the uploaded object afterward -- so nothing has to be re-read to + * verify what was written. Entry dates are pinned the same way buildArchive + * pins them, which is what makes the two byte-identical for the same input. + */ +export async function buildArchiveToStorage( + entries: AsyncIterable<{ path: string; content: Buffer }>, + storagePath: string +): Promise<{ size: number; md5: string }> { + const archive = archiver("zip", { zlib: { level: 9 } }); + const writeStream = storage.bucket().file(storagePath).createWriteStream({ + contentType: "application/zip", + }); + + const hash = createHash("md5"); + let size = 0; + + // Settles once, however it settles -- the archive stream, the destination + // stream, and (below) an entries-iterable failure are three independent + // sources of "this pass is over", and only the first one should decide the + // outcome. A second event after that (e.g. the destination erroring after + // archive.abort() closes it) must not try to resolve/reject an already + // -settled promise. + let settled = false; + const done = new Promise<void>((resolve, reject) => { + const fail = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + archive.on("warning", fail); + archive.on("error", fail); + writeStream.on("error", fail); + writeStream.on("finish", () => { + if (settled) return; + settled = true; + resolve(); + }); + }); + // If the entries iterable throws (see the catch block below), `done` may + // never be awaited -- but the archive/writeStream error handlers above can + // still fire later during cleanup. Without this, that would surface as an + // unhandled rejection despite the real error already having been thrown. + done.catch(() => undefined); + + archive.on("data", (chunk: Buffer) => { + hash.update(chunk); + size += chunk.length; + }); + archive.pipe(writeStream); + + try { + for await (const entry of entries) { + // Same fixed date as buildArchive -- required for the two builders to + // produce byte-identical output for the same input. + archive.append(entry.content, { name: entry.path, date: new Date(0) }); + } + await archive.finalize(); + await done; + } catch (err) { + archive.abort(); + writeStream.destroy(err instanceof Error ? err : new Error(String(err))); + throw err; + } + + return { size, md5: hash.digest("hex") }; +} + +/** + * True when a provider-reported checksum matches a locally computed md5. + * + * Providers decorate the value (Zenodo reports "md5:<hex>"), and a provider + * that reports nothing at all yields `false` — callers must treat an + * unverifiable upload as unverified and keep the originals. + */ +export function checksumMatches(reported: string | undefined, expectedMd5: string): boolean { + if (!reported) { + return false; + } + const normalized = reported.trim().toLowerCase().replace(/^md5:/, ""); + return normalized === expectedMd5.toLowerCase(); +} + +// -------------------------------------------------------------------------- +// Orchestration +// -------------------------------------------------------------------------- + +function experimentRef(experimentID: string) { + return db.collection("experiments").doc(experimentID); +} + +function batchesCollection(experimentID: string) { + return experimentRef(experimentID).collection("compactionBatches"); +} + +// Leases the experiment to one compaction pass. Same shape as the collision +// cache's rehydration lease: a stale lease simply expires, so a crashed pass +// cannot wedge an experiment permanently. +// +// Exported so finalization.ts (docs/finalization-spec.md) can acquire and +// release the SAME lease field -- finalization is "structurally +// compactExperiment with a different selection rule" and reuses this lease +// rather than inventing a second one, which is also what makes the write gate +// (compaction-gate.ts) hold submissions for free during a finalization pass. +export async function acquireLease(experimentID: string): Promise<boolean> { + return db.runTransaction(async (tx) => { + const snap = await tx.get(experimentRef(experimentID)); + const until = snap.data()?.compaction?.compactingUntil as FirebaseFirestore.Timestamp | undefined; + if (until && until.toMillis() > Date.now()) { + return false; + } + tx.update(experimentRef(experimentID), { + "compaction.compactingUntil": Timestamp.fromMillis(Date.now() + LEASE_MS), + }); + return true; + }); +} + +/** + * Ends a pass, recording what it observed. + * + * `sessionsSeen` is what makes the scheduled worker's change trigger work: it + * compares the experiment's current `sessions` against this to decide whether + * anything could have been added since, instead of re-listing on a timer. It + * is deliberately the value read at the START of the pass — a submission that + * lands while compaction is running leaves the two unequal, so the next run + * looks again rather than assuming this pass saw it. + */ +export async function releaseLease( + experimentID: string, + sessionsSeen: number, + patch: Record<string, unknown> = {} +): Promise<void> { + await experimentRef(experimentID).update({ + "compaction.compactingUntil": FieldValue.delete(), + "compaction.lastCheckedAt": Timestamp.now(), + "compaction.sessionsAtLastCheck": sessionsSeen, + ...patch, + }); +} + +interface BatchRecord { + index: number; + archiveName: string; + status: "uploading" | "sealed"; + memberHashes: string[]; + expectedMd5: string; + fileCount: number; +} + +/** + * Compacts one experiment. Safe to call on anything — it self-selects and + * returns a status rather than throwing for the ordinary "not applicable" + * cases. + */ +export async function compactExperiment(experimentID: string): Promise<CompactionResult> { + // Stamped here rather than at each of runCompaction's dozen return points, + // so a new early return can never ship without it. + const result = { ...(await runCompaction(experimentID)), experimentID }; + + // Two sets of queue entries are waiting on this pass: submissions the + // provider refused for lack of room, and submissions the write gate held + // back while the pass ran (compaction-gate.ts). Releasing both here rather + // than in a caller means every entry point into compaction closes the loop. + // + // Done on any terminal outcome, not just "compacted": a pass that found + // nothing to do, or failed, has still released its lease, so anything held + // for it should stop waiting. + if (result.status !== "leased-elsewhere") { + await releaseHeldUploads(experimentID); + } + + return result; +} + +/** + * Makes this experiment's waiting queue entries retryable immediately. + * + * Two kinds are waiting, and both are waiting on something that has now + * happened. QUOTA_EXCEEDED entries were refused for lack of room and sit on + * the slow tier -- correct when nothing is coming to fix it, wrong the moment + * compaction has. Entries held by the write gate were never even attempted. + * + * Only nextRetryAt moves. retryCount, backoff state and the payload are + * untouched, so a submission failing for some other reason gains no extra + * attempts, and an entry that is genuinely mid-flight (`processing`) is not + * disturbed. + */ +async function releaseHeldUploads(experimentID: string): Promise<void> { + const waiting = await db + .collection("uploadQueue") + .where("experimentID", "==", experimentID) + .where("status", "==", "pending") + .where("providerErrorCode", "in", ["QUOTA_EXCEEDED", "CONTENTION"]) + .get(); + + if (waiting.empty) { + return; + } + + const batch = db.batch(); + const now = Timestamp.now(); + waiting.docs.forEach((doc) => batch.update(doc.ref, { nextRetryAt: now })); + await batch.commit(); + + console.log( + `compaction: released ${waiting.size} waiting upload(s) for ${experimentID} to the next retry pass` + ); +} + +async function runCompaction(experimentID: string): Promise<Omit<CompactionResult, "experimentID">> { + const expSnap = await experimentRef(experimentID).get(); + if (!expSnap.exists) { + return { status: "not-eligible", detail: "experiment does not exist" }; + } + const expData = expSnap.data() as ExperimentData; + + // Read once, at the start, and recorded by every releaseLease below. The + // scheduled worker compares this against the live `sessions` to decide + // whether anything could have been added since — see releaseLease. + const sessionsSeen = expData.sessions ?? 0; + + if (!expData.storageProvider || !expData.providerContainer) { + return { status: "not-eligible", detail: "legacy experiment with no provider container" }; + } + + let provider: StorageProvider; + try { + provider = getProvider(expData.storageProvider); + } catch { + return { status: "not-eligible", detail: `unknown provider ${expData.storageProvider}` }; + } + + const cap = provider.capabilities.maxFileCount; + if (cap === null) { + return { status: "not-eligible", detail: "provider has no file-count cap" }; + } + // Enforced rather than assumed: capabilities.maxFileCount is documented as a + // contract that these two exist, and a provider that breaks it would + // otherwise fail partway through a pass -- possibly after uploading an + // archive it then cannot clean up behind. + if (!provider.deleteFile || !provider.downloadFileBytes) { + return { + status: "not-eligible", + detail: `provider ${provider.id} declares maxFileCount but implements no deleteFile/downloadFileBytes`, + }; + } + + const salt = await getSalt(experimentID); + if (!salt) { + // No salt means no filename has ever been claimed, i.e. nothing has been + // submitted. Compacting would have nothing to seal, and sealing is what + // keeps duplicate detection working once files leave the listing. + return { status: "nothing-to-archive", detail: "experiment has no collision-cache salt" }; + } + + if (!(await acquireLease(experimentID))) { + return { status: "leased-elsewhere" }; + } + + try { + const userSnap = await db.collection("users").doc(expData.owner).get(); + if (!userSnap.exists) { + await releaseLease(experimentID, sessionsSeen); + return { status: "failed", detail: "owner record missing" }; + } + const tokenResult = await resolveToken(userSnap.data() as UserData, expData); + if (!tokenResult.success) { + await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": tokenResult.error }); + return { status: "failed", detail: `token resolution failed: ${tokenResult.error}` }; + } + const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; + const container = expData.providerContainer as ContainerRef; + + const files = await provider.listFiles(auth, container); + + // Resume before anything else: an interrupted pass left an archive that + // may already hold these sessions, and building a second one would put the + // same data in two places (and, once the first is deleted from, lose the + // link between them). + const resumed = await resumeInterruptedBatch(experimentID, provider, auth, container, files); + if (resumed) { + const after = await provider.listFiles(auth, container); + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastRunAt": Timestamp.now(), + "compaction.lastFileCount": after.length, + }); + return { + status: "compacted", + archived: resumed.archived, + archiveName: resumed.archiveName, + fileCountBefore: files.length, + fileCountAfter: after.length, + undeleted: resumed.undeleted, + detail: "resumed an interrupted pass", + }; + } + + if (files.length < Math.floor(cap * WATERMARK_RATIO)) { + // lastFileCount is recorded so the scheduled worker can pick a re-check + // interval from how close this experiment is to the cap, rather than + // waiting out a fixed cooldown while a fast-collecting study saturates. + await releaseLease(experimentID, sessionsSeen, { "compaction.lastFileCount": files.length }); + return { status: "below-watermark", fileCountBefore: files.length }; + } + + // The record is already at the cap, so there is no room for the archive, + // which is itself a new file. Handled further down, once a batch exists to + // justify borrowing a slot. + const saturated = files.length >= cap; + + const sealed = await readSealedNames( + experimentID, + salt, + files.map((file) => file.name) + ); + const batch = selectBatch(files, { alreadySealed: sealed }); + if (batch.length === 0) { + await releaseLease(experimentID, sessionsSeen, { "compaction.lastFileCount": files.length }); + return { status: "nothing-to-archive", fileCountBefore: files.length }; + } + + const index = await nextBatchIndex(experimentID); + const archiveName = archiveNameForIndex(index); + const paths = archivePathsFor(provider, expData.metadataActive === true, batch.map((f) => f.name)); + + const entries: { path: string; content: Buffer }[] = []; + for (const file of batch) { + const download = await provider.downloadFileBytes(auth, container, file); + if (!download.success) { + // Abort the whole batch rather than archive a subset: a partial + // archive that then had its (complete) member list deleted would lose + // whatever failed to download. + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastError": `download failed for one member: ${download.providerMessage ?? download.error}`, + }); + return { status: "failed", detail: `download failed: ${download.error}` }; + } + entries.push({ path: paths.get(file.name) ?? file.name, content: download.content }); + } + + const { zip, md5 } = await buildArchive(entries); + const memberHashes = batch.map((file) => claimDocId(salt, file.name)); + + // Recorded BEFORE the upload. This is the crash-safety hinge: if the + // function dies between here and the delete step, the next pass finds this + // document, matches its hashes against the live listing, and finishes the + // job instead of starting a duplicate one. + const batchRef = batchesCollection(experimentID).doc(String(index).padStart(4, "0")); + await batchRef.set({ + index, + archiveName, + status: "uploading", + memberHashes, + expectedMd5: md5, + fileCount: batch.length, + createdAt: Timestamp.now(), + }); + + // MAKING ROOM WHEN THE RECORD IS ALREADY FULL. + // + // The archive is itself a new file, so a record at the cap cannot accept + // it. Steady-state growth never gets here -- a pass leaves the record + // nearly empty -- but files arriving DURING a pass can, and requirement 6's + // burst (30-100 submissions inside a minute, several files each for a + // metadataActive experiment) exceeds the entire 100-file cap, so no + // watermark setting makes this unreachable. + // + // One slot is all that is needed, and .psychds-ignore is a slot that can be + // given up for free: its content is a fixed constant shared with the + // Psych-DS tooling, so deleting it loses nothing and restoring it is a PUT + // of a literal. It is put back once the pass completes; if the pass dies + // first, metadata-derived-upload.ts rewrites it on the next submission + // anyway, which is why this needs no resume state. + // + // An experiment without that file has nothing reproducible to give up, and + // overwriting a session instead would risk its only copy, so it stops and + // asks for a hand. It writes one file per submission, so it has far more + // headroom to begin with. + if (saturated) { + if (!files.some((file) => file.name === PSYCHDS_IGNORE_FILENAME)) { + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastFileCount": files.length, + "compaction.lastError": "record is at the provider file cap with no reproducible file to free", + }); + return { + status: "saturated", + fileCountBefore: files.length, + detail: + `record holds ${files.length} of ${cap} files and has no .psychds-ignore to free; ` + + `remove one file to let compaction proceed`, + }; + } + const freed = await provider.deleteFile(auth, container, { name: PSYCHDS_IGNORE_FILENAME }); + if (!freed.success) { + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastFileCount": files.length, + "compaction.lastError": `could not free a slot: ${freed.providerMessage ?? freed.error}`, + }); + return { status: "failed", detail: `could not free a slot for the archive: ${freed.error}` }; + } + } + + const uploaded = await putVerifiedArchive(provider, auth, container, archiveName, zip, md5); + if (!uploaded.ok) { + await batchRef.delete(); + if (saturated) { + await restoreIgnoreFile(provider, auth, container); + } + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastFileCount": files.length, + "compaction.lastError": uploaded.detail, + }); + return { status: "failed", detail: uploaded.detail }; + } + + // Everything in the batch is now inside a verified archive on the + // provider, so the originals can go. + const undeleted = await sealAndDelete( + experimentID, + provider, + auth, + container, + batchRef, + memberHashes, + batch + ); + + if (saturated) { + // Deleting the batch above returned far more room than the one slot that + // was borrowed, so this always fits now. + await restoreIgnoreFile(provider, auth, container); + } + + const after = await provider.listFiles(auth, container); + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastRunAt": Timestamp.now(), + "compaction.lastFileCount": after.length, + "compaction.lastError": FieldValue.delete(), + }); + + return { + status: "compacted", + archived: batch.length, + archiveName, + fileCountBefore: files.length, + fileCountAfter: after.length, + undeleted, + recoveredFromSaturation: saturated, + }; + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": detail }).catch(() => undefined); + return { status: "failed", detail }; + } +} + +/** + * Puts .psychds-ignore back after its slot was borrowed to fit an archive into + * a full record. + * + * Reproducing it needs nothing from the provider: the content is a constant + * shared with the Psych-DS tooling, which is the entire reason this is the + * file whose slot gets borrowed. metadata-derived-upload.ts would rewrite it + * on the next submission anyway; doing it here means the record is never left + * missing a file a validator expects. + */ +async function restoreIgnoreFile( + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef +): Promise<void> { + const result = await provider.writeSessionFile( + auth, + container, + PSYCHDS_IGNORE_FILENAME, + Buffer.from(PSYCHDS_IGNORE_CONTENT), + { size: Buffer.byteLength(PSYCHDS_IGNORE_CONTENT), contentType: "text/plain" } + ); + if (!result.success) { + // Not fatal, and deliberately not retried here: the next submission + // rewrites this file regardless. + console.warn(`compaction: could not restore ${PSYCHDS_IGNORE_FILENAME}: ${result.error}`); + } +} + +/** + * Uploads the archive under `name` and confirms the provider stored exactly + * the bytes we built. A mismatch removes the object rather than leaving + * something that could later be mistaken for a sealed batch. + * + * This is the gate every delete in this module depends on: DataPipe keeps no + * copy of submitted data, so an unverified archive must never authorize + * removing the sessions that went into it. + */ +async function putVerifiedArchive( + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + name: string, + zip: Buffer, + md5: string +): Promise<{ ok: true } | { ok: false; detail: string }> { + const write = await provider.writeSessionFile(auth, container, name, zip, { + size: zip.length, + contentType: "application/zip", + }); + if (!write.success) { + return { ok: false, detail: `archive upload failed: ${write.providerMessage ?? write.error}` }; + } + if (!checksumMatches(write.fileRef.checksum, md5)) { + await provider.deleteFile!(auth, container, { name }); + return { + ok: false, + detail: `archive checksum mismatch (reported ${write.fileRef.checksum ?? "nothing"})`, + }; + } + return { ok: true }; +} + +async function nextBatchIndex(experimentID: string): Promise<number> { + const existing = await batchesCollection(experimentID).get(); + let max = 0; + existing.forEach((doc) => { + const index = doc.data().index as number | undefined; + if (typeof index === "number" && index > max) { + max = index; + } + }); + return max + 1; +} + +/** + * Seals the batch's claims, then deletes the loose originals. + * + * The order is not interchangeable. Sealing first means that if the deletes + * are interrupted, the surviving files are already protected from a second + * archiving pass and their filenames are still recognised as taken. Deleting + * first would open a window where a file is gone from the provider and its + * claim can still expire, silently re-opening a filename that was collected + * months earlier. + * + * Returns how many originals could not be deleted. Those stay loose and are + * skipped (not re-archived) on the next pass, because their claims are sealed. + */ +async function sealAndDelete( + experimentID: string, + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + batchRef: FirebaseFirestore.DocumentReference, + memberHashes: string[], + members: FileRef[] +): Promise<number> { + await sealClaimHashes(experimentID, memberHashes); + await batchRef.update({ status: "sealed", sealedAt: Timestamp.now() }); + + let undeleted = 0; + for (const file of members) { + const result = await provider.deleteFile!(auth, container, file); + if (!result.success) { + undeleted += 1; + console.error( + `compaction: failed to delete ${experimentID} member after archiving: ${result.providerMessage ?? result.error}` + ); + } + } + return undeleted; +} + +/** + * Finishes a pass that died after its archive was recorded. + * + * Member names are recovered by hashing the CURRENT listing and matching + * against the recorded hashes, never by reading names out of Firestore — the + * batch record deliberately stores only hashes (see sealClaimHashes). Files + * already deleted simply do not appear in the listing, which is exactly right: + * they need no further work. + */ +async function resumeInterruptedBatch( + experimentID: string, + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + files: FileRef[] +): Promise<{ archived: number; archiveName: string; undeleted: number } | null> { + const pending = await batchesCollection(experimentID).where("status", "==", "uploading").get(); + if (pending.empty) { + return null; + } + + const salt = await getSalt(experimentID); + if (!salt) { + return null; + } + + let resumedArchive: string | null = null; + let archived = 0; + let undeleted = 0; + + for (const doc of pending.docs) { + const record = doc.data() as BatchRecord; + + const listed = files.find((file) => file.name === record.archiveName); + + // The upload never landed, or landed as something other than the archive + // that was built. Either way nothing has been deleted yet, so discarding + // the record is safe and the index gets reused. + if (!listed || !checksumMatches(listed.checksum, record.expectedMd5)) { + if (listed) { + await provider.deleteFile!(auth, container, listed); + } + await doc.ref.delete(); + continue; + } + + const hashes = new Set(record.memberHashes); + const survivors = files.filter((file) => hashes.has(claimDocId(salt, file.name))); + + undeleted += await sealAndDelete( + experimentID, + provider, + auth, + container, + doc.ref, + record.memberHashes, + survivors + ); + + resumedArchive = record.archiveName; + archived += record.fileCount; + } + + return resumedArchive ? { archived, archiveName: resumedArchive, undeleted } : null; +} diff --git a/functions/src/finalization.ts b/functions/src/finalization.ts new file mode 100644 index 0000000..6f85242 --- /dev/null +++ b/functions/src/finalization.ts @@ -0,0 +1,547 @@ +// Finalization: the end-of-study, PERMANENT pass that merges every remaining +// provider file into ONE archive carrying the whole Psych-DS tree +// (docs/finalization-spec.md). Structurally compactExperiment +// (compaction.ts) with a different selection rule and one irreversible extra +// step: once the merge is verified and sealed, the experiment is marked +// finalized and stops accepting submissions (see the guard this adds to +// api-data.ts / api-base64.ts). +// +// THE ORDERING RULE, unchanged from compaction.ts and just as load-bearing +// here: upload the archive, verify the checksum the provider reports back, +// seal the claims, and only THEN delete the originals. Never the reverse. +// DataPipe keeps no copy of submitted data, so a delete that runs before a +// verified upload is unrecoverable loss. +// +// WHY ONE ARCHIVE: multiple final archives break the Psych-DS compatibility +// this feature exists to produce (finalization-spec.md, decision 2). The +// merge streams straight to Cloud Storage via buildArchiveToStorage and is +// uploaded to the provider via writeStreamedFile, so its size is never bounded +// by function memory -- only by the provider's own hard per-file limit +// (capabilities.maxFileSizeBytes). This refuses to exceed that limit rather +// than silently splitting across multiple archives; splitting is out of scope +// for this pass (see finalization-spec.md's Phase 2 note on why the memory +// ceiling, not the provider ceiling, was the thing worth removing). +// +// CRASH SAFETY, same hinge as compactionBatches: a `finalizationRuns/current` +// record is written -- with the merged archive's expected md5 and every +// member's hash, never a raw filename (collision-cache.ts's "the raw filename +// is never stored anywhere" property) -- BEFORE the provider upload is +// attempted. An interrupted pass resumes from that record instead of +// re-merging (which would double the archive's contents) or, if the upload +// never landed, discards it and starts clean. + +import { Timestamp, FieldValue } from "firebase-admin/firestore"; +import { db, storage } from "./app.js"; +import { getProvider } from "./providers/index.js"; +import { StorageProvider, ContainerRef, ResolvedAuth, FileRef } from "./providers/types.js"; +import { ExperimentData, UserData } from "./interfaces.js"; +import resolveToken from "./resolve-token.js"; +import { getSalt, claimDocId, sealClaimHashes } from "./collision-cache.js"; +import { readArchive } from "./archive-reader.js"; +import { + buildArchiveToStorage, + checksumMatches, + archivePathsFor, + isArchiveName, + acquireLease, + releaseLease, +} from "./compaction.js"; + +// Fixed name, unlike compaction's numbered datapipe-batch-NNNN.zip: the +// compaction lease this reuses guarantees at most one finalization pass is +// ever in flight for an experiment (see acquireLease), and finalization runs +// at most once ever (a second attempt refuses at "already-finalized"), so +// there is never more than one merged archive to name. +const FINAL_ARCHIVE_NAME = "datapipe-final.zip"; + +// The only file finalization ever leaves loose. Same reason compaction never +// sweeps it into a batch (see NEVER_ARCHIVE in compaction.ts): it is the +// record's live Psych-DS descriptor, and metadata-block.ts holds a +// metadataFileRef to it that a buried copy would break. Everything else -- +// every batch archive, every loose session file, and .psychds-ignore -- is a +// member of the merge. +const EXCLUDE_FROM_MERGE = new Set(["dataset_description.json"]); + +export interface FinalizationResult { + // Carried on every result the same way CompactionResult carries it, so log + // lines and test assertions stay attributable without the caller having to + // hold the id separately. + experimentID: string; + status: + | "finalized" + | "already-finalized" + | "not-eligible" + | "leased-elsewhere" + // No collision-cache salt means nothing has ever been submitted through + // DataPipe -- mirrors compaction's identical "nothing-to-archive" for the + // identical reason (see runCompaction). + | "nothing-to-archive" + // Something is still sitting in uploadQueue for this experiment. That + // data belongs INSIDE the merge -- finalizeExperiment only ever looks at + // what the provider currently lists, which a queued entry is by + // definition not part of yet -- so the correct move is to wait for the + // queue to drain, not seal a record that is missing it. + | "queued-uploads-pending" + // The merged archive is bigger than the provider's hard per-file limit. + // Splitting is a last resort this pass does not implement (see the module + // header) -- nothing has been uploaded or deleted, so this is always safe + // to retry once the provider's own limit is what's addressed, e.g. by + // migrating to a provider with a higher (or no) cap. + | "archive-too-large" + | "failed"; + archived?: number; + archiveName?: string; + detail?: string; +} + +function experimentRef(experimentID: string) { + return db.collection("experiments").doc(experimentID); +} + +// Fixed doc id -- see FINAL_ARCHIVE_NAME above for why only one run can ever +// be in flight or ever needs to exist. +function finalizationRunRef(experimentID: string) { + return experimentRef(experimentID).collection("finalizationRuns").doc("current"); +} + +interface FinalizationRecord { + archiveName: string; + // The Cloud Storage object buildArchiveToStorage wrote the merge to. + // Recorded so a resumed pass (or the cleanup after a normal one) can find + // and remove it without having to reconstruct the path from convention. + storagePath: string; + status: "uploading" | "sealed"; + memberHashes: string[]; + expectedMd5: string; + fileCount: number; + createdAt: Timestamp; + sealedAt?: Timestamp; +} + +function storagePathFor(experimentID: string): string { + return `finalization/${experimentID}/${FINAL_ARCHIVE_NAME}`; +} + +async function deleteStorageObject(storagePath: string): Promise<void> { + await storage + .bucket() + .file(storagePath) + .delete() + .catch(() => undefined); // best-effort scratch cleanup; a leftover temp object is harmless +} + +/** + * Finalizes one experiment. Safe to call on anything -- it self-selects and + * returns a status rather than throwing for the ordinary "not applicable" + * cases, same convention as compactExperiment. + */ +export async function finalizeExperiment(experimentID: string): Promise<FinalizationResult> { + return { ...(await runFinalization(experimentID)), experimentID }; +} + +async function runFinalization(experimentID: string): Promise<Omit<FinalizationResult, "experimentID">> { + const expSnap = await experimentRef(experimentID).get(); + if (!expSnap.exists) { + return { status: "not-eligible", detail: "experiment does not exist" }; + } + const expData = expSnap.data() as ExperimentData; + const sessionsSeen = expData.sessions ?? 0; + + // Permanent, and checked before anything else that might otherwise look + // like grounds to proceed -- see the finalized field's doc comment in + // interfaces.ts. + if (expData.finalized === true) { + return { status: "already-finalized" }; + } + + if (!expData.storageProvider || !expData.providerContainer) { + return { status: "not-eligible", detail: "legacy experiment with no provider container" }; + } + + let provider: StorageProvider; + try { + provider = getProvider(expData.storageProvider); + } catch { + return { status: "not-eligible", detail: `unknown provider ${expData.storageProvider}` }; + } + + // Same eligibility contract as compaction's: a non-null maxFileCount is + // what makes a provider's flat keyspace need this archive-of-record in the + // first place (see compaction.ts's header, "where the Psych-DS directory + // structure lives"), and it is the contract that deleteFile/ + // downloadFileBytes also exist. writeStreamedFile is finalization's own + // extra requirement, since the merge is uploaded from a stream rather than + // a Buffer. + if (provider.capabilities.maxFileCount === null) { + return { status: "not-eligible", detail: "provider has no file-count cap" }; + } + if (!provider.deleteFile || !provider.downloadFileBytes || !provider.writeStreamedFile) { + return { + status: "not-eligible", + detail: `provider ${provider.id} declares maxFileCount but implements no deleteFile/downloadFileBytes/writeStreamedFile`, + }; + } + + const salt = await getSalt(experimentID); + if (!salt) { + return { status: "nothing-to-archive", detail: "experiment has no collision-cache salt" }; + } + + // A queued upload (still retrying after a provider hiccup, or waiting out + // this very lease via the write gate -- compaction-gate.ts) belongs INSIDE + // the merge. finalizeExperiment only ever looks at provider.listFiles, which + // a queued entry is by definition not part of yet, so proceeding here would + // strand that data outside the sealed record -- not through any race, just + // because the check never looked. Checked BEFORE the lease is taken (a + // pending/processing entry means there is real work left to do, so there is + // nothing this pass could usefully hold) and it also shrinks the race this + // module's own lease can otherwise create (see scheduled-upload-retry.ts's + // matching `finalized` guard for the remaining sliver: an entry that gets + // queued in the gap between this check and the provider write below). + const pendingQueue = await db + .collection("uploadQueue") + .where("experimentID", "==", experimentID) + .where("status", "in", ["pending", "processing"]) + .get(); + if (!pendingQueue.empty) { + return { + status: "queued-uploads-pending", + detail: `${pendingQueue.size} upload(s) for this experiment are still queued; finalize once the queue has drained`, + }; + } + + if (!(await acquireLease(experimentID))) { + return { status: "leased-elsewhere" }; + } + + try { + const userSnap = await db.collection("users").doc(expData.owner).get(); + if (!userSnap.exists) { + await releaseLease(experimentID, sessionsSeen); + return { status: "failed", detail: "owner record missing" }; + } + const tokenResult = await resolveToken(userSnap.data() as UserData, expData); + if (!tokenResult.success) { + await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": tokenResult.error }); + return { status: "failed", detail: `token resolution failed: ${tokenResult.error}` }; + } + const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; + const container = expData.providerContainer as ContainerRef; + + const files = await provider.listFiles(auth, container); + + // Resume before anything else -- an interrupted pass may already have a + // verified (or even sealed) merge sitting on the provider, and re-merging + // would double its contents. Same precedence compactExperiment gives + // resumeInterruptedBatch. + const resumed = await resumeInterruptedFinalization(experimentID, provider, auth, container, files, salt); + if (resumed) { + await markFinalized(experimentID, sessionsSeen); + return { + status: "finalized", + archived: resumed.archived, + archiveName: resumed.archiveName, + detail: resumed.detail, + }; + } + + const members = files.filter((file) => !EXCLUDE_FROM_MERGE.has(file.name)); + + if (members.length === 0) { + // Nothing was ever collected beyond the descriptor -- finalizing is + // still the correct terminal state (the study is over either way), just + // with no archive to build. + await markFinalized(experimentID, sessionsSeen); + return { + status: "finalized", + archived: 0, + detail: "nothing to merge; the experiment had no provider files besides dataset_description.json", + }; + } + + const looseMembers = members.filter((file) => !isArchiveName(file.name)); + const loosePaths = archivePathsFor( + provider, + expData.metadataActive === true, + looseMembers.map((file) => file.name) + ); + + const storagePath = storagePathFor(experimentID); + let build: { size: number; md5: string }; + try { + build = await buildArchiveToStorage(mergeEntries(provider, auth, container, members, loosePaths), storagePath); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + await deleteStorageObject(storagePath); + await releaseLease(experimentID, sessionsSeen, { + "compaction.lastError": `failed to build merged archive: ${detail}`, + }); + return { status: "failed", detail: `failed to build merged archive: ${detail}` }; + } + + const maxFileSizeBytes = provider.capabilities.maxFileSizeBytes; + if (maxFileSizeBytes !== null && build.size > maxFileSizeBytes) { + await deleteStorageObject(storagePath); + const detail = + `merged archive is ${build.size} bytes, over ${provider.id}'s ${maxFileSizeBytes}-byte ` + + `per-file limit; splitting into multiple final archives is not implemented`; + await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": detail }); + return { status: "archive-too-large", detail }; + } + + const memberHashes = members.map((file) => claimDocId(salt, file.name)); + const runRef = finalizationRunRef(experimentID); + + // Recorded BEFORE the provider upload -- this is the crash-safety hinge. + // If the function dies between here and the delete step, the next pass + // finds this document and either resumes (upload already verified) or + // discards it and re-merges (upload never landed), rather than trusting a + // half-finished attempt. + await runRef.set({ + archiveName: FINAL_ARCHIVE_NAME, + storagePath, + status: "uploading", + memberHashes, + expectedMd5: build.md5, + fileCount: members.length, + createdAt: Timestamp.now(), + }); + + const uploaded = await putVerifiedFinalizationArchive(provider, auth, container, storagePath, build.size, build.md5); + if (!uploaded.ok) { + await runRef.delete(); + await deleteStorageObject(storagePath); + await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": uploaded.detail }); + return { status: "failed", detail: uploaded.detail }; + } + + // Everything in the merge is now inside a verified archive on the + // provider, so the originals can go. + await sealAndDeleteMembers(experimentID, provider, auth, container, runRef, memberHashes, members); + await deleteStorageObject(storagePath); + + await markFinalized(experimentID, sessionsSeen); + + return { status: "finalized", archived: members.length, archiveName: FINAL_ARCHIVE_NAME }; + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": detail }).catch(() => undefined); + return { status: "failed", detail }; + } +} + +/** + * Marks the experiment permanently finalized and releases the lease in the + * same write. `compaction.lastError` is cleared the way a successful + * compaction pass clears it -- a stale error from an earlier attempt should + * not linger once the pass it belongs to has actually succeeded. + */ +async function markFinalized(experimentID: string, sessionsSeen: number): Promise<void> { + await releaseLease(experimentID, sessionsSeen, { + finalized: true, + finalizedAt: Timestamp.now(), + "compaction.lastError": FieldValue.delete(), + }); +} + +/** + * Lazily re-emits every member's content at its Psych-DS path, one member at + * a time -- fed straight into buildArchiveToStorage, which drains it exactly + * that way (see its own header). A batch archive's members are re-emitted at + * the paths recorded INSIDE it (readArchive returns exactly those), never + * recomputed, because archivePathsFor was already applied once when the batch + * was built; a loose file's path is looked up in `loosePaths`, computed for + * the whole loose set up front so its own collision-avoidance (see + * archivePathsFor in compaction.ts) sees every loose name at once. + */ +async function* mergeEntries( + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + members: FileRef[], + loosePaths: Map<string, string> +): AsyncIterable<{ path: string; content: Buffer }> { + const seenPaths = new Set<string>(); + + // A path collision across two DIFFERENT source members (a batch built at + // one time and a loose file claimed at another, say) cannot happen under + // any archivePathFor DataPipe ships, but two zip members at one path is + // silent corruption -- the same defensive fallback archivePathsFor takes, + // just scoped across the whole merge rather than one provider's listing. + const claimPath = (candidate: string, originName: string): string => { + if (!seenPaths.has(candidate)) { + seenPaths.add(candidate); + return candidate; + } + const fallback = `${originName}__${candidate}`; + seenPaths.add(fallback); + return fallback; + }; + + for (const member of members) { + const download = await provider.downloadFileBytes!(auth, container, member); + if (!download.success) { + throw new Error( + `failed to download "${member.name}" while merging: ${download.providerMessage ?? download.error}` + ); + } + + if (isArchiveName(member.name)) { + // A batch archive: unpack it (readArchive verifies each member's CRC-32 + // and uncompressed size before handing it back -- see archive-reader.ts) + // and re-emit its contents at their recorded paths, rather than nesting + // the zip itself inside the merge. + let inner: Map<string, Buffer>; + try { + inner = readArchive(download.content); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + throw new Error(`batch archive "${member.name}" could not be read while merging: ${detail}`); + } + for (const [path, content] of inner) { + yield { path: claimPath(path, member.name), content }; + } + } else { + const path = loosePaths.get(member.name) ?? member.name; + yield { path: claimPath(path, member.name), content: download.content }; + } + } +} + +/** + * Uploads the merged archive from its Cloud Storage temp object and confirms + * the provider stored exactly the bytes buildArchiveToStorage wrote -- + * putVerifiedArchive's (compaction.ts) streaming sibling. A mismatch removes + * the provider-side object rather than leaving something that could later be + * mistaken for a verified merge. + * + * This is the gate the delete in runFinalization depends on: an unverified + * upload must never authorize removing the sessions that went into it. + */ +async function putVerifiedFinalizationArchive( + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + storagePath: string, + size: number, + md5: string +): Promise<{ ok: true } | { ok: false; detail: string }> { + const body = storage.bucket().file(storagePath).createReadStream(); + const write = await provider.writeStreamedFile!(auth, container, FINAL_ARCHIVE_NAME, body, size, { + size, + contentType: "application/zip", + }); + if (!write.success) { + return { ok: false, detail: `merged archive upload failed: ${write.providerMessage ?? write.error}` }; + } + if (!checksumMatches(write.fileRef.checksum, md5)) { + await provider.deleteFile!(auth, container, { name: FINAL_ARCHIVE_NAME }); + return { + ok: false, + detail: `merged archive checksum mismatch (reported ${write.fileRef.checksum ?? "nothing"})`, + }; + } + return { ok: true }; +} + +/** + * Seals the merge's claims, then deletes the loose/archived originals -- + * sealAndDelete's (compaction.ts) sibling, recording against the + * finalizationRuns doc instead of a compactionBatches one. Order is the same + * and for the same reason: sealing first means an interruption during the + * deletes still leaves every remaining original protected from being + * re-merged, and its filename still recognised as taken. + */ +async function sealAndDeleteMembers( + experimentID: string, + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + runRef: FirebaseFirestore.DocumentReference, + memberHashes: string[], + members: FileRef[] +): Promise<number> { + await sealClaimHashes(experimentID, memberHashes); + await runRef.update({ status: "sealed", sealedAt: Timestamp.now() }); + + let undeleted = 0; + for (const file of members) { + const result = await provider.deleteFile!(auth, container, file); + if (!result.success) { + undeleted += 1; + console.error( + `finalization: failed to delete ${experimentID} member after merging: ${result.providerMessage ?? result.error}` + ); + } + } + return undeleted; +} + +/** + * Finishes a pass that died after its finalizationRuns record was written -- + * resumeInterruptedBatch's (compaction.ts) sibling, extended with the one + * extra crash point finalization has that compaction does not: dying after + * the record was sealed but before the experiment was marked finalized. + * + * Member names are recovered by hashing the CURRENT listing and matching + * against the recorded hashes, never by reading names out of Firestore -- the + * record deliberately stores only hashes (see sealClaimHashes). + * + * Returns null when there is nothing to resume (no record at all, or a + * record whose upload never landed and was discarded), meaning the caller + * should proceed with an ordinary fresh pass. + */ +async function resumeInterruptedFinalization( + experimentID: string, + provider: StorageProvider, + auth: ResolvedAuth, + container: ContainerRef, + files: FileRef[], + salt: string +): Promise<{ archived: number; archiveName: string; detail: string } | null> { + const runRef = finalizationRunRef(experimentID); + const runSnap = await runRef.get(); + if (!runSnap.exists) { + return null; + } + const record = runSnap.data() as FinalizationRecord; + + if (record.status === "sealed") { + // Died after every member was sealed and deleted, before the experiment + // was marked finalized. Nothing left to redo on the provider -- just the + // flag, which the caller sets once this returns. + await deleteStorageObject(record.storagePath); + return { + archived: record.fileCount, + archiveName: record.archiveName, + detail: "resumed after seal, before the finalized flag was set", + }; + } + + // status === "uploading" + const listed = files.find((file) => file.name === record.archiveName); + + // The upload never landed, or landed as something other than the archive + // that was built. Either way nothing has been deleted yet, so discarding + // the record and letting the caller re-merge from scratch is safe. + if (!listed || !checksumMatches(listed.checksum, record.expectedMd5)) { + if (listed) { + await provider.deleteFile!(auth, container, listed); + } + await deleteStorageObject(record.storagePath); + await runRef.delete(); + return null; + } + + const hashes = new Set(record.memberHashes); + const survivors = files.filter((file) => hashes.has(claimDocId(salt, file.name))); + + await sealAndDeleteMembers(experimentID, provider, auth, container, runRef, record.memberHashes, survivors); + await deleteStorageObject(record.storagePath); + + return { + archived: record.fileCount, + archiveName: record.archiveName, + detail: "resumed an interrupted finalization pass", + }; +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 7fd382e..27fb0a2 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,6 +9,7 @@ import { checkEmailConflict } from "./check-email-conflict.js"; import { scheduledTokenRefresh } from "./scheduled-token-refresh.js"; import { scheduledUploadRetry } from "./scheduled-upload-retry.js"; import { scheduledPendingRecovery } from "./scheduled-pending-recovery.js"; +import { onExperimentGrew, onUploadQueueChanged } from "./compaction-triggers.js"; import { apiQueueStatus } from "./api-queue-status.js"; import { generateOAuthState } from "./generate-oauth-state.js"; import { connectProvider, connectStaticTokenProvider, disconnectProvider } from "./connect-provider.js"; @@ -19,6 +20,7 @@ import { deleteAccount } from "./delete-account.js"; import { createExperiment } from "./create-experiment.js"; import { getProviderAccessToken } from "./get-provider-access-token.js"; import { providerSetupWarnings } from "./provider-setup-warnings.js"; +import { apiFinalize, finalizeTask } from "./api-finalize.js"; setGlobalOptions({ maxInstances: 20 @@ -34,6 +36,8 @@ export { scheduledTokenRefresh as scheduledtokenrefresh, scheduledUploadRetry as scheduleduploadretry, scheduledPendingRecovery as scheduledpendingrecovery, + onExperimentGrew as onexperimentgrew, + onUploadQueueChanged as onuploadqueuechanged, apiQueueStatus as apiqueuestatus, generateOAuthState as generateoauthstate, connectProvider as connectprovider, @@ -45,5 +49,7 @@ export { deleteAccount as deleteaccount, createExperiment as createexperiment, getProviderAccessToken as getprovideraccesstoken, - providerSetupWarnings as providersetupwarnings + providerSetupWarnings as providersetupwarnings, + apiFinalize as apifinalize, + finalizeTask as finalizetask }; diff --git a/functions/src/interfaces.ts b/functions/src/interfaces.ts index b218ef5..a26e4ad 100644 --- a/functions/src/interfaces.ts +++ b/functions/src/interfaces.ts @@ -27,6 +27,75 @@ export interface ExperimentData { providerContainer?: ContainerRef; metadataFileRef?: FileRef | null; collisionCache?: CollisionCacheState; + compaction?: CompactionState; + // Set once, permanently, by finalization.ts (docs/finalization-spec.md). + // Checked by api-data.ts / api-base64.ts to reject submissions after the + // fact -- a session landing after this point would sit outside the merged + // archive and quietly make the record non-Psych-DS again. There is no + // un-finalize: firestore.rules (Phase 4) is what stops a client from + // clearing this through the client SDK; the admin-only write path here is + // the only place it is ever set. + finalized?: boolean; + finalizedAt?: FirebaseFirestore.Timestamp; + // Progress surface for the split apiFinalize/finalizeTask pair + // (functions/src/api-finalize.ts, Phase 4 of docs/finalization-spec.md). + // Written by admin-SDK code only -- firestore.rules blocks a client write + // to this field the same way it blocks `finalized` itself, for the same + // reason: a researcher forging "finalized" progress here would be + // indistinguishable, to the dashboard poller, from a real pass. + finalization?: FinalizationState; + } + + // experiments/{id}.finalization. `status` starts at "queued" the moment + // apiFinalize enqueues the Cloud Task and ends at one of + // FinalizationResult's status values (finalization.ts) once finalizeTask's + // call to finalizeExperiment returns -- "queued" and "running" are the only + // two values that do not also appear on FinalizationResult. Kept as a + // sibling map to `compaction` above (same experiment doc, same + // Timestamp-and-detail shape) rather than folded into it: compaction is a + // recurring background pass with no client-visible "in progress" state to + // poll, while finalization is a one-shot, user-triggered action whose whole + // point is that the dashboard has something to poll. + export interface FinalizationState { + status: + | "queued" + | "running" + | "finalized" + | "already-finalized" + | "not-eligible" + | "leased-elsewhere" + | "nothing-to-archive" + | "queued-uploads-pending" + | "archive-too-large" + | "failed"; + startedAt?: FirebaseFirestore.Timestamp; + finishedAt?: FirebaseFirestore.Timestamp; + // Human-readable detail carried over from FinalizationResult.detail (or, + // for a status this module produces itself -- "failed" from an uncaught + // exception -- an equivalent message). Absent on "queued"/"running" and on + // the plain "finalized" success case, which needs no further explanation. + detail?: string; + } + + // experiments/{id}.compaction (additive; absent until the first pass looks + // at this experiment). Per-batch membership lives in the compactionBatches + // subcollection rather than here — see functions/src/compaction.ts. + export interface CompactionState { + // Held for the duration of a pass so two never overlap; cleared on + // completion. Same lease shape as CollisionCacheState.rehydratingUntil. + compactingUntil?: FirebaseFirestore.Timestamp; + // Set on every pass, including one that found nothing to do. + lastCheckedAt?: FirebaseFirestore.Timestamp; + // The experiment's `sessions` value when that pass began. This is the + // scheduled worker's change trigger: it re-examines an experiment when + // this no longer matches the live count, instead of polling on a timer. + sessionsAtLastCheck?: number; + // How many files the provider held at the end of the last pass. + // Diagnostic only — nothing branches on it. + lastFileCount?: number; + // Set only when files were actually archived. + lastRunAt?: FirebaseFirestore.Timestamp; + lastError?: string; } export interface UserData { diff --git a/functions/src/providers/dataverse.ts b/functions/src/providers/dataverse.ts index 20662ce..dffc94d 100644 --- a/functions/src/providers/dataverse.ts +++ b/functions/src/providers/dataverse.ts @@ -279,6 +279,12 @@ export const dataverseProvider: StorageProvider = { // no API that surfaces it, so this stays null (descriptive only; never a // correctness gate -- see types.ts). maxFileSizeBytes: null, + // Null for the same reason as maxFileSizeBytes, but with a sharper + // consequence: a non-null value here would enrol Dataverse in compaction + // (see types.ts), and this adapter implements neither deleteFile nor + // downloadFileBytes. Any installation that does impose a file cap needs + // those two methods first. + maxFileCount: null, quotaNote: "File size and storage limits are set by the researcher's hosting Dataverse installation", }, diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index f8d4c2f..1770a9c 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -197,6 +197,9 @@ export const gdriveProvider: StorageProvider = { nativeSubfolders: true, supportsRegion: false, maxFileSizeBytes: null, + // Drive's per-folder limit (500k) is far beyond any experiment, and the + // real constraint is bytes, not files -- see quotaNote. + maxFileCount: null, quotaNote: "Free Google accounts share 15 GB across Drive, Gmail, and Photos", }, diff --git a/functions/src/providers/osf.ts b/functions/src/providers/osf.ts index cc5cb20..fb25cfc 100644 --- a/functions/src/providers/osf.ts +++ b/functions/src/providers/osf.ts @@ -52,6 +52,7 @@ export const osfProvider: StorageProvider = { nativeSubfolders: true, supportsRegion: true, maxFileSizeBytes: null, + maxFileCount: null, quotaNote: null, }, diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index a4e1fa7..6df398b 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -58,6 +58,17 @@ export interface FileRef { id?: string; path?: string; rev?: string; + // Both optional and both best-effort: a provider that does not report them + // on a given call leaves them undefined, and no caller may treat their + // absence as an error. Compaction (compaction.ts) uses `size` to bound how + // many files it pulls into one archive, and `checksum` to verify an uploaded + // archive landed intact BEFORE it deletes the originals -- so an adapter + // that omits `checksum` from its write result disables that verification and + // must not be given a non-null maxFileCount. Format is the provider's own + // (Zenodo reports "md5:<hex>"); compare like-for-like, never across + // providers. + size?: number; + checksum?: string; } export interface FileMeta { @@ -94,12 +105,31 @@ export type DownloadResult = providerMessage: string | null; }; +export type DeleteResult = + | { success: true } + | { + success: false; + error: ProviderErrorCode; + providerStatus: number | null; + providerMessage: string | null; + }; + // Descriptive (UI hints, subfolder fallback, size-cap warnings) — never a // correctness gate. Collision detection lives in Firestore, not here. +// +// maxFileCount is the ONE EXCEPTION and is deliberately not descriptive: it is +// what makes an experiment eligible for compaction (compaction.ts), so a +// non-null value here is a contract that this adapter also implements +// deleteFile, downloadFileBytes, and (where its keyspace is flat) +// archivePathFor. Zenodo's hard 100-files-per-record limit is the motivating +// case and the only non-null value today. null means "no known cap": either +// the provider has none (OSF, Drive) or it is per-installation and unreadable +// (Dataverse), and both mean the same thing here — never compact. export interface ProviderCapabilities { nativeSubfolders: boolean; supportsRegion: boolean; maxFileSizeBytes: number | null; + maxFileCount: number | null; quotaNote: string | null; } @@ -228,6 +258,72 @@ export interface StorageProvider { // verbatim MUST implement this. See claimNameFor in providers/index.ts. storedNameFor?(filename: string): string; + // storedNameFor's counterpart, used ONLY to lay out a compaction archive: + // given a name as this adapter's listFiles reports it, return the path the + // file should occupy inside the zip. Omitting it means "identity", which is + // correct for every provider with real folders. + // + // This is not a general inverse and cannot be one -- storedNameFor is + // many-to-one (Zenodo maps every run of slashes to a single "_"). It is + // exact only over the paths DATAPIPE ITSELF writes, which is all it is ever + // asked about: metadata-derived-files.ts flattens researcher subfolders with + // "-" BEFORE building a path, so a leaf never contains a slash and the only + // shapes that reach a provider are `data/raw/<leaf>`, `data/<stem>_data.csv`, + // `dataset_description.json`, and `.psychds-ignore`. Callers pass + // metadataActive so the reconstruction is skipped entirely for experiments + // that never produce a slashed path — see archivePathsFor in compaction.ts. + archivePathFor?(storedName: string): string; + + // Removes a file. Required for any provider with a non-null maxFileCount, + // since compaction cannot relieve a cap without it; optional otherwise, and + // absent on providers DataPipe never deletes from. Never throws — failures + // come back as a DeleteResult, same convention as WriteResult. + // + // Callers must treat this as best-effort and idempotent: a file that is + // already gone is a success, not an error, because the only caller retries + // after partial failure. + deleteFile?( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise<DeleteResult>; + + // downloadFile's binary-safe sibling, returning raw bytes instead of text. + // Required for any provider with a non-null maxFileCount. + // + // Both exist because neither is right for both callers. metadata-block.ts + // wants a decoded JSON string, while compaction re-uploads what it reads + // byte-for-byte and includes files submitted through /api/base64 — images, + // audio, video. Routing those through downloadFile's response.text() would + // decode them as UTF-8 and replace every invalid sequence with U+FFFD, + // silently corrupting the archive DataPipe is about to delete the originals + // in favor of. Nothing would surface it: the write succeeds and the bytes + // are simply wrong. + downloadFileBytes?( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise<{ success: true; content: Buffer } | { success: false; error: ProviderErrorCode; providerStatus: number | null; providerMessage: string | null }>; + + // Uploads from a readable stream, for payloads too large to hold in memory. + // Required for any provider with a non-null maxFileCount. + // + // Exists because writeSessionFile takes a Buffer, which caps the largest + // file an adapter can move at function memory -- fine for a single session, + // but a finalization archive merges an entire study into ONE file (Psych-DS + // requires it) and has no such ceiling by design. `size` is required and + // must be the exact byte length of `body`, not an estimate: Zenodo's bucket + // PUT needs a real Content-Length header up front, since the request is + // streamed and there is no buffered body to measure afterward. + writeStreamedFile?( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + body: NodeJS.ReadableStream, + size: number, + meta: FileMeta + ): Promise<WriteResult>; + // Fetches a file's contents as text. Used by metadata-block.ts to read // back an existing dataset_description.json. Never throws — failures come // back as a DownloadResult, same shape convention as WriteResult. diff --git a/functions/src/providers/zenodo.ts b/functions/src/providers/zenodo.ts index 41fdbb9..a7cc415 100644 --- a/functions/src/providers/zenodo.ts +++ b/functions/src/providers/zenodo.ts @@ -1,4 +1,4 @@ -import fetch from "node-fetch"; +import fetch, { RequestInit } from "node-fetch"; import { decrypt } from "../crypto-utils.js"; import { UserData } from "../interfaces.js"; import { @@ -9,6 +9,7 @@ import { FileMeta, WriteResult, DownloadResult, + DeleteResult, ProviderErrorCode, TokenResult, } from "./types.js"; @@ -73,6 +74,10 @@ export function isAllowedZenodoServer(serverUrl: string): boolean { // capabilities are never a correctness gate (see types.ts). const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 * 1024; +// Files per record. Unlike MAX_FILE_SIZE_BYTES this is enforced hard by Zenodo +// and is a correctness gate, not a UI hint -- see capabilities.maxFileCount. +const MAX_FILE_COUNT = 100; + function authHeaders(auth: ResolvedAuth): Record<string, string> { // Header rather than Zenodo's supported ?access_token= query parameter, so // the credential never lands in a URL that could reach a log or an error @@ -80,12 +85,46 @@ function authHeaders(auth: ResolvedAuth): Record<string, string> { return { Authorization: `Bearer ${auth.token}` }; } +// TEST-ONLY TRANSPORT SEAM. Returns a replacement origin for every Zenodo +// call, or undefined in any real deployment. +// +// Why this has to exist at all: ALLOWED_HOSTS above rejects every address a +// same-machine mock server can bind to, so without a seam there is no way to +// exercise the emulator-hosted apidata function against a fake Zenodo -- the +// identical wall connect-static-token-emulator.test.js documents for +// Dataverse's connect path. gdrive has GDRIVE_API_BASE for the same reason; +// this is that pattern, tightened. +// +// It is gated on FUNCTIONS_EMULATOR, which the Firebase emulator sets to +// "true" and a DEPLOYED function never sets -- so in production this reads an +// unset variable and returns undefined no matter what ZENODO_API_BASE holds. +// The gate is what makes it safe to redirect writes from an env var at all. +// +// It REPLACES the resolved serverUrl rather than extending the allowlist, +// deliberately: under the emulator, no test can then reach real zenodo.org by +// seeding a realistic-looking container. That failure mode is not +// hypothetical -- docs/provider-migration-design.md records the OSF refresh +// path making live calls to accounts.osf.io from a test for exactly this +// reason (no override, so the real host stayed reachable). +function emulatorServerOverride(): string | undefined { + if (process.env.FUNCTIONS_EMULATOR !== "true") { + return undefined; + } + const override = process.env.ZENODO_API_BASE; + return override ? override.replace(/\/+$/, "") : undefined; +} + // serverUrl can come from the container (a deposition knows which installation // it lives on) or from auth (the researcher's connection), container winning -- // same precedence as dataverse.ts's resolveServerUrl, for the same reason: // calls made before a container exists (createDataContainer, // validateStaticToken) only have auth. function resolveServerUrl(auth: ResolvedAuth, container?: ContainerRef): string { + const override = emulatorServerOverride(); + if (override) { + return override; + } + const fromContainer = (container as ZenodoContainerRef | undefined)?.serverUrl; const serverUrl = fromContainer ?? auth.serverUrl; if (!serverUrl) { @@ -150,6 +189,37 @@ function toZenodoKey(name: string): string { return name.replace(/[/\\]+/g, "_"); } +// toZenodoKey's counterpart for compaction archives (StorageProvider. +// archivePathFor). Rebuilds the Psych-DS path a flattened key came from, so +// the zip carries `data/raw/subject-1.json` even though the record can only +// ever show `data_raw_subject-1.json`. +// +// toZenodoKey is many-to-one, so this is NOT its inverse in general -- it is +// exact only over the four path shapes DataPipe writes, which is the entire +// set it is ever asked about: +// +// data_raw_subject-1.json -> data/raw/subject-1.json +// data_subject-1_data.csv -> data/subject-1_data.csv +// dataset_description.json -> unchanged ("data" is not followed by "_") +// .psychds-ignore -> unchanged +// +// The leaf is safe to hand back untouched because metadata-derived-files.ts +// flattens researcher-supplied subfolders with "-" BEFORE the path is built, +// so a leaf reaching Zenodo never contains a slash and there is no second +// separator left to guess at. The one remaining ambiguity -- a researcher +// naming their own file `data_x.json` in an experiment that writes no slashed +// paths at all -- is removed by the caller, not here: compaction only applies +// this to metadataActive experiments (see archivePathsFor in compaction.ts). +export function fromZenodoKey(key: string): string { + if (key.startsWith("data_raw_")) { + return `data/raw/${key.slice("data_raw_".length)}`; + } + if (key.startsWith("data_")) { + return `data/${key.slice("data_".length)}`; + } + return key; +} + function encodeKey(key: string): string { return encodeURIComponent(toZenodoKey(key)); } @@ -256,6 +326,11 @@ interface BucketPutResponse { version_id?: string; } +// node-fetch's own RequestInit type has no `duplex` field (it predates the +// option), but its runtime happily accepts and ignores it -- see +// writeStreamedFile for why this must still be sent on every call. +type RequestInitWithDuplex = RequestInit & { duplex?: "half" }; + interface DepositionFileResponse { id?: string; filename?: string; @@ -273,6 +348,11 @@ export const zenodoProvider: StorageProvider = { nativeSubfolders: false, supportsRegion: false, maxFileSizeBytes: MAX_FILE_SIZE_BYTES, + // The only non-null maxFileCount in the codebase, and the reason + // compaction.ts exists. Verified live rather than read off the docs: the + // 101st file comes back 400 with "Uploading selected files will result in + // exceeding the max amount per record." (spike gate E, 2026-08-11). + maxFileCount: MAX_FILE_COUNT, quotaNote: "Zenodo allows up to 100 files and 50 GB per record. DataPipe compacts completed sessions into archives to stay under the file limit.", }, @@ -455,7 +535,80 @@ export const zenodoProvider: StorageProvider = { // interchangeable. metadata-block.ts also requires a defined id before // it will persist a metadataFileRef, so leaving this unset would make it // re-discover the metadata file by listing on every single submission. - fileRef: { name: storedFilename, id: storedFilename }, + // checksum ("md5:<hex>") is what lets compaction.ts prove an uploaded + // archive landed intact before it deletes the sessions that went into + // it. Absent on a response that omits it, which callers must tolerate. + fileRef: { + name: storedFilename, + id: storedFilename, + size: responseBody.size, + checksum: responseBody.checksum, + }, + storedFilename, + }; + }, + + // writeSessionFile's streaming sibling (StorageProvider.writeStreamedFile). + // Same bucket PUT, same octet-stream requirement, same defensive read of + // key/checksum -- the only difference is the body and an explicit `size`, + // because a stream has no `.length` to read a Content-Length from the way + // writeSessionFile does off its Buffer. + // + // `duplex: "half"` is required here and is NOT optional hardening: Node's + // built-in fetch (undici) throws "duplex option is required when sending a + // body" for any stream body, verified live against a real local server + // rather than assumed. node-fetch's own implementation (what this file + // normally talks to) tolerates the option fine either way, so sending it + // unconditionally is safe. This matters here specifically because the + // in-process emulator suites alias node-fetch to Node's native fetch to + // exercise real HTTP calls (see compaction-emulator.test.js) -- so a build + // that worked only against node-fetch would pass its own unit tests and + // then hang or throw the moment it ran there. + async writeStreamedFile( + auth: ResolvedAuth, + container: ContainerRef, + filename: string, + body: NodeJS.ReadableStream, + size: number, + _meta: FileMeta + ): Promise<WriteResult> { + const zenodoContainer = container as ZenodoContainerRef; + const serverUrl = resolveServerUrl(auth, zenodoContainer); + const bucket = resolveBucketUrl(zenodoContainer, serverUrl); + + const response = await fetch(`${bucket}/${encodeKey(filename)}`, { + method: "PUT", + headers: { + ...authHeaders(auth), + // MUST be application/octet-stream, same hard 415 as writeSessionFile + // -- see that method's comment for the live evidence. + "Content-Type": "application/octet-stream", + "Content-Length": String(size), + }, + body, + duplex: "half", + } as RequestInitWithDuplex); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { success: false, ...mapped }; + } + + const responseBody = (await response.json()) as BucketPutResponse; + + // Same defensive read as writeSessionFile: never assume the provider kept + // the requested name. See that method's comment for why the fallback is + // the flattened key rather than the raw `filename`. + const storedFilename = responseBody.key ?? toZenodoKey(filename); + + return { + success: true, + fileRef: { + name: storedFilename, + id: storedFilename, + size: responseBody.size, + checksum: responseBody.checksum, + }, storedFilename, }; }, @@ -507,10 +660,17 @@ export const zenodoProvider: StorageProvider = { // neither rather than emitting a FileRef with an undefined name -- the // collision cache matches on exact names, so a bad entry there would let a // duplicate through. - return (body || []) - .map((file) => file.filename ?? file.key) - .filter((name): name is string => typeof name === "string" && name.length > 0) - .map((name) => ({ name, id: name })); + // size/checksum are passed through for compaction.ts, which uses them to + // bound an archive's memory footprint and to skip re-reading files it has + // already sealed. Both are optional on FileRef and genuinely absent on + // some responses, so nothing may depend on them being set. + return (body || []).flatMap((file): FileRef[] => { + const name = file.filename ?? file.key; + if (typeof name !== "string" || name.length === 0) { + return []; + } + return [{ name, id: name, size: file.filesize, checksum: file.checksum }]; + }); }, async downloadFile( @@ -541,27 +701,93 @@ export const zenodoProvider: StorageProvider = { return { success: true, content }; }, - // A Zenodo record holds at most 100 files, and the compaction that is meant - // to keep a study under that cap (batch zips during collection, one merged - // archive at finalization -- see docs/provider-migration-design.md) is NOT - // built yet. Until it is, session 101 fails and stays failed: the queue maps - // Zenodo's refusal to QUOTA_EXCEEDED, which is slow-tier and needs human - // action to clear. No data is lost -- the submission stays in pending - // storage and QueuePanel surfaces the reason -- but the researcher cannot - // fix it, so they need to hear about the limit BEFORE they start collecting - // rather than after. - // - // Unconditional and offline, unlike dataverse.ts's version probe: the cap is - // a property of Zenodo itself, not of an installation, so there is nothing - // to interrogate and no failure mode to fail open from. + // downloadFile without the UTF-8 decode. Compaction re-uploads exactly what + // it reads and archives /api/base64 submissions (images, audio, video), so + // decoding to a string first would replace every invalid sequence with + // U+FFFD and corrupt the archive -- silently, since the subsequent write + // still succeeds. See StorageProvider.downloadFileBytes. + async downloadFileBytes( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ) { + const zenodoContainer = container as ZenodoContainerRef; + const serverUrl = resolveServerUrl(auth, zenodoContainer); + const bucket = resolveBucketUrl(zenodoContainer, serverUrl); + + const response = await fetch(`${bucket}/${encodeKey(fileRef.name)}`, { + method: "GET", + headers: authHeaders(auth), + }); + + if (!isSuccessStatus(response.status)) { + const mapped = await mapErrorResponse(response); + return { + success: false as const, + error: mapped.error, + providerStatus: mapped.providerStatus, + providerMessage: mapped.providerMessage, + }; + } + + return { success: true as const, content: Buffer.from(await response.arrayBuffer()) }; + }, + + // Bucket DELETE by key, addressing the object the same way writeSessionFile + // and downloadFile do. // - // DELETE THIS once compaction ships. - async setupWarnings(_auth: ResolvedAuth): Promise<string[]> { - return [ - "Zenodo allows at most 100 files per deposition, and DataPipe does not yet " + - "combine sessions into archives. Plan for fewer than 100 submissions in this " + - "experiment: after that, further submissions will fail to upload and will have " + - "to be recovered by hand.", - ]; + // A 404 is reported as SUCCESS, deliberately. The only caller is compaction, + // which deletes the originals after verifying their archive uploaded, and + // resumes a partially-completed pass by re-deleting whatever is left; a + // missing key means a previous pass already removed it, which is precisely + // the state the caller is trying to reach. Treating it as an error would + // wedge an experiment that got interrupted mid-delete. + async deleteFile( + auth: ResolvedAuth, + container: ContainerRef, + fileRef: FileRef + ): Promise<DeleteResult> { + const zenodoContainer = container as ZenodoContainerRef; + const serverUrl = resolveServerUrl(auth, zenodoContainer); + const bucket = resolveBucketUrl(zenodoContainer, serverUrl); + + const response = await fetch(`${bucket}/${encodeKey(fileRef.name)}`, { + method: "DELETE", + headers: authHeaders(auth), + }); + + if (response.status === 404 || isSuccessStatus(response.status)) { + return { success: true }; + } + + const mapped = await mapErrorResponse(response); + return { + success: false, + error: mapped.error, + providerStatus: mapped.providerStatus, + providerMessage: mapped.providerMessage, + }; + }, + + // Rebuilds the Psych-DS path a flattened key came from, so compaction + // archives carry the directory structure Zenodo's keyspace cannot hold. See + // fromZenodoKey above for why this is exact over DataPipe's own paths and + // why the caller gates it on metadataActive. + archivePathFor(storedName: string): string { + return fromZenodoKey(storedName); }, + + // setupWarnings is deliberately NOT implemented, and its absence is the + // point. It previously carried a standing warning that a Zenodo experiment + // must stay under 100 submissions because DataPipe could not combine + // sessions into archives; compaction.ts is that missing piece, so the + // warning would now be false. A setup-time warning is for something the + // researcher must act on before collecting, and there is nothing left to act + // on here. + // + // The cap is relieved, not removed: each sealed batch is itself a file, so a + // record still tops out at roughly MAX_FILE_COUNT batches' worth of sessions + // (thousands, at the default batch size). Finalization -- one merged archive + // replacing every batch -- is what removes the ceiling entirely and is not + // built yet. Nothing needs to warn about a limit that far out. }; diff --git a/functions/src/scheduled-upload-retry.ts b/functions/src/scheduled-upload-retry.ts index 073cd94..b9f1ec5 100644 --- a/functions/src/scheduled-upload-retry.ts +++ b/functions/src/scheduled-upload-retry.ts @@ -7,6 +7,7 @@ import resolveToken from "./resolve-token.js"; import { claimFilename, confirmClaim, CollisionCacheUnavailableError } from "./collision-cache.js"; import { ExperimentData, UserData } from "./interfaces.js"; import { isFastRetry } from "./queue-upload.js"; +import { isCompactionInFlight, COMPACTION_HOLD_REASON } from "./compaction-gate.js"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24 hours (slow tier cap, unchanged) @@ -139,6 +140,37 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho const userData = userDoc.data() as UserData; const expData = expDoc.data() as ExperimentData; + // Finalization is permanent (docs/finalization-spec.md): once + // finalizeExperiment has run, every remaining provider file has been merged + // into one archive and the originals deleted, and the experiment stops + // accepting new submissions (api-data.ts / api-base64.ts). finalizeExperiment + // itself refuses to run while anything is still queued (finalization.ts's + // "queued-uploads-pending" check), so the only way this entry can still be + // pending AND find `finalized` true here is the one race that check cannot + // see -- this entry got queued (or was already queued and still waiting out + // this experiment's compaction/finalization lease -- see isCompactionInFlight + // above) in the gap between that check running and finalization actually + // completing. Either way, writing it now would drop a loose file into a + // container finalization already emptied and sealed. + // + // Marked failed rather than retried, deliberately: finalized never + // un-finalizes, so retrying would spend this entry's five attempts against a + // condition that can never clear. The data itself is NOT lost -- + // api-queue-status.ts lets the researcher download any queued payload + // straight from the dashboard -- so the failureReason says that plainly, + // which is what makes this recoverable by a human instead of mysterious. + if (expData.finalized) { + await docRef.update({ + status: "failed", + failureReason: + "This experiment was finalized while the upload was queued. The data was not lost -- " + + "download it from the queue panel on the dashboard -- but it cannot be added to a record " + + "that has already been sealed.", + ...CLEARED_CODE, + }); + return; + } + let auth: ResolvedAuth; try { const tokenResult = await resolveToken(userData, expData); @@ -223,6 +255,20 @@ async function processQueueItem(queueDoc: FirebaseFirestore.QueryDocumentSnapsho } } + // The retry worker is a writer too, so it observes the compaction gate for + // the same reason api-data.ts does: a backlog draining into a container + // mid-pass would grow the file count the pass is counting on staying still. + // Rescheduling rather than failing -- this is not an error, and it must not + // consume one of the entry's five attempts. + if (isCompactionInFlight(expData)) { + await docRef.update({ + status: "pending", + nextRetryAt: Timestamp.fromMillis(Date.now() + 60 * 1000), + failureReason: COMPACTION_HOLD_REASON, + }); + return; + } + // Attempt the upload try { const result = await provider.writeSessionFile( diff --git a/pages/admin/[experiment_id].js b/pages/admin/[experiment_id].js index a8a8ab5..f2a8a73 100644 --- a/pages/admin/[experiment_id].js +++ b/pages/admin/[experiment_id].js @@ -13,6 +13,7 @@ import ExperimentInfo from "../../components/dashboard/ExperimentInfo"; import ExperimentActive from "../../components/dashboard/ExperimentActive"; import ExperimentValidation from "../../components/dashboard/ExperimentValidation"; import MetadataControl from "../../components/dashboard/MetadataControl"; +import FinalizeControl from "../../components/dashboard/FinalizeControl"; import CodeHints from "../../components/dashboard/CodeHints"; import ErrorPanel from "../../components/dashboard/ErrorPanel"; import QueuePanel, { UploadsResolvedNotice } from "../../components/dashboard/QueuePanel"; @@ -168,6 +169,12 @@ function ExperimentPageDashboard({ experiment_id }) { </Popover.Root> </HStack> <MetadataControl data={data} /> + + <Separator my={5} borderColor="whiteAlpha.200" /> + <Text fontSize="xs" fontWeight="semibold" textTransform="uppercase" letterSpacing="wide" color="gray.500" mb={3}> + Finalize + </Text> + <FinalizeControl data={data} experimentId={experiment_id} /> </VStack> <VStack flex="1" minW="300px" align="stretch"> diff --git a/pages/faq.js b/pages/faq.js index 76c5454..a1c3bf0 100644 --- a/pages/faq.js +++ b/pages/faq.js @@ -105,6 +105,31 @@ export default function FAQ() { the automatic retries do not succeed. </Text> </FAQItem> + <FAQItem + value="item-2c" + question="Can I add or remove files myself while data collection is running?" + > + <Text mb={2}> + Please don't. While an experiment is active, treat its storage + location as belonging to DataPipe: let DataPipe write to it, and + download from it as much as you like, but avoid uploading, renaming, + or deleting files there yourself until collection is finished. + </Text> + <Text mb={2}> + DataPipe keeps its own record of which filenames it has already used, + so that a participant who submits twice cannot overwrite existing + data. Files that appear without DataPipe writing them are missing + from that record. Depending on the storage provider, the next + submission that happens to use the same name may then be silently + renamed or may overwrite what you added. + </Text> + <Text> + On providers with a limit on how many files one project can hold — + Zenodo allows 100 — DataPipe combines older sessions into archives to + stay under it. Files added by hand count toward that limit, and can + fill the project faster than DataPipe expects. + </Text> + </FAQItem> <FAQItem value="item-3" question="How much does it cost?"> <Text>DataPipe is free to use.</Text> </FAQItem> From f1f606c1819ce2b22b9bbddf3ee7acc085bb5ddf Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 14:02:53 -0400 Subject: [PATCH 082/181] fix: don't let a still-building index fail the compaction trigger `firebase deploy` submits index definitions and returns; the builds run asynchronously for minutes while the newly deployed functions are already serving. A query needing an index that has not finished building fails with FAILED_PRECONDITION. releaseHeldUploads runs on nearly every compaction path, including the common below-watermark one, and sat outside runCompaction's try/catch -- so during that window it would propagate out of compactExperiment, fail the Firestore trigger that called it, and be retried for up to seven days, for every experiment-document update on every capped-provider experiment near the watermark. It is an accelerator, not a correctness step: the entries it releases are already on the 60-second fast tier or will be retried regardless, and the compaction itself has committed by the time it runs. Losing it costs a slightly slower queue drain. So it is now best-effort and logs instead of throwing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/finalization-spec.md | 8 ++++++++ functions/src/compaction.ts | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/finalization-spec.md b/docs/finalization-spec.md index 3435819..6d82f55 100644 --- a/docs/finalization-spec.md +++ b/docs/finalization-spec.md @@ -203,6 +203,14 @@ runs as part of `firebase emulators:exec`, so this is testable locally. compaction/finalization queries. The Firestore emulator does not enforce composite indexes, so the suite passes without them and only a deploy proves the query shapes match. + + Note the CI deploy (`.github/workflows/firebase-deploy-test.yml`) runs + `firebase deploy --only firestore,functions,hosting`, so indexes and rules DO + go out — but `deploy` only SUBMITS index definitions and the builds run + asynchronously for minutes afterwards, with the new functions already live. + Any query needing a still-building index fails with FAILED_PRECONDITION in + that window. `releaseHeldUploads` is wrapped for exactly this reason; check + new index-dependent queries against the same hazard before adding them. - **The Cloud Tasks queue.** `firebase deploy` provisions a queue for an `onTaskDispatched` function, but that has not been exercised here. Confirm `finalizetask`'s queue exists and that `apiFinalize` can enqueue to it in the diff --git a/functions/src/compaction.ts b/functions/src/compaction.ts index 6990b21..89fb540 100644 --- a/functions/src/compaction.ts +++ b/functions/src/compaction.ts @@ -429,7 +429,29 @@ export async function compactExperiment(experimentID: string): Promise<Compactio // nothing to do, or failed, has still released its lease, so anything held // for it should stop waiting. if (result.status !== "leased-elsewhere") { - await releaseHeldUploads(experimentID); + // Best-effort, and deliberately NOT allowed to fail the caller. This is an + // accelerator: the entries it releases are already on the 60-second fast + // tier (CONTENTION) or will be retried anyway (QUOTA_EXCEEDED), so losing + // it costs a slightly slower drain and nothing else. + // + // The motivating case is a deploy. This query needs a composite index, and + // `firebase deploy` only SUBMITS index definitions -- builds run + // asynchronously for minutes afterwards while the new functions are + // already live. An unguarded throw here would propagate out of + // compactExperiment, fail the Firestore trigger that called it, and get + // retried for up to seven days -- on every experiment-document update for + // every capped-provider experiment near the watermark, for the whole build + // window. The compaction itself has already completed and committed by + // this point, so there is nothing to roll back and nothing to gain from + // failing loudly. + try { + await releaseHeldUploads(experimentID); + } catch (e) { + console.error( + `compaction: could not release held uploads for ${experimentID} (they will drain on their own): `, + e instanceof Error ? e.message : e + ); + } } return result; From de0636dbaf589d4722fff72589728ef2d00dc8dc Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 16:26:44 -0400 Subject: [PATCH 083/181] feat: point the test deployment at the Zenodo sandbox lib/provider-config.js hardcoded https://zenodo.org and rendered no server field, so connecting Zenodo on datapipe-test created real depositions on the live service using the researcher's real account. There was no way around it from the UI either: pasting a sandbox token is rejected at connect time, because the connect endpoint validates it against zenodo.org. NEXT_PUBLIC_ZENODO_ENV now selects the host the same way NEXT_PUBLIC_OSF_ENV already does -- a prefix, "" on production and "sandbox." on the test site. Both deploy workflows set it explicitly rather than relying on the default, so production cannot inherit a sandbox value by accident. No backend change was needed: zenodo.ts already allowlists both hosts and resolves the server from the stored connection, so this only decides where NEW connections point. containerLink now takes its host from the CONTAINER rather than a constant, matching dataverse's already-tested behavior. An experiment created before a deployment was switched still lives where it was created, so its link has to follow the data rather than today's configuration. The host is resolved once at module scope. defaultServerUrl is evaluated when the module loads and containerLink when it is called, so reading process.env separately in each let them disagree -- which a test caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .github/workflows/firebase-deploy-test.yml | 3 + .github/workflows/firebase-deploy.yml | 3 + __tests__/provider-config.test.js | 64 ++++++++++++++++++++++ lib/provider-config.js | 39 ++++++++++--- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index bf84a6a..ba8d073 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -20,6 +20,9 @@ env: NEXT_PUBLIC_GENERATE_STATE: "https://datapipe-test.web.app/api/generateoauthstate" NEXT_PUBLIC_BASE_URL: "https://datapipe-test.web.app" NEXT_PUBLIC_OSF_ENV: "" + # sandbox.zenodo.org. Without this the TEST site creates real + # depositions on the live Zenodo using the researcher's real account. + NEXT_PUBLIC_ZENODO_ENV: "sandbox." # Google Picker (folder selection for gdrive experiments). The API key is # restricted to the Picker API + our domains, so it is safe in the browser # bundle -- not a secret. The project number is public. diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml index c4f7538..edc1465 100644 --- a/.github/workflows/firebase-deploy.yml +++ b/.github/workflows/firebase-deploy.yml @@ -20,6 +20,9 @@ env: NEXT_PUBLIC_GENERATE_STATE: "https://pipe.jspsych.org/api/generateoauthstate" NEXT_PUBLIC_BASE_URL: "https://pipe.jspsych.org" NEXT_PUBLIC_OSF_ENV: "" + # Empty = the real zenodo.org. Set explicitly rather than relying on the + # default, so production never inherits a sandbox value by accident. + NEXT_PUBLIC_ZENODO_ENV: "" jobs: build: diff --git a/__tests__/provider-config.test.js b/__tests__/provider-config.test.js index 99d0098..7672ec8 100644 --- a/__tests__/provider-config.test.js +++ b/__tests__/provider-config.test.js @@ -86,3 +86,67 @@ describe("STORAGE_PROVIDERS.dataverse", () => { expect(STORAGE_PROVIDERS.dataverse.id).toBe("dataverse"); }); }); + +describe("zenodo: which Zenodo a deployment points at", () => { + // NEXT_PUBLIC_ZENODO_ENV is read at module scope, so each case has to + // re-import with the value already set. jest.resetModules + a dynamic + // require is the only way to exercise more than one deployment shape. + function loadWith(zenodoEnv) { + let mod; + jest.isolateModules(() => { + const prior = process.env.NEXT_PUBLIC_ZENODO_ENV; + if (zenodoEnv === undefined) { + delete process.env.NEXT_PUBLIC_ZENODO_ENV; + } else { + process.env.NEXT_PUBLIC_ZENODO_ENV = zenodoEnv; + } + mod = require("../lib/provider-config").STORAGE_PROVIDERS; + if (prior === undefined) { + delete process.env.NEXT_PUBLIC_ZENODO_ENV; + } else { + process.env.NEXT_PUBLIC_ZENODO_ENV = prior; + } + }); + return mod; + } + + it("points production at the real zenodo.org", () => { + expect(loadWith("").zenodo.defaultServerUrl).toBe("https://zenodo.org"); + }); + + it("points the test deployment at the sandbox", () => { + // The whole reason this setting exists: without it the test site creates + // real depositions on the live service using the researcher's real + // account. + expect(loadWith("sandbox.").zenodo.defaultServerUrl).toBe( + "https://sandbox.zenodo.org" + ); + }); + + it("falls back to production when the variable is absent entirely", () => { + // An unset variable must never resolve to something like + // "https://undefinedzenodo.org", and defaulting to sandbox would be worse + // -- a misconfigured production deploy would silently write nowhere real. + expect(loadWith(undefined).zenodo.defaultServerUrl).toBe("https://zenodo.org"); + }); + + it("containerLink follows the container's host, not the current deployment", () => { + // Same rule as dataverse above. An experiment created before a deployment + // was switched still lives where it was created, so its link has to follow + // the data rather than today's configuration. + const url = loadWith("sandbox.").zenodo.containerLink({ + providerContainer: { + serverUrl: "https://zenodo.org", + depositionId: 5551212, + }, + }); + expect(url).toBe("https://zenodo.org/deposit/5551212"); + }); + + it("containerLink falls back to the deployment host for a container with no serverUrl", () => { + const url = loadWith("sandbox.").zenodo.containerLink({ + providerContainer: { depositionId: 42 }, + }); + expect(url).toBe("https://sandbox.zenodo.org/deposit/42"); + }); +}); diff --git a/lib/provider-config.js b/lib/provider-config.js index 9eb4f2c..4584680 100644 --- a/lib/provider-config.js +++ b/lib/provider-config.js @@ -19,6 +19,18 @@ // and it must be kept in sync by hand with functions/src/providers/*.ts // whenever containerInput changes there. The duplication is deliberate: // lib/ is bundled into the Next.js app and cannot import from functions/src/. +// Which Zenodo this DEPLOYMENT points at: "" on production -> zenodo.org, +// "sandbox." on the test site -> sandbox.zenodo.org. Same host-prefix shape as +// NEXT_PUBLIC_OSF_ENV. +// +// Resolved once, here, rather than inline at each use. The two uses below are +// evaluated at different times -- defaultServerUrl when this module loads, +// containerLink when it is called -- so reading process.env separately in each +// would let them disagree. Next inlines NEXT_PUBLIC_* at build time so that +// cannot bite in the browser, but it does in tests, which is where it showed +// up. +const ZENODO_HOST = `https://${process.env.NEXT_PUBLIC_ZENODO_ENV ?? ""}zenodo.org`; + export const STORAGE_PROVIDERS = { gdrive: { id: "gdrive", @@ -73,14 +85,20 @@ export const STORAGE_PROVIDERS = { id: "zenodo", name: "Zenodo", authMethod: "static-token", - // NOT federated, unlike Dataverse: there is one production Zenodo. The - // connect endpoint still requires a serverUrl, so this fixed value is sent - // on the researcher's behalf and no field is rendered - // (see ProviderConnections.js's handleTokenConnect). The sandbox - // (sandbox.zenodo.org) is reachable by the spike script, which calls the - // adapter directly, so it needs no researcher-facing option here. + // NOT federated, unlike Dataverse: a researcher never picks an + // installation, so no field is rendered and this value is sent on their + // behalf (see ProviderConnections.js's handleTokenConnect). + // + // It is still per-DEPLOYMENT, which is what NEXT_PUBLIC_ZENODO_ENV + // controls: "" on production -> zenodo.org, "sandbox." on the test site -> + // sandbox.zenodo.org. Same host-prefix shape as NEXT_PUBLIC_OSF_ENV, and + // for the same reason -- without it the test deployment writes real + // depositions to the live service using the researcher's real account. + // The two Zenodos have entirely separate accounts and tokens, so a + // sandbox token pasted into a production-pointed deployment is rejected + // at connect time rather than silently misfiling data. needsServerUrl: false, - defaultServerUrl: "https://zenodo.org", + defaultServerUrl: ZENODO_HOST, tokenLabel: "Personal access token", tokenHelp: "Create one under Applications → Personal access tokens in your Zenodo account settings. It needs the deposit:write and deposit:actions scopes. Zenodo tokens do not expire.", @@ -88,8 +106,13 @@ export const STORAGE_PROVIDERS = { // Zenodo depositions stay unpublished while data is being collected, so // the researcher-facing link is the deposit editor rather than a public // record page (which does not exist until they publish). + // Host comes from the CONTAINER, not from the env above, matching + // dataverse's containerLink. The env only decides where NEW connections + // point; an experiment created before the deployment was switched still + // lives wherever it was created, and its link has to follow the data + // rather than the current configuration. containerLink: (exp) => - `https://zenodo.org/deposit/${exp.providerContainer?.depositionId}`, + `${exp.providerContainer?.serverUrl ?? ZENODO_HOST}/deposit/${exp.providerContainer?.depositionId}`, containerLabel: "Zenodo Deposition", containerLinkText: "Open deposition", // Mirrors functions/src/providers/zenodo.ts's containerInput exactly. From cd3c866a3f79094b4da3524f229eee522a081763 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 16:37:28 -0400 Subject: [PATCH 084/181] test: add spike gates for the three adapter methods never run live Compaction and finalization added deleteFile, writeStreamedFile and downloadFileBytes, and all three have only ever run against a local Express mock. Every bug the original Zenodo spike caught -- the 415 on content-type, the encoded-slash 404, the exceeding/exceeds regex miss -- was adapter-level and invisible to a mock built from the same assumptions as the code. F. deleteFile. Confirms a delete removes the object, and records what Zenodo returns for deleting a key that is already gone. The adapter reports that as SUCCESS deliberately, because compaction resumes an interrupted pass by re-deleting whatever is left; if Zenodo answers differently the resume path stops being idempotent. G. writeStreamedFile. The least-proven path in the feature, and finalization depends on it completely. Sends 2 MB in 64 KB chunks and checks three things separately: that undici accepts the stream at all, that the stored bytes match, and that the response still carries a checksum -- without one, compaction can never authorize deleting the originals. H. downloadFileBytes. Round-trips deliberately invalid UTF-8 and also reads the same object through downloadFile, so the corruption that motivated the second method stays visible rather than asserted. I. checksum format. The quietest failure available: a bucket PUT reports "md5:<hex>", and crash-resume compares the LISTING endpoint's checksum against a recorded md5. If the two disagree on format, every interrupted pass silently discards its archive and rebuilds instead of resuming, with nothing in the logs. F-I run before E, which fills the record to its 100-file cap and so must stay last. Noted in the header, since the first draft of this had them after E where they would have failed for lack of room. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- scripts/zenodo-spike.mjs | 149 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/scripts/zenodo-spike.mjs b/scripts/zenodo-spike.mjs index f99b17d..3f49364 100644 --- a/scripts/zenodo-spike.mjs +++ b/scripts/zenodo-spike.mjs @@ -17,7 +17,17 @@ // // Leaves the deposition UNPUBLISHED and never calls the publish action, so // nothing here mints a DOI. With cleanup on, the draft is deleted at the end. +// +// Gates A-E were the original migration gating questions. F-I cover the three +// adapter methods compaction and finalization added (deleteFile, +// writeStreamedFile, downloadFileBytes) plus the checksum-format assumption +// that crash-resume rests on -- none of which had ever run against a real +// Zenodo. +// +// ORDER MATTERS: gate E fills the record to its 100-file cap, so it runs LAST. +// Anything that needs room to write must come before it. +import { Readable } from "node:stream"; import { zenodoProvider } from "../functions/lib/providers/zenodo.js"; const token = process.env.ZENODO_TOKEN; @@ -182,6 +192,144 @@ async function main() { ); } + // ---- Gate F: does deleteFile actually delete, and what does a repeat say? - + // Compaction deletes the loose originals once their archive is verified, and + // it RESUMES an interrupted pass by re-deleting whatever is still there. The + // adapter therefore reports a 404 as SUCCESS on purpose. If Zenodo answers a + // delete-of-a-missing-key with something else, that resume path stops being + // idempotent and a half-finished pass wedges. + { + const body = payload("gate-f"); + await zenodoProvider.writeSessionFile(auth, container, "gate-f.json", body, meta(body)); + const before = (await zenodoProvider.listFiles(auth, container)).some((f) => f.name === "gate-f.json"); + + const del = await zenodoProvider.deleteFile(auth, container, { name: "gate-f.json" }); + const after = (await zenodoProvider.listFiles(auth, container)).some((f) => f.name === "gate-f.json"); + + // Raw call, because the adapter deliberately hides this status. The point + // of the gate is to learn what Zenodo really returns, not what we map it to. + const rawRepeat = await fetch(`${container.bucketUrl}/gate-f.json`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + const mappedRepeat = await zenodoProvider.deleteFile(auth, container, { name: "gate-f.json" }); + + const ok = before && del.success && !after && mappedRepeat.success; + record( + "F. deleteFile", + ok ? "PASS" : "FAIL", + `present-before=${before} delete.success=${del.success} gone-after=${!after}; ` + + `repeat delete raw status=${rawRepeat.status} -> adapter reports success=${mappedRepeat.success}` + + (mappedRepeat.success ? "" : " <-- resume path is NOT idempotent") + ); + } + + // ---- Gate G: can the bucket PUT take a STREAMED body? ------------------ + // The least-proven path in the whole feature, and finalization depends on it + // entirely: the merged archive is streamed so its size is bounded by Zenodo's + // 50 GB per-file limit rather than by function memory. Verified so far only + // against a local Express mock. + // + // Three separate questions, and a partial answer is still a failure: + // 1. does undici accept the stream at all (duplex: "half") + // 2. do the stored bytes match what we sent + // 3. does the response still carry the checksum -- compaction refuses to + // delete originals without one, so no checksum means no finalization + { + const CHUNK = 64 * 1024; + const streamed = Buffer.alloc(2 * 1024 * 1024); + for (let i = 0; i < streamed.length; i++) streamed[i] = (i * 31 + 7) & 0xff; + + async function* chunks() { + for (let off = 0; off < streamed.length; off += CHUNK) { + yield streamed.subarray(off, Math.min(off + CHUNK, streamed.length)); + } + } + + let verdict = "FAIL"; + let detail; + try { + const w = await zenodoProvider.writeStreamedFile( + auth, + container, + "gate-g.bin", + Readable.from(chunks()), + streamed.length, + { size: streamed.length, contentType: "application/zip" } + ); + if (!w.success) { + detail = `stream upload REFUSED: ${w.providerStatus} "${w.providerMessage}" -> ${w.error}`; + } else { + const back = await zenodoProvider.downloadFileBytes(auth, container, { name: "gate-g.bin" }); + const identical = back.success && Buffer.compare(back.content, streamed) === 0; + const hasChecksum = !!w.fileRef.checksum; + verdict = identical && hasChecksum ? "PASS" : "FAIL"; + detail = + `uploaded ${streamed.length} bytes in ${CHUNK}-byte chunks; ` + + `bytes-identical=${identical} checksum=${w.fileRef.checksum ?? "ABSENT"}` + + (hasChecksum ? "" : " <-- no checksum means compaction can never authorize a delete"); + } + } catch (e) { + detail = `stream upload THREW: ${e instanceof Error ? e.message : e}`; + } + record("G. writeStreamedFile", verdict, detail); + } + + // ---- Gate H: are binary bytes preserved on the way back? --------------- + // downloadFileBytes exists precisely because downloadFile decodes as UTF-8, + // which replaces every invalid sequence with U+FFFD. Compaction reads members + // back to build an archive and then deletes the originals, so a lossy read + // is silent, permanent corruption. This gate demonstrates the difference + // rather than asserting it, so the reason the method exists stays visible. + { + const raw = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00, 0x01, 0x80, 0xc0, 0xfd]); + await zenodoProvider.writeSessionFile(auth, container, "gate-h.bin", raw, { + size: raw.length, + contentType: "application/octet-stream", + }); + + const bytes = await zenodoProvider.downloadFileBytes(auth, container, { name: "gate-h.bin" }); + const text = await zenodoProvider.downloadFile(auth, container, { name: "gate-h.bin" }); + + const exact = bytes.success && Buffer.compare(bytes.content, raw) === 0; + const textLossy = !text.success || Buffer.compare(Buffer.from(text.content, "utf8"), raw) !== 0; + + record( + "H. downloadFileBytes", + exact ? "PASS" : "FAIL", + `bytes-identical=${exact}; downloadFile (text) lossy-as-expected=${textLossy}` + + (exact ? "" : " <-- binary round trip is corrupting data") + ); + } + + // ---- Gate I: is the listing checksum the same shape as the PUT's? ------ + // The quietest failure in the feature. A bucket PUT reports "md5:<hex>". + // Compaction's crash-resume re-reads the checksum from the LISTING endpoint + // and compares it to the md5 it recorded before uploading. If the two + // endpoints disagree on format, that comparison never matches, so every + // interrupted pass silently discards its archive and rebuilds from scratch + // instead of resuming -- no error, no failed write, nothing in the logs. + { + const body = payload("gate-i"); + const w = await zenodoProvider.writeSessionFile(auth, container, "gate-i.json", body, meta(body)); + const listed = (await zenodoProvider.listFiles(auth, container)).find((f) => f.name === "gate-i.json"); + + const putSum = w.success ? w.fileRef.checksum : undefined; + const listSum = listed?.checksum; + // Mirrors checksumMatches in compaction.ts, reimplemented rather than + // imported so this script stays free of firebase-admin. + const norm = (v) => (v ?? "").trim().toLowerCase().replace(/^md5:/, ""); + const comparable = !!putSum && !!listSum && norm(putSum) === norm(listSum); + + record( + "I. checksum format", + comparable ? "PASS" : "FAIL", + `PUT reports "${putSum ?? "ABSENT"}", listing reports "${listSum ?? "ABSENT"}"; ` + + `comparable after normalisation=${comparable}` + + (comparable ? "" : " <-- crash-resume will never match and will always rebuild") + ); + } + // ---- Gate E (opt-in): what happens at the 101st file? ----------------- // Confirms the cap is real and that the adapter maps the refusal to // QUOTA_EXCEEDED rather than a generic UNAVAILABLE -- the queue treats @@ -208,6 +356,7 @@ async function main() { console.log("\n[SKIP] E. 100-file cap (set ZENODO_CAP_TEST=1 to run)"); } + // ---- cleanup ---------------------------------------------------------- if (cleanup) { const del = await fetch(`${serverUrl}/api/deposit/depositions/${container.depositionId}`, { From f7e8ae841ca159f3fcf94fa58c0f3fd2ca6d82ce Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 16:51:00 -0400 Subject: [PATCH 085/181] fix: verify Zenodo deletes instead of trusting the status Gate F caught this on its first live run: Zenodo's bucket DELETE returns 500 INTERNAL SERVER ERROR while actually removing the object. Reproduced consistently -- 3/3 further deletes, all 500, all genuinely gone. This is a service BUG, not a contract. Zenodo documents 204 for its deposition-files delete and does not document the bucket delete this adapter uses at all, so there is no documented status to conform to. Matching reports: zenodo/zenodo#2502 and #2506. The consequences were real. sealAndDelete would have reported every file in a batch as undeleted, and worse, the saturation path deletes .psychds-ignore to free a slot for the archive and ABORTS if that reports failure -- so a full record would have refused to compact while having actually freed the slot. Special-casing 500 is wrong in both directions: hardcoding it as success would mask a genuine outage, and trusting it means believing a delete failed when it did not. So on any non-2xx, non-404 response the adapter now asks what is actually in the deposition, and reports success if the object is gone. Correct whether the bug is present, fixed, or intermittent; the check never runs once Zenodo returns 204. Gate F also under-reported -- it printed only delete.success, not the mapped status and message, which is what a diagnosis actually needs. Fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- functions/src/providers/zenodo.ts | 46 +++++++++++++++++++++++++++++++ scripts/zenodo-spike.mjs | 4 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/functions/src/providers/zenodo.ts b/functions/src/providers/zenodo.ts index a7cc415..64ba50c 100644 --- a/functions/src/providers/zenodo.ts +++ b/functions/src/providers/zenodo.ts @@ -761,6 +761,52 @@ export const zenodoProvider: StorageProvider = { } const mapped = await mapErrorResponse(response); + + // ZENODO RETURNS 500 FOR A DELETE THAT ACTUALLY SUCCEEDED -- and this is a + // SERVICE BUG, not a contract. Stated plainly because the distinction + // decides what to do about it. + // + // Observed live (sandbox, spike gate F, 2026-08-20): the object is gone + // from the deposition afterwards, but the response is "500 INTERNAL SERVER + // ERROR / The server encountered an internal error". Zenodo documents 204 + // for its deposition-files delete and does not document the bucket delete + // this method uses at ALL, so there is no documented status to conform to. + // Matching reports: zenodo/zenodo#2502 ("very inconsistent behaviour when + // deleting/uploading files") and #2506 (bucket API behaviour changed). + // + // Because it is a bug rather than a contract, it may be intermittent and + // it may be fixed without notice. That rules out special-casing 500, in + // either direction: hardcoding "500 means success here" would mask a real + // outage, and trusting the status means believing a delete failed when it + // did not. Verifying is the only option that is correct in all three + // worlds -- broken, fixed, or intermittent. When Zenodo does return 204, + // the check below never runs. + // + // The alternative would be switching to the DOCUMENTED endpoint, + // /api/deposit/depositions/{id}/files/{file_id}. Not done here because it + // needs the deposition-file UUID, and listFiles deliberately reports the + // KEY as the id (every other operation addresses objects by key). Worth + // revisiting if the bucket delete proves unreliable in other ways. + // + // Trusting the status is not an option. Compaction deletes a whole batch + // after verifying its archive, so it would report every single file as + // undeleted; worse, the saturation path deletes .psychds-ignore to free a + // slot for the archive and ABORTS if that reports failure -- so a full + // record would refuse to compact while having actually freed the slot. + // + // Treating 500 as success is not an option either: that would mask a + // genuine outage. So ask the provider what is actually there. This costs + // one extra listing, and only on a path that is already failing. + try { + const remaining = await zenodoProvider.listFiles(auth, container); + if (!remaining.some((file) => file.name === toZenodoKey(fileRef.name))) { + return { success: true }; + } + } catch { + // Listing failed too -- genuinely cannot tell, so report the original + // error rather than guessing in either direction. + } + return { success: false, error: mapped.error, diff --git a/scripts/zenodo-spike.mjs b/scripts/zenodo-spike.mjs index 3f49364..108e557 100644 --- a/scripts/zenodo-spike.mjs +++ b/scripts/zenodo-spike.mjs @@ -218,7 +218,9 @@ async function main() { record( "F. deleteFile", ok ? "PASS" : "FAIL", - `present-before=${before} delete.success=${del.success} gone-after=${!after}; ` + + `present-before=${before} delete.success=${del.success}` + + (del.success ? "" : ` (${del.providerStatus} "${del.providerMessage}" -> ${del.error})`) + + ` gone-after=${!after}; ` + `repeat delete raw status=${rawRepeat.status} -> adapter reports success=${mappedRepeat.success}` + (mappedRepeat.success ? "" : " <-- resume path is NOT idempotent") ); From be124124b1995ea847a20f3c82d35f6c8907891b Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 17:21:54 -0400 Subject: [PATCH 086/181] test: add a live end-to-end check against a deployed DataPipe The spike scripts verify adapter methods against a real provider; this covers the layer above them -- a full compaction cycle and finalization driven through the deployed HTTP API, including whether the Firestore triggers fire at all, which nothing local can test. Most of it needs no credentials. /api/data is public by design (participants' browsers post to it), so the load, the duplicate-rejection check and the post-finalization rejection are all observable from status codes alone. That matters most for the duplicate check: after compaction a filename exists ONLY inside the archive, so if the sealed claims were lost the resubmission is accepted as new and the duplicate quietly lands. It is the feature's quietest failure and it is fully detectable from outside. ZENODO_TOKEN/DEPOSITION_ID additionally enable the provider-side checks -- file count dropping, the batch archive appearing, and downloading it to confirm the data/raw/ paths that Zenodo's flat keyspace cannot hold. That uses the shipped readArchive, so the verification runs the same code compaction does. ID_TOKEN enables the finalize phase. Both degrade to explicit SKIPs rather than silently passing. Polls for the archive rather than assuming a submission count: how many files a submission produces depends on how many sidecar CSVs the data yields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- scripts/live-check.mjs | 239 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 scripts/live-check.mjs diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs new file mode 100644 index 0000000..88f88f5 --- /dev/null +++ b/scripts/live-check.mjs @@ -0,0 +1,239 @@ +// End-to-end check of compaction + finalization against a DEPLOYED DataPipe. +// +// Everything here drives the real HTTP API rather than the dashboard, because +// /api/data is a public endpoint by design (participants' browsers post to it) +// -- so the whole load and most of the verification needs no credentials at +// all. The parts that do are optional and skip cleanly. +// +// Usage: +// EXPERIMENT_ID=xxxx node scripts/live-check.mjs +// +// Env: +// EXPERIMENT_ID (required) an experiment on a capped provider, metadata ON +// BASE_URL (default https://datapipe-test.web.app) +// ZENODO_TOKEN (optional) sandbox token -- enables deposition-side checks +// DEPOSITION_ID (optional) required with ZENODO_TOKEN +// ZENODO_SERVER (default https://sandbox.zenodo.org) +// ID_TOKEN (optional) Firebase ID token -- enables the finalize phase +// MAX_SUBMISSIONS (default 60) safety stop +// +// Without ZENODO_TOKEN this still proves the load path, duplicate rejection +// after archiving, and post-finalization rejection -- all observable from the +// public API. What it cannot see is the archive's CONTENTS, which is the whole +// point of the feature, so supply the token if you can. + +import { readArchive } from "../functions/lib/archive-reader.js"; + +const BASE = process.env.BASE_URL || "https://datapipe-test.web.app"; +const EXP = process.env.EXPERIMENT_ID; +const ZTOKEN = process.env.ZENODO_TOKEN; +const DEPOSITION = process.env.DEPOSITION_ID; +const ZSERVER = process.env.ZENODO_SERVER || "https://sandbox.zenodo.org"; +const ID_TOKEN = process.env.ID_TOKEN; +const MAX_SUBMISSIONS = Number(process.env.MAX_SUBMISSIONS || 60); + +if (!EXP) { + console.error("EXPERIMENT_ID is required. See the header of this file."); + process.exit(1); +} + +const results = []; +const record = (step, verdict, detail) => { + results.push({ step, verdict, detail }); + console.log(`\n[${verdict}] ${step}\n ${detail}`); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const stamp = Date.now().toString(36); + +async function submit(filename, data) { + const res = await fetch(`${BASE}/api/data`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ experimentID: EXP, filename, data }), + }); + let body; + try { body = await res.json(); } catch { body = { raw: "<non-json>" }; } + return { status: res.status, body }; +} + +// Deposition-side view. Null when no token was supplied, so every caller has +// to treat "cannot see" as distinct from "saw nothing". +async function listDeposition() { + if (!ZTOKEN || !DEPOSITION) return null; + const res = await fetch(`${ZSERVER}/api/deposit/depositions/${DEPOSITION}/files`, { + headers: { Authorization: `Bearer ${ZTOKEN}` }, + }); + if (!res.ok) throw new Error(`deposition listing failed: ${res.status}`); + return (await res.json()).map((f) => f.filename ?? f.key); +} + +async function fetchArchive(name) { + const res = await fetch(`${ZSERVER}/api/files/${BUCKET}/${encodeURIComponent(name)}`, { + headers: { Authorization: `Bearer ${ZTOKEN}` }, + }); + if (!res.ok) throw new Error(`archive download failed: ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +let BUCKET = null; +async function resolveBucket() { + if (!ZTOKEN || !DEPOSITION) return; + const res = await fetch(`${ZSERVER}/api/deposit/depositions/${DEPOSITION}`, { + headers: { Authorization: `Bearer ${ZTOKEN}` }, + }); + if (res.ok) BUCKET = (await res.json())?.links?.bucket?.split("/").pop() ?? null; +} + +async function main() { + console.log(`Live check against ${BASE}\n experiment ${EXP}`); + await resolveBucket(); + console.log(` deposition ${DEPOSITION ?? "<not supplied -- Zenodo-side checks will skip>"}\n`); + + // ---- 1. does a single submission land? -------------------------------- + // Before anything else, because if the write path is broken every later + // signal is noise. Also the first evidence the compaction trigger fires -- + // it runs on the experiment document update this causes. + const before = await listDeposition(); + const first = await submit(`live-${stamp}-1.json`, JSON.stringify([{ trial: 1, rt: 401 }])); + record( + "1. single submission", + first.status === 201 ? "PASS" : "FAIL", + `HTTP ${first.status} ${JSON.stringify(first.body).slice(0, 160)}` + + (first.status === 202 ? " <-- QUEUED, not written; check the queue panel before continuing" : "") + ); + if (first.status !== 201) { + return finish(); + } + + if (before) { + await sleep(3000); + const after = await listDeposition(); + record( + "2. files appeared on the provider", + after.length > before.length ? "PASS" : "FAIL", + `deposition went ${before.length} -> ${after.length} files` + ); + } else { + console.log("\n[SKIP] 2. provider-side file count (no ZENODO_TOKEN/DEPOSITION_ID)"); + } + + // ---- 3. drive past the compaction watermark --------------------------- + // 80 files on Zenodo. A metadata-active submission writes 2 (raw + main CSV) + // when the data has no nested columns, so this is ~39 submissions -- but it + // polls rather than assuming, because sidecar count depends on the data. + const archived = []; + let submitted = 1; + let sawArchive = null; + for (let i = 2; i <= MAX_SUBMISSIONS; i++) { + const name = `live-${stamp}-${i}.json`; + const r = await submit(name, JSON.stringify([{ trial: 1, rt: 400 + i }])); + submitted++; + if (r.status !== 201) { + record("3. load", "FAIL", `submission ${i} returned HTTP ${r.status} ${JSON.stringify(r.body).slice(0, 120)}`); + break; + } + archived.push(name); + process.stdout.write("."); + await sleep(250); + + if (i % 5 === 0) { + const files = await listDeposition(); + if (files) { + const zip = files.find((f) => /^datapipe-batch-\d{4}\.zip$/.test(f)); + if (zip) { sawArchive = { zip, files }; break; } + } + } + } + console.log(""); + + if (ZTOKEN && DEPOSITION) { + // Compaction is asynchronous -- the trigger fires on the submission that + // crosses the watermark, so give it room to finish before judging. + for (let waited = 0; waited < 120 && !sawArchive; waited += 10) { + await sleep(10000); + const files = await listDeposition(); + const zip = files.find((f) => /^datapipe-batch-\d{4}\.zip$/.test(f)); + if (zip) sawArchive = { zip, files }; + } + record( + "3. compaction ran", + sawArchive ? "PASS" : "FAIL", + sawArchive + ? `after ${submitted} submissions: ${sawArchive.zip} present, ${sawArchive.files.length} files on the deposition` + : `after ${submitted} submissions no batch archive appeared -- check the onexperimentgrew logs` + ); + } + + // ---- 4. does the archive carry the Psych-DS tree? --------------------- + // The reason the feature exists. Zenodo cannot store a slash, so the paths + // only exist inside the zip. + if (sawArchive && BUCKET) { + const zip = await fetchArchive(sawArchive.zip); + const entries = readArchive(zip); + const names = [...entries.keys()]; + const nested = names.filter((n) => n.startsWith("data/raw/")); + record( + "4. Psych-DS paths inside the archive", + nested.length > 0 ? "PASS" : "FAIL", + `${entries.size} members; ${nested.length} under data/raw/ e.g. ${names.slice(0, 3).join(", ")}` + ); + } + + // ---- 5. are archived filenames still claimed? ------------------------- + // The silent one. These files now exist ONLY inside the zip, so if the + // sealed claims were lost the resubmission below is accepted as new and the + // duplicate quietly lands. + if (sawArchive) { + const loose = sawArchive.files; + const gone = archived.find((n) => !loose.includes(n)); + if (gone) { + const dup = await submit(gone, JSON.stringify([{ trial: 1 }])); + record( + "5. duplicate rejected after archiving", + dup.status === 400 ? "PASS" : "FAIL", + `resubmitted ${gone} (now only inside the archive) -> HTTP ${dup.status}` + + (dup.status === 201 ? " <-- ACCEPTED. Sealed claims are not working; this is data loss." : "") + ); + } + } + + // ---- 6. finalize ------------------------------------------------------ + if (ID_TOKEN) { + const res = await fetch(`${BASE}/api/finalize`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${ID_TOKEN}` }, + body: JSON.stringify({ experimentID: EXP }), + }); + const body = await res.text(); + record( + "6. finalize accepted", + res.status === 202 ? "PASS" : "FAIL", + `HTTP ${res.status} ${body.slice(0, 200)}` + ); + + if (res.status === 202) { + await sleep(60000); + const post = await submit(`live-${stamp}-after-final.json`, JSON.stringify([{ trial: 1 }])); + record( + "7. finalized experiment rejects submissions", + post.status === 400 ? "PASS" : "FAIL", + `HTTP ${post.status} ${JSON.stringify(post.body).slice(0, 160)}` + ); + } + } else { + console.log("\n[SKIP] 6-7. finalize (no ID_TOKEN)"); + } + + finish(); +} + +function finish() { + console.log("\n==== SUMMARY ===="); + for (const r of results) console.log(`${r.verdict.padEnd(6)} ${r.step}`); + process.exit(results.some((r) => r.verdict === "FAIL") ? 1 : 0); +} + +main().catch((e) => { + console.error("\nLive check aborted:", e); + process.exit(1); +}); From d515d39fbd8c6ee855dfa88a938fab2fc1e10de4 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 17:41:48 -0400 Subject: [PATCH 087/181] fix: do not hold the compaction lease while deciding there is no work Found by the first live run. An experiment sitting at ~22 of 100 files started returning 202-queued to participants with failureReason "Compaction in progress" while nothing was being compacted at all. compactExperiment is called from a Firestore trigger on EVERY submission, and it acquired the lease before deciding whether there was anything to do -- holding it across a token resolve and a listFiles round trip to the provider. The lease is not a bookkeeping marker: it is what makes compaction-gate.ts divert live submissions into the upload queue. So every participant was paying for a question whose answer is almost always no, and under the burst profile in requirement 6 a large share of submissions would have been diverted. No data was at risk -- the queue is durable and drains on the 60-second fast tier -- but people were being told to wait on a no-op. Everything up to the decision is now read-only and lease-free. The lease is taken only when there is genuinely work, and the listing is retaken under it so no decision rests on state that can still move. The no-work path records what it saw through a new noteCheck rather than releaseLease. Reusing releaseLease would clear compactingUntil and cancel a lease this call never owned -- a worse bug than the one being fixed. Emulator tests could not have caught this: they call compactExperiment sequentially, so nothing ever races a no-op check. The regressions added here test the property that matters instead -- that the write gate stays OPEN during a check, sampled through the same field isCompactionInFlight reads, and that a real pass still closes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/compaction-emulator.test.js | 71 +++++++++++++++++ functions/src/compaction.ts | 78 +++++++++++++++---- scripts/live-check.mjs | 26 +++++-- 3 files changed, 156 insertions(+), 19 deletions(-) diff --git a/functions/src/__tests__/compaction-emulator.test.js b/functions/src/__tests__/compaction-emulator.test.js index f9bf7be..d9770da 100644 --- a/functions/src/__tests__/compaction-emulator.test.js +++ b/functions/src/__tests__/compaction-emulator.test.js @@ -1107,3 +1107,74 @@ describe("C10. the write gate", () => { await db.collection("uploadQueue").doc(queueDocId).delete(); }); }); + +describe("C11. a no-work check must not hold the lease", () => { + // Regression for a live failure (2026-08-20). compactExperiment is called + // from a Firestore trigger on EVERY submission, and it used to acquire the + // lease before deciding whether there was anything to do -- holding it + // across a token resolve and a provider listing. Because the lease is what + // makes compaction-gate.ts divert submissions into the upload queue, live + // participants started getting 202-queued with "Compaction in progress" + // while the record sat at ~22 of 100 files and nothing was being compacted. + // + // Emulator tests missed it because they call compactExperiment sequentially, + // so nothing ever races a no-op check. + + it("leaves no lease behind when there is nothing to compact", async () => { + const { experimentID } = await seedExperiment({ sessionCount: 20 }); + + const result = await compactExperiment(experimentID); + expect(result.status).toBe("below-watermark"); + + const after = (await db.collection("experiments").doc(experimentID).get()).data(); + expect(after.compaction.compactingUntil).toBeUndefined(); + // It still records what it saw, so the trigger's cheap pre-filter converges. + expect(after.compaction.lastFileCount).toBe(22); + expect(after.compaction.sessionsAtLastCheck).toBe(20); + }); + + it("does not close the write gate while merely checking", async () => { + // The property that actually matters to a participant: a submission + // arriving during a no-work check must still be written, not queued. + const { experimentID } = await seedExperiment({ sessionCount: 20 }); + const expRef = db.collection("experiments").doc(experimentID); + + const gateClosedDuringCheck = []; + // Poll the gate while the check runs. isCompactionInFlight reads exactly + // this field, so sampling it is sampling what api-data.ts would decide. + const poller = setInterval(async () => { + const snap = await expRef.get(); + const until = snap.data()?.compaction?.compactingUntil; + if (until && until.toMillis() > Date.now()) gateClosedDuringCheck.push(true); + }, 15); + + await compactExperiment(experimentID); + clearInterval(poller); + await new Promise((r) => setTimeout(r, 50)); + + expect(gateClosedDuringCheck).toHaveLength(0); + }); + + it("still takes the lease when there IS work", async () => { + // The guard must not have been achieved by never leasing at all -- a real + // pass has to close the gate, or submissions would land mid-compaction. + const { experimentID } = await seedExperiment(); + const expRef = db.collection("experiments").doc(experimentID); + + let sawLease = false; + const poller = setInterval(async () => { + const snap = await expRef.get(); + const until = snap.data()?.compaction?.compactingUntil; + if (until && until.toMillis() > Date.now()) sawLease = true; + }, 15); + + const result = await compactExperiment(experimentID); + clearInterval(poller); + + expect(result.status).toBe("compacted"); + expect(sawLease).toBe(true); + // ...and released afterwards. + const after = (await expRef.get()).data(); + expect(after.compaction.compactingUntil).toBeUndefined(); + }); +}); diff --git a/functions/src/compaction.ts b/functions/src/compaction.ts index 89fb540..57bf5d7 100644 --- a/functions/src/compaction.ts +++ b/functions/src/compaction.ts @@ -378,6 +378,25 @@ export async function acquireLease(experimentID: string): Promise<boolean> { }); } +/** + * Records that an experiment was examined, without touching the lease. + * + * Deliberately not releaseLease with an empty patch: that clears + * compactingUntil, which on the no-work path would cancel a lease this call + * never owned. + */ +async function noteCheck( + experimentID: string, + sessionsSeen: number, + fileCount: number +): Promise<void> { + await experimentRef(experimentID).update({ + "compaction.lastCheckedAt": Timestamp.now(), + "compaction.sessionsAtLastCheck": sessionsSeen, + "compaction.lastFileCount": fileCount, + }); +} + /** * Ends a pass, recording what it observed. * @@ -538,24 +557,57 @@ async function runCompaction(experimentID: string): Promise<Omit<CompactionResul return { status: "nothing-to-archive", detail: "experiment has no collision-cache salt" }; } + // SURVEY FIRST, AND WITHOUT THE LEASE. + // + // The lease is not a "I am looking at this experiment" marker -- it is what + // makes compaction-gate.ts divert live submissions into the upload queue. + // Holding it to decide whether there is any work therefore charges every + // participant for a question whose answer is almost always "no". + // + // That is not theoretical: this function is called from a Firestore trigger + // on EVERY submission, and it used to take the lease before resolving a + // token and listing the provider's files. Live run 2026-08-20, an + // experiment sitting at ~22 of 100 files: submissions started coming back + // 202-queued with failureReason "Compaction in progress" while nothing was + // being compacted at all. No data was lost -- that is what the queue is for + // -- but participants were being told to wait on a no-op. + // + // So everything up to the decision is read-only and lease-free. The lease is + // taken only once there is genuinely something to do. + const userSnap = await db.collection("users").doc(expData.owner).get(); + if (!userSnap.exists) { + return { status: "failed", detail: "owner record missing" }; + } + const tokenResult = await resolveToken(userSnap.data() as UserData, expData); + if (!tokenResult.success) { + return { status: "failed", detail: `token resolution failed: ${tokenResult.error}` }; + } + const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; + const container = expData.providerContainer as ContainerRef; + + const survey = await provider.listFiles(auth, container); + + // An interrupted pass has to be finished even below the watermark, and + // finishing it mutates state -- so that path always needs the lease. + const interrupted = !( + await batchesCollection(experimentID).where("status", "==", "uploading").limit(1).get() + ).empty; + + if (!interrupted && survey.length < Math.floor(cap * WATERMARK_RATIO)) { + // Recorded WITHOUT releaseLease: that helper clears compactingUntil, and + // calling it here would stomp a lease belonging to somebody else's pass. + await noteCheck(experimentID, sessionsSeen, survey.length); + return { status: "below-watermark", fileCountBefore: survey.length }; + } + if (!(await acquireLease(experimentID))) { return { status: "leased-elsewhere" }; } try { - const userSnap = await db.collection("users").doc(expData.owner).get(); - if (!userSnap.exists) { - await releaseLease(experimentID, sessionsSeen); - return { status: "failed", detail: "owner record missing" }; - } - const tokenResult = await resolveToken(userSnap.data() as UserData, expData); - if (!tokenResult.success) { - await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": tokenResult.error }); - return { status: "failed", detail: `token resolution failed: ${tokenResult.error}` }; - } - const auth: ResolvedAuth = { token: tokenResult.token, serverUrl: tokenResult.serverUrl }; - const container = expData.providerContainer as ContainerRef; - + // Re-listed under the lease. The survey above was taken without one, so + // submissions could have landed in between; every decision from here on + // has to be made against state that can no longer move. const files = await provider.listFiles(auth, container); // Resume before anything else: an interrupted pass left an archive that diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs index 88f88f5..36c8fdc 100644 --- a/scripts/live-check.mjs +++ b/scripts/live-check.mjs @@ -15,7 +15,8 @@ // DEPOSITION_ID (optional) required with ZENODO_TOKEN // ZENODO_SERVER (default https://sandbox.zenodo.org) // ID_TOKEN (optional) Firebase ID token -- enables the finalize phase -// MAX_SUBMISSIONS (default 60) safety stop +// REQUIRED_FIELDS (default trial_type) must match the experiment's setting +// MAX_SUBMISSIONS (default 100) safety stop // // Without ZENODO_TOKEN this still proves the load path, duplicate rejection // after archiving, and post-finalization rejection -- all observable from the @@ -30,7 +31,7 @@ const ZTOKEN = process.env.ZENODO_TOKEN; const DEPOSITION = process.env.DEPOSITION_ID; const ZSERVER = process.env.ZENODO_SERVER || "https://sandbox.zenodo.org"; const ID_TOKEN = process.env.ID_TOKEN; -const MAX_SUBMISSIONS = Number(process.env.MAX_SUBMISSIONS || 60); +const MAX_SUBMISSIONS = Number(process.env.MAX_SUBMISSIONS || 100); if (!EXP) { console.error("EXPERIMENT_ID is required. See the header of this file."); @@ -45,6 +46,19 @@ const record = (step, verdict, detail) => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const stamp = Date.now().toString(36); +// Rows must satisfy the experiment's requiredFields when useValidation is on, +// or /api/data rejects the submission before the provider is ever touched -- +// which would look like a write-path failure rather than a fixture problem. +// Comma-separated, matching the experiment's own setting. +const REQUIRED = (process.env.REQUIRED_FIELDS || "trial_type") + .split(",").map((f) => f.trim()).filter(Boolean); +const row = (i) => { + const r = { trial_index: i, rt: 400 + i }; + for (const f of REQUIRED) r[f] = r[f] ?? `probe-${f}`; + return r; +}; +const sample = (i) => JSON.stringify([row(i)]); + async function submit(filename, data) { const res = await fetch(`${BASE}/api/data`, { method: "POST", @@ -94,7 +108,7 @@ async function main() { // signal is noise. Also the first evidence the compaction trigger fires -- // it runs on the experiment document update this causes. const before = await listDeposition(); - const first = await submit(`live-${stamp}-1.json`, JSON.stringify([{ trial: 1, rt: 401 }])); + const first = await submit(`live-${stamp}-1.json`, sample(1)); record( "1. single submission", first.status === 201 ? "PASS" : "FAIL", @@ -126,7 +140,7 @@ async function main() { let sawArchive = null; for (let i = 2; i <= MAX_SUBMISSIONS; i++) { const name = `live-${stamp}-${i}.json`; - const r = await submit(name, JSON.stringify([{ trial: 1, rt: 400 + i }])); + const r = await submit(name, sample(i)); submitted++; if (r.status !== 201) { record("3. load", "FAIL", `submission ${i} returned HTTP ${r.status} ${JSON.stringify(r.body).slice(0, 120)}`); @@ -187,7 +201,7 @@ async function main() { const loose = sawArchive.files; const gone = archived.find((n) => !loose.includes(n)); if (gone) { - const dup = await submit(gone, JSON.stringify([{ trial: 1 }])); + const dup = await submit(gone, sample(0)); record( "5. duplicate rejected after archiving", dup.status === 400 ? "PASS" : "FAIL", @@ -213,7 +227,7 @@ async function main() { if (res.status === 202) { await sleep(60000); - const post = await submit(`live-${stamp}-after-final.json`, JSON.stringify([{ trial: 1 }])); + const post = await submit(`live-${stamp}-after-final.json`, sample(0)); record( "7. finalized experiment rejects submissions", post.status === 400 ? "PASS" : "FAIL", From 7126c0cb93f35b8f1b90532a709d453afadc11f3 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 17:54:34 -0400 Subject: [PATCH 088/181] test: a 202 during compaction is correct, not a load failure The first successful live run reported FAIL on submission 31, which returned 202 while a real compaction pass was in flight. That is the write gate doing exactly its job: the lease diverts live submissions into the durable queue so the file count cannot move while the pass decides what to archive. The participant is unaffected -- the payload reaches Cloud Storage before the response returns. Aborting the load there also cut the run short and made the script report a mid-flight file count, since it stopped polling between "archive uploaded" and "originals deleted". So a 202 now continues the run, and a new phase checks the property that actually matters: that everything diverted eventually LANDS. It does that through the public API alone, by resubmitting each queued filename -- a stored file must come back 400 as a duplicate, and a 201 would mean the payload never made it and the name is free again, which is silent data loss. Observed for real on this run: the one diverted submission drained to "completed" about four minutes later, which is the behaviour this now asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- scripts/live-check.mjs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs index 36c8fdc..27890b4 100644 --- a/scripts/live-check.mjs +++ b/scripts/live-check.mjs @@ -136,12 +136,24 @@ async function main() { // when the data has no nested columns, so this is ~39 submissions -- but it // polls rather than assuming, because sidecar count depends on the data. const archived = []; + const queued = []; let submitted = 1; let sawArchive = null; for (let i = 2; i <= MAX_SUBMISSIONS; i++) { const name = `live-${stamp}-${i}.json`; const r = await submit(name, sample(i)); submitted++; + if (r.status === 202) { + // NOT a failure. A compaction pass holds a lease that diverts live + // submissions into the durable queue, precisely so the file count cannot + // move while the pass decides what to archive. The participant is + // unaffected -- the payload is in Cloud Storage before this returns. + // What matters is that it later LANDS, which is checked after the run. + queued.push(name); + process.stdout.write("q"); + await sleep(250); + continue; + } if (r.status !== 201) { record("3. load", "FAIL", `submission ${i} returned HTTP ${r.status} ${JSON.stringify(r.body).slice(0, 120)}`); break; @@ -211,6 +223,30 @@ async function main() { } } + // ---- 5b. did everything the gate diverted actually land? -------------- + // Uses no credentials: resubmitting a filename that was successfully stored + // must be REJECTED as a duplicate. A 201 here would mean the queued payload + // never made it and the name is free again -- silent data loss. + if (queued.length > 0) { + console.log(`\n ${queued.length} submission(s) were diverted to the queue; waiting for the retry worker...`); + // The retry worker runs on a 5-minute cadence and these sit on the + // 60-second fast tier, so this is a wait, not a poll-forever. + await sleep(6 * 60 * 1000); + const lost = []; + for (const name of queued) { + const again = await submit(name, sample(0)); + if (again.status !== 400) lost.push(`${name} -> HTTP ${again.status}`); + await sleep(250); + } + record( + "5b. diverted submissions landed", + lost.length === 0 ? "PASS" : "FAIL", + lost.length === 0 + ? `all ${queued.length} queued submission(s) are now stored (resubmission rejected as duplicate)` + : `NOT stored: ${lost.join("; ")} <-- queued data was lost` + ); + } + // ---- 6. finalize ------------------------------------------------------ if (ID_TOKEN) { const res = await fetch(`${BASE}/api/finalize`, { From f26cd68832537877c127aaa73e815bdde1acbc37 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 18:38:17 -0400 Subject: [PATCH 089/181] fix: put dataset_description.json inside the finalized archive Found by inspecting a real finalized deposition rather than by a test. The merged archive carried the whole data/raw/... tree but no dataset_description.json at all, because finalization excluded it in order to leave a descriptor visible on the record. That produced an artifact valid in NEITHER view. Psych-DS requires the descriptor at the dataset root, and the archive is the dataset -- it is the only place the directory tree exists, since Zenodo cannot store a slash in a key. So the zip was not a valid Psych-DS dataset. Nor was the record around it, whose data is sealed inside a zip. Half the spec was met by unzipping and half by not. The descriptor now goes inside the archive AND stays loose on the record. Inside because the archive is the dataset; loose because the record should show a human-readable descriptor without anyone downloading an archive, and metadata-block.ts holds a metadataFileRef to that object. Duplicating it is safe only because finalization is TERMINAL: the experiment stops accepting submissions, so nothing can update one copy and leave the other stale. That is not true during collection, which is why compaction still keeps it strictly out of its batches (NEVER_ARCHIVE in compaction.ts). Sealing now covers only what actually leaves the listing. The descriptor stays loose, so a cold cache rehydrates its claim normally and it needs no seal. F1 asserted the old behaviour explicitly; updated, along with the entry count it checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/finalization-spec.md | 14 ++++- .../__tests__/finalization-emulator.test.js | 63 +++++++++++++++++-- functions/src/finalization.ts | 46 ++++++++++---- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/docs/finalization-spec.md b/docs/finalization-spec.md index 6d82f55..91372d7 100644 --- a/docs/finalization-spec.md +++ b/docs/finalization-spec.md @@ -20,8 +20,18 @@ files into ONE archive carrying the full Psych-DS tree, leaving be triggered by our own memory ceiling — see Phase 2. 3. **`.psychds-ignore` goes INSIDE the final archive** and its loose copy is deleted. Nothing regenerates it once submissions stop. - `dataset_description.json` stays loose so the record still shows a - descriptor. + **`dataset_description.json` goes inside the archive AND stays loose.** + Corrected 2026-08-20 after inspecting a real finalized deposition: the + original "stays loose only" rule left the archive with no descriptor at all, + so the zip was not a valid Psych-DS dataset (the spec requires it at the + dataset root) and neither was the record around it, whose data is sealed in + a zip — half the spec met by unzipping and half by not. It is inside because + the archive IS the dataset, the only place the `data/raw/...` tree exists; + it is also loose so the record shows a human-readable descriptor and + metadata-block.ts's `metadataFileRef` still resolves. Duplicating it is safe + only because finalization is terminal — nothing can update one copy and + leave the other stale, which is why compaction still keeps it strictly out + of its batches during collection. 4. **Publishing / minting a DOI is out of scope.** Irreversible, and the researcher's call. diff --git a/functions/src/__tests__/finalization-emulator.test.js b/functions/src/__tests__/finalization-emulator.test.js index 92ff42a..74b0040 100644 --- a/functions/src/__tests__/finalization-emulator.test.js +++ b/functions/src/__tests__/finalization-emulator.test.js @@ -390,15 +390,20 @@ describe("F1. the full merge", () => { expect(result.archived).toBe(5); const entries = readZipEntries(finalArchiveBytes()); - // 3 (batch1) + 2 (batch2) + 2 loose + .psychds-ignore = 8 exploded entries. - expect(entries.size).toBe(8); + // 3 (batch1) + 2 (batch2) + 2 loose + .psychds-ignore + the descriptor + // = 9 exploded entries. Note `archived` above is 5 and this is 9: the + // first counts top-level provider files REMOVED, the second counts what + // ends up inside the archive -- and the descriptor is in the archive + // without being removed. + expect(entries.size).toBe(9); for (let i = 1; i <= 7; i += 1) { expect(entries.has(`data/raw/subject-${i}.json`)).toBe(true); } expect(entries.has(".psychds-ignore")).toBe(true); - // The record's descriptor is excluded from the merge and never appears - // inside the archive. - expect(entries.has("dataset_description.json")).toBe(false); + // The descriptor IS inside the archive: Psych-DS requires it at the + // dataset root, and the archive is the dataset (it is the only place the + // data/raw/... tree exists). See F11. + expect(entries.has("dataset_description.json")).toBe(true); }); it("leaves dataset_description.json loose and moves .psychds-ignore inside the archive", async () => { @@ -1037,3 +1042,51 @@ describe("F10. archive-too-large", () => { expect(expData.compaction?.compactingUntil).toBeUndefined(); }); }); + +describe("F11. the merged archive is a valid Psych-DS dataset on its own", () => { + // Caught by inspecting a real finalized deposition. The archive contained + // the whole data/raw/... tree but NO dataset_description.json, because + // finalization excluded it to leave a descriptor visible on the record. + // + // That produced an artifact valid in neither view: the zip was not a + // Psych-DS dataset (the spec requires the descriptor at the dataset root) + // and neither was the record around it, whose data is sealed inside a zip. + // Half the spec met by unzipping and half by not. + + it("contains dataset_description.json at the archive root", async () => { + const { experimentID } = await seedFinalizableExperiment(); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + + const entries = readZipEntries(finalArchiveBytes()); + expect([...entries.keys()]).toContain("dataset_description.json"); + // And it is the real descriptor, not an empty placeholder. + expect(JSON.parse(entries.get("dataset_description.json").toString("utf8"))).toBeTruthy(); + }); + + it("also leaves a copy loose on the record", async () => { + // Both, not either. A visitor should see a descriptor without downloading + // an archive, and metadata-block.ts holds a metadataFileRef to that + // object. + const { experimentID } = await seedFinalizableExperiment(); + await finalizeExperiment(experimentID); + + expect(mock.has("dataset_description.json")).toBe(true); + expect(mock.keys().sort()).toEqual(["dataset_description.json", "datapipe-final.zip"].sort()); + }); + + it("the two copies are identical, since nothing can update either afterwards", async () => { + // Duplication is only safe because finalization is terminal. If the + // experiment could still accept submissions, metadata-block.ts would + // update the loose copy and the archived one would silently go stale. + const { experimentID } = await seedFinalizableExperiment(); + const before = mock.get("dataset_description.json").toString("utf8"); + + await finalizeExperiment(experimentID); + + const entries = readZipEntries(finalArchiveBytes()); + expect(entries.get("dataset_description.json").toString("utf8")).toBe(before); + expect(mock.get("dataset_description.json").toString("utf8")).toBe(before); + }); +}); diff --git a/functions/src/finalization.ts b/functions/src/finalization.ts index 6f85242..2a117bf 100644 --- a/functions/src/finalization.ts +++ b/functions/src/finalization.ts @@ -54,13 +54,27 @@ import { // there is never more than one merged archive to name. const FINAL_ARCHIVE_NAME = "datapipe-final.zip"; -// The only file finalization ever leaves loose. Same reason compaction never -// sweeps it into a batch (see NEVER_ARCHIVE in compaction.ts): it is the -// record's live Psych-DS descriptor, and metadata-block.ts holds a -// metadataFileRef to it that a buried copy would break. Everything else -- -// every batch archive, every loose session file, and .psychds-ignore -- is a -// member of the merge. -const EXCLUDE_FROM_MERGE = new Set(["dataset_description.json"]); +// dataset_description.json goes INSIDE the merged archive AND stays loose on +// the record. Both, deliberately. +// +// Inside, because Psych-DS requires it at the dataset root. The merged archive +// IS the dataset -- it is the only place the data/raw/... tree exists, since +// Zenodo cannot store a slash in a key. An archive without the descriptor is +// not a valid Psych-DS dataset, and neither is the record around it, whose +// data is sealed in a zip. Leaving it only on the outside produced an artifact +// that validated in neither view: half the spec met by unzipping and half by +// not. +// +// Loose, because the record should still show a human-readable descriptor +// without anyone downloading an archive, and because metadata-block.ts holds a +// metadataFileRef to that object. +// +// Duplicating it is safe here specifically because finalization is TERMINAL: +// the experiment stops accepting submissions, so nothing can update one copy +// and leave the other stale. That would not be safe during collection, which +// is why compaction still keeps it strictly out of its batches +// (NEVER_ARCHIVE in compaction.ts). +const KEEP_LOOSE_AFTER_MERGE = new Set(["dataset_description.json"]); export interface FinalizationResult { // Carried on every result the same way CompactionResult carries it, so log @@ -245,9 +259,12 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR }; } - const members = files.filter((file) => !EXCLUDE_FROM_MERGE.has(file.name)); + // Everything goes into the archive; only some of it is then removed from + // the record. See KEEP_LOOSE_AFTER_MERGE. + const members = files; + const removable = files.filter((file) => !KEEP_LOOSE_AFTER_MERGE.has(file.name)); - if (members.length === 0) { + if (removable.length === 0) { // Nothing was ever collected beyond the descriptor -- finalizing is // still the correct terminal state (the study is over either way), just // with no archive to build. @@ -289,7 +306,10 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR return { status: "archive-too-large", detail }; } - const memberHashes = members.map((file) => claimDocId(salt, file.name)); + // Hashes cover only what is actually leaving the listing. The descriptor + // stays loose, so a cold cache still rehydrates its claim normally and it + // needs no sealing. + const memberHashes = removable.map((file) => claimDocId(salt, file.name)); const runRef = finalizationRunRef(experimentID); // Recorded BEFORE the provider upload -- this is the crash-safety hinge. @@ -303,7 +323,7 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR status: "uploading", memberHashes, expectedMd5: build.md5, - fileCount: members.length, + fileCount: removable.length, createdAt: Timestamp.now(), }); @@ -317,12 +337,12 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR // Everything in the merge is now inside a verified archive on the // provider, so the originals can go. - await sealAndDeleteMembers(experimentID, provider, auth, container, runRef, memberHashes, members); + await sealAndDeleteMembers(experimentID, provider, auth, container, runRef, memberHashes, removable); await deleteStorageObject(storagePath); await markFinalized(experimentID, sessionsSeen); - return { status: "finalized", archived: members.length, archiveName: FINAL_ARCHIVE_NAME }; + return { status: "finalized", archived: removable.length, archiveName: FINAL_ARCHIVE_NAME }; } catch (e) { const detail = e instanceof Error ? e.message : String(e); await releaseLease(experimentID, sessionsSeen, { "compaction.lastError": detail }).catch(() => undefined); From eda754779fc4b201eaf35292dc635a0a61950ab1 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 19:05:33 -0400 Subject: [PATCH 090/181] fix: finalize to a single self-contained archive Follow-up to the previous commit, which put dataset_description.json inside the archive but also kept a loose copy on the record. The duplication was there so a visitor could read a descriptor without downloading an archive -- and that justification does not survive checking. Zenodo has a built-in zip previewer that lists archive contents on the record page (zenodo/zenodo#812 exists to fix its handling of split zips), so putting the descriptor inside hides nothing. Psych-DS has no requirement that it live outside the dataset either. So the descriptor is now only inside, and the record ends as exactly one file: the merged archive. A second copy is a second thing that can be wrong. Nothing reads the loose copy: metadata-block.ts owns the metadataFileRef pointing at it, and that only runs during a submission, which a finalized experiment rejects. Sealing now covers every archived filename including the descriptor's, since everything leaves the provider listing -- a cold cache rehydrating from a listing containing only the archive would otherwise forget every filename the study used. "Nothing collected" is judged on DATA rather than file count, so an experiment that only ever got a descriptor still finalizes without wrapping a lone descriptor in an archive. The test helper that mirrors the merge was reproducing the old selection, so it is fixed at the source rather than by adjusting the counts it feeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/finalization-spec.md | 36 ++++++---- .../__tests__/finalization-emulator.test.js | 71 +++++++++++-------- functions/src/finalization.ts | 51 +++++++------ 3 files changed, 87 insertions(+), 71 deletions(-) diff --git a/docs/finalization-spec.md b/docs/finalization-spec.md index 91372d7..dacb62d 100644 --- a/docs/finalization-spec.md +++ b/docs/finalization-spec.md @@ -18,20 +18,28 @@ files into ONE archive carrying the full Psych-DS tree, leaving but they break Psych-DS compatibility, so they are a last resort reachable only above a provider's hard per-file limit (Zenodo: 50 GB). They must never be triggered by our own memory ceiling — see Phase 2. -3. **`.psychds-ignore` goes INSIDE the final archive** and its loose copy is - deleted. Nothing regenerates it once submissions stop. - **`dataset_description.json` goes inside the archive AND stays loose.** - Corrected 2026-08-20 after inspecting a real finalized deposition: the - original "stays loose only" rule left the archive with no descriptor at all, - so the zip was not a valid Psych-DS dataset (the spec requires it at the - dataset root) and neither was the record around it, whose data is sealed in - a zip — half the spec met by unzipping and half by not. It is inside because - the archive IS the dataset, the only place the `data/raw/...` tree exists; - it is also loose so the record shows a human-readable descriptor and - metadata-block.ts's `metadataFileRef` still resolves. Duplicating it is safe - only because finalization is terminal — nothing can update one copy and - leave the other stale, which is why compaction still keeps it strictly out - of its batches during collection. +3. **The record ends as exactly ONE file: the merged archive.** Both Psych-DS + control files go inside it — `.psychds-ignore` and + `dataset_description.json` — and nothing is left loose. + + Corrected twice, which is worth recording. The original rule kept the + descriptor loose "so the record still shows a descriptor", which left the + archive with none at all: the zip was not a valid Psych-DS dataset (the spec + requires the descriptor at the dataset root) and the record was not one + either (its data is sealed in a zip). Half the spec met by unzipping and + half by not. The first fix wrote it in BOTH places, which was valid but + duplicated a file to serve a need that does not survive checking — Zenodo + has a built-in zip previewer that lists archive contents on the record page + without downloading, so putting the descriptor inside hides nothing, and a + second copy is a second thing that can be wrong. Nothing reads the loose + copy either: `metadata-block.ts` owns the `metadataFileRef` pointing at it, + and that only runs during a submission, which a finalized experiment + rejects. + + Compaction still keeps both files strictly out of its batches during + collection (`NEVER_ARCHIVE`), where they are live and rewritten per + submission. This rule applies only at finalization, which is terminal. + 4. **Publishing / minting a DOI is out of scope.** Irreversible, and the researcher's call. diff --git a/functions/src/__tests__/finalization-emulator.test.js b/functions/src/__tests__/finalization-emulator.test.js index 74b0040..a79c972 100644 --- a/functions/src/__tests__/finalization-emulator.test.js +++ b/functions/src/__tests__/finalization-emulator.test.js @@ -384,10 +384,10 @@ describe("F1. the full merge", () => { expect(result.status).toBe("finalized"); expect(result.archiveName).toBe("datapipe-final.zip"); // archived counts top-level provider files consumed by the merge: the 2 - // batch archives + 2 loose sessions + .psychds-ignore = 5. Each batch - // then explodes into its own members once unpacked, which is what the - // archive's entry count below checks. - expect(result.archived).toBe(5); + // batch archives + 2 loose sessions + .psychds-ignore + the descriptor + // = 6. Each batch then explodes into its own members once unpacked, which + // is what the archive's entry count below checks. + expect(result.archived).toBe(6); const entries = readZipEntries(finalArchiveBytes()); // 3 (batch1) + 2 (batch2) + 2 loose + .psychds-ignore + the descriptor @@ -406,22 +406,25 @@ describe("F1. the full merge", () => { expect(entries.has("dataset_description.json")).toBe(true); }); - it("leaves dataset_description.json loose and moves .psychds-ignore inside the archive", async () => { + it("moves both Psych-DS control files inside the archive", async () => { const { experimentID } = await seedFinalizableExperiment(); await finalizeExperiment(experimentID); - expect(mock.has("dataset_description.json")).toBe(true); - // Nothing regenerates it once finalized, so the loose copy is gone. + // Neither is left loose. Nothing regenerates them once finalized, and the + // archive is the dataset -- both belong at its root, not beside it. + expect(mock.has("dataset_description.json")).toBe(false); expect(mock.has(PSYCHDS_IGNORE_FILE)).toBe(false); + const entries = readZipEntries(finalArchiveBytes()); expect(entries.get(".psychds-ignore").toString("utf8")).toBe(PSYCHDS_IGNORE_CONTENT); + expect(entries.has("dataset_description.json")).toBe(true); }); - it("deletes every merged member, leaving only the archive and the descriptor", async () => { + it("deletes every merged member, leaving the archive as the record's only file", async () => { const { experimentID } = await seedFinalizableExperiment(); await finalizeExperiment(experimentID); - expect(mock.keys().sort()).toEqual(["dataset_description.json", "datapipe-final.zip"].sort()); + expect(mock.keys()).toEqual(["datapipe-final.zip"]); }); it("keeps names flat for an experiment that never wrote a slashed path", async () => { @@ -520,8 +523,9 @@ describe("F4. resuming an interrupted pass", () => { async function computeMergedArchive({ experimentID, names, batch1, batch2, looseContents, looseNames }) { // Reproduce exactly what runFinalization's merge would produce: batch // members re-emitted at their recorded paths, loose files at their - // reconstructed paths, .psychds-ignore included, dataset_description.json - // excluded. + // reconstructed paths, and BOTH Psych-DS control files included -- + // .psychds-ignore and dataset_description.json. The archive is the + // dataset, so its root carries the descriptor. const entries = []; for (const [name, content] of batch1.contents) { const paths = archivePathsFor(zenodoProvider, true, [name]); @@ -536,8 +540,13 @@ describe("F4. resuming an interrupted pass", () => { entries.push({ path: loosePaths.get(name), content: looseContents.get(name) }); } entries.push({ path: ".psychds-ignore", content: Buffer.from(PSYCHDS_IGNORE_CONTENT) }); + entries.push({ + path: "dataset_description.json", + content: mock.get("dataset_description.json"), + }); - const members = names.filter((n) => n !== "dataset_description.json"); + // Every provider file is a member now; nothing is held back. + const members = names; return { ...(await buildArchive(entries)), members }; } @@ -632,7 +641,7 @@ describe("F4. resuming an interrupted pass", () => { // Nothing was deleted before the crash, so the safe move is to throw the // record away and finalize normally. expect(result.status).toBe("finalized"); - expect(result.archived).toBe(5); + expect(result.archived).toBe(6); expect(mock.has("datapipe-final.zip")).toBe(true); }); }); @@ -646,7 +655,7 @@ describe("F5. finalization is permanent", () => { const second = await finalizeExperiment(experimentID); expect(second.status).toBe("already-finalized"); // Nothing else moved -- the archive from the first pass is untouched. - expect(mock.keys().sort()).toEqual(["dataset_description.json", "datapipe-final.zip"].sort()); + expect(mock.keys()).toEqual(["datapipe-final.zip"]); }); it("refuses a new /api/data submission once finalized", async () => { @@ -1051,7 +1060,6 @@ describe("F11. the merged archive is a valid Psych-DS dataset on its own", () => // That produced an artifact valid in neither view: the zip was not a // Psych-DS dataset (the spec requires the descriptor at the dataset root) // and neither was the record around it, whose data is sealed inside a zip. - // Half the spec met by unzipping and half by not. it("contains dataset_description.json at the archive root", async () => { const { experimentID } = await seedFinalizableExperiment(); @@ -1061,32 +1069,33 @@ describe("F11. the merged archive is a valid Psych-DS dataset on its own", () => const entries = readZipEntries(finalArchiveBytes()); expect([...entries.keys()]).toContain("dataset_description.json"); - // And it is the real descriptor, not an empty placeholder. expect(JSON.parse(entries.get("dataset_description.json").toString("utf8"))).toBeTruthy(); }); - it("also leaves a copy loose on the record", async () => { - // Both, not either. A visitor should see a descriptor without downloading - // an archive, and metadata-block.ts holds a metadataFileRef to that - // object. + it("leaves the record as exactly one file", async () => { + // No loose copy. Zenodo's zip previewer lists archive contents on the + // record page without downloading, so putting the descriptor inside hides + // nothing -- and a second copy is a second thing that can be wrong. const { experimentID } = await seedFinalizableExperiment(); await finalizeExperiment(experimentID); - expect(mock.has("dataset_description.json")).toBe(true); - expect(mock.keys().sort()).toEqual(["dataset_description.json", "datapipe-final.zip"].sort()); + expect(mock.keys()).toEqual(["datapipe-final.zip"]); }); - it("the two copies are identical, since nothing can update either afterwards", async () => { - // Duplication is only safe because finalization is terminal. If the - // experiment could still accept submissions, metadata-block.ts would - // update the loose copy and the archived one would silently go stale. + it("seals every archived filename, including the descriptor's", async () => { + // Everything leaves the provider listing now, so a cold cache would + // otherwise rehydrate from a listing containing only the archive and + // forget every filename the study ever used. const { experimentID } = await seedFinalizableExperiment(); - const before = mock.get("dataset_description.json").toString("utf8"); - await finalizeExperiment(experimentID); - const entries = readZipEntries(finalArchiveBytes()); - expect(entries.get("dataset_description.json").toString("utf8")).toBe(before); - expect(mock.get("dataset_description.json").toString("utf8")).toBe(before); + const claim = await db + .collection("experiments") + .doc(experimentID) + .collection("filenameClaims") + .doc(claimDocId(SALT, "dataset_description.json")) + .get(); + expect(claim.exists).toBe(true); + expect(claim.data().sealed).toBe(true); }); }); diff --git a/functions/src/finalization.ts b/functions/src/finalization.ts index 2a117bf..5092a7d 100644 --- a/functions/src/finalization.ts +++ b/functions/src/finalization.ts @@ -54,27 +54,24 @@ import { // there is never more than one merged archive to name. const FINAL_ARCHIVE_NAME = "datapipe-final.zip"; -// dataset_description.json goes INSIDE the merged archive AND stays loose on -// the record. Both, deliberately. +// THE RECORD ENDS AS EXACTLY ONE FILE: the merged archive. Everything goes +// inside it, dataset_description.json included, and nothing is left loose. // -// Inside, because Psych-DS requires it at the dataset root. The merged archive -// IS the dataset -- it is the only place the data/raw/... tree exists, since -// Zenodo cannot store a slash in a key. An archive without the descriptor is -// not a valid Psych-DS dataset, and neither is the record around it, whose -// data is sealed in a zip. Leaving it only on the outside produced an artifact -// that validated in neither view: half the spec met by unzipping and half by -// not. +// Psych-DS requires the descriptor at the dataset root, and the archive IS the +// dataset -- it is the only place the data/raw/... tree exists, since Zenodo +// cannot store a slash in a key. An earlier version left the descriptor loose +// so the record would show something human-readable, which produced an +// artifact valid in neither view: a zip that was not a Psych-DS dataset +// (no descriptor) inside a record that was not one either (data sealed in a +// zip). A brief second version wrote it in BOTH places, which was correct but +// duplicated a file for a reason that does not survive checking -- Zenodo has +// a built-in zip previewer that lists archive contents on the record page +// without downloading, so nothing is hidden by putting the descriptor inside. // -// Loose, because the record should still show a human-readable descriptor -// without anyone downloading an archive, and because metadata-block.ts holds a -// metadataFileRef to that object. -// -// Duplicating it is safe here specifically because finalization is TERMINAL: -// the experiment stops accepting submissions, so nothing can update one copy -// and leave the other stale. That would not be safe during collection, which -// is why compaction still keeps it strictly out of its batches -// (NEVER_ARCHIVE in compaction.ts). -const KEEP_LOOSE_AFTER_MERGE = new Set(["dataset_description.json"]); +// Nothing reads the loose copy afterwards either: metadata-block.ts owns the +// metadataFileRef pointing at it, and that only runs during a submission, +// which a finalized experiment rejects. +const DESCRIPTOR = "dataset_description.json"; export interface FinalizationResult { // Carried on every result the same way CompactionResult carries it, so log @@ -259,12 +256,14 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR }; } - // Everything goes into the archive; only some of it is then removed from - // the record. See KEEP_LOOSE_AFTER_MERGE. + // Every file is both archived and removed -- see DESCRIPTOR above. const members = files; - const removable = files.filter((file) => !KEEP_LOOSE_AFTER_MERGE.has(file.name)); + const removable = files; - if (removable.length === 0) { + // "Nothing collected" is judged on DATA, not on file count: an experiment + // that only ever got its descriptor has no dataset to build, and wrapping + // a lone descriptor in an archive would be noise. + if (files.filter((file) => file.name !== DESCRIPTOR).length === 0) { // Nothing was ever collected beyond the descriptor -- finalizing is // still the correct terminal state (the study is over either way), just // with no archive to build. @@ -306,9 +305,9 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR return { status: "archive-too-large", detail }; } - // Hashes cover only what is actually leaving the listing. The descriptor - // stays loose, so a cold cache still rehydrates its claim normally and it - // needs no sealing. + // Everything leaves the listing, so everything needs sealing -- a cold + // cache rehydrates from that listing and would otherwise forget every + // filename the study ever used. const memberHashes = removable.map((file) => claimDocId(salt, file.name)); const runRef = finalizationRunRef(experimentID); From 635e860350e734b721fa022a94de36fb015b3660 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 19:53:14 -0400 Subject: [PATCH 091/181] fix: queue derived Psych-DS files when a submission is diverted Found on a live run. The deposition showed 44 loose raw sessions against 10 derived CSVs -- 44 sessions with no data/<base>_data.csv at all. The compaction write gate queued the raw file and nothing else, and the retry worker only writes back what is in the queue; it never re-runs the metadata pipeline. So every session the gate diverted lost its derived Psych-DS tables permanently. No submitted data was lost -- the raw file is the source of truth -- but the dataset ends up with holes in exactly the format this feature exists to produce, and nothing errors while it happens. The two provider-failure branches beside it already call queueDerivedFiles; the gate branch was written from the collision-cache-rehydrating branch, which does not. That branch has the identical hole and is fixed here too. It pre-dates the gate and is far rarer (a rehydration lease lasts 60 seconds), but it is the same bug. The assertion lives in scripts/live-check.mjs rather than the emulator suite. Covering it there needs the DEPLOYED function to reach a provider mock through the metadata block, which means proxying the shared 3581 port and satisfying the SSRF origin check on a Firestore-supplied bucket URL -- three layers of fixture between the test and the one line it would prove. The live script already talks to a real deployment, which is where the bug appeared and where the ratio is directly observable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- functions/src/api-data.ts | 14 ++++++++++++++ scripts/live-check.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/functions/src/api-data.ts b/functions/src/api-data.ts index c06e746..02ba9f8 100644 --- a/functions/src/api-data.ts +++ b/functions/src/api-data.ts @@ -260,6 +260,11 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy + // Same reasoning as the compaction-gate branch below: without this the + // session's derived tables are never generated at all. Pre-dates the + // gate and is far rarer (a rehydration lease lasts 60 seconds), but it + // is the identical hole. + await queueDerivedFiles(derivedFiles, derivedTarget, "Collision cache rehydrating"); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); await writeLog(experimentID, "logError", {...MESSAGES.OSF_UPLOAD_EXCEPTION, detail: "Collision cache rehydrating"}); return; @@ -292,6 +297,15 @@ export const apiData = onRequest({ cors: true, memory: "512MiB", concurrency: 1 }); await exp_doc_ref.set({ sessions: FieldValue.increment(1) }, { merge: true }); await cleanupPending(pendingPath); // queue-upload has its own copy + // The derived Psych-DS tables have to be queued too. The retry worker + // only writes back what is in the queue -- it never re-runs the metadata + // pipeline -- so queueing the raw file alone means this session's + // data/<base>_data.csv is never produced at all. The raw file is the + // source of truth and no submitted data is lost either way, but the + // dataset ends up missing derived tables for every session the gate + // diverted, which is a Psych-DS dataset with holes in it. Observed live: + // 44 loose raw sessions against 10 derived CSVs. + await queueDerivedFiles(derivedFiles, derivedTarget, COMPACTION_HOLD_REASON, "CONTENTION"); res.status(202).json({...MESSAGES.OSF_UPLOAD_QUEUED, metadataMessage}); return; } catch { diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs index 27890b4..fc409c1 100644 --- a/scripts/live-check.mjs +++ b/scripts/live-check.mjs @@ -247,6 +247,32 @@ async function main() { ); } + // ---- 5c. does every session still have its derived Psych-DS table? ---- + // A submission the write gate diverts is queued and written back later by + // the retry worker -- which only writes what is IN the queue and never + // re-runs the metadata pipeline. Queueing the raw file alone therefore + // leaves that session with no data/<base>_data.csv, permanently. No + // submitted data is lost (the raw file is the source of truth) but the + // dataset is a Psych-DS dataset with holes in it, and nothing errors. + // + // Observed live 2026-08-20 before the fix: 44 loose raw sessions against 10 + // derived CSVs. Emulator coverage for this needs the deployed function to + // reach a provider mock through the metadata block, which is exactly the + // wiring this script exists to avoid -- so the assertion lives here. + if (ZTOKEN && DEPOSITION) { + const files = await listDeposition(); + const raw = files.filter((n) => n.startsWith("data_raw_")); + const csv = files.filter((n) => n.startsWith("data_") && !n.startsWith("data_raw_")); + record( + "5c. derived tables kept pace with raw sessions", + raw.length === csv.length ? "PASS" : "FAIL", + `${raw.length} loose raw session(s), ${csv.length} loose derived CSV(s)` + + (raw.length === csv.length + ? "" + : " <-- sessions without a derived table; a diverted submission did not queue its Psych-DS files") + ); + } + // ---- 6. finalize ------------------------------------------------------ if (ID_TOKEN) { const res = await fetch(`${BASE}/api/finalize`, { From 772368166e00e36b2c91fcd5828e50d152b0c610 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Thu, 20 Aug 2026 20:50:04 -0400 Subject: [PATCH 092/181] test: count derived tables across the whole dataset, not just loose files The 5c check compared loose raw sessions against loose derived CSVs and reported a failure on its first clean run: 12 against 13. Nothing was wrong. Compaction archives a PREFIX of the provider's listing, so it cuts through raw/CSV pairs and leaves the loose set legitimately lopsided -- the archive held 38 raw / 37 csv and the loose set 12 / 13, for totals of 50 and 50. The invariant is about the dataset, not about whichever part of it happens not to be archived yet, so the check now unzips each archive and counts across everything. Also adds SUBMIT_DELAY_MS and backs off for 5 seconds after a 202 instead of continuing to fire. A 202 means a compaction pass holds the lease, and every submission sent during one just piles into the queue to be drained at 25 per five-minute worker tick. Pacing at 900ms took diversions from 33 of 75 down to 7 of 50 and cut the run time roughly in half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- scripts/live-check.mjs | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs index fc409c1..47a593e 100644 --- a/scripts/live-check.mjs +++ b/scripts/live-check.mjs @@ -17,6 +17,7 @@ // ID_TOKEN (optional) Firebase ID token -- enables the finalize phase // REQUIRED_FIELDS (default trial_type) must match the experiment's setting // MAX_SUBMISSIONS (default 100) safety stop +// SUBMIT_DELAY_MS (default 250) pacing between submissions // // Without ZENODO_TOKEN this still proves the load path, duplicate rejection // after archiving, and post-finalization rejection -- all observable from the @@ -32,6 +33,7 @@ const DEPOSITION = process.env.DEPOSITION_ID; const ZSERVER = process.env.ZENODO_SERVER || "https://sandbox.zenodo.org"; const ID_TOKEN = process.env.ID_TOKEN; const MAX_SUBMISSIONS = Number(process.env.MAX_SUBMISSIONS || 100); +const SUBMIT_DELAY_MS = Number(process.env.SUBMIT_DELAY_MS || 250); if (!EXP) { console.error("EXPERIMENT_ID is required. See the header of this file."); @@ -151,7 +153,11 @@ async function main() { // What matters is that it later LANDS, which is checked after the run. queued.push(name); process.stdout.write("q"); - await sleep(250); + // Back off hard rather than keep firing. A 202 means a compaction pass + // holds the lease, and every submission sent during it just piles up in + // the queue to be drained later at 25 per five-minute worker tick -- + // which makes the run long and tells us nothing new after the first one. + await sleep(5000); continue; } if (r.status !== 201) { @@ -160,7 +166,7 @@ async function main() { } archived.push(name); process.stdout.write("."); - await sleep(250); + await sleep(SUBMIT_DELAY_MS); if (i % 5 === 0) { const files = await listDeposition(); @@ -255,19 +261,27 @@ async function main() { // submitted data is lost (the raw file is the source of truth) but the // dataset is a Psych-DS dataset with holes in it, and nothing errors. // - // Observed live 2026-08-20 before the fix: 44 loose raw sessions against 10 - // derived CSVs. Emulator coverage for this needs the deployed function to - // reach a provider mock through the metadata block, which is exactly the - // wiring this script exists to avoid -- so the assertion lives here. - if (ZTOKEN && DEPOSITION) { - const files = await listDeposition(); - const raw = files.filter((n) => n.startsWith("data_raw_")); - const csv = files.filter((n) => n.startsWith("data_") && !n.startsWith("data_raw_")); + // COUNT ACROSS THE WHOLE DATASET, not just the loose files. Compaction + // archives a prefix of the provider's listing, which cuts through raw/CSV + // pairs -- so the loose set is legitimately lopsided and comparing it alone + // reports a failure that is not one. (It did exactly that on first use: + // 12 loose raw against 13 loose CSVs, while the totals were 50 and 50.) + if (ZTOKEN && DEPOSITION && BUCKET) { + const loose = await listDeposition(); + let raw = loose.filter((n) => n.startsWith("data_raw_")).length; + let csv = loose.filter((n) => n.startsWith("data_") && !n.startsWith("data_raw_")).length; + + for (const name of loose.filter((n) => n.endsWith(".zip"))) { + const inZip = [...readArchive(await fetchArchive(name)).keys()]; + raw += inZip.filter((n) => n.startsWith("data/raw/")).length; + csv += inZip.filter((n) => n.startsWith("data/") && n.endsWith(".csv")).length; + } + record( "5c. derived tables kept pace with raw sessions", - raw.length === csv.length ? "PASS" : "FAIL", - `${raw.length} loose raw session(s), ${csv.length} loose derived CSV(s)` + - (raw.length === csv.length + raw === csv ? "PASS" : "FAIL", + `${raw} raw session(s), ${csv} derived CSV(s) across the whole dataset` + + (raw === csv ? "" : " <-- sessions without a derived table; a diverted submission did not queue its Psych-DS files") ); From 20eb92219eec808497b4b71dfcf4cbf1b7c4a0bd Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Fri, 21 Aug 2026 08:00:15 -0400 Subject: [PATCH 093/181] fix: keep the real error code when a nested Drive folder walk fails findFolder and createFolder each compute a MappedDriveError and then threw it away, stringifying it into a plain Error. writeSessionFile's segment walk could therefore only report a generic UNAVAILABLE with providerStatus null, whatever had actually gone wrong. That is user-facing rather than cosmetic. QueuePanel.js prints "your storage provider connection may need to be refreshed" for AUTH_EXPIRED and "temporarily unavailable" for UNAVAILABLE, so a researcher whose Drive token had expired was told to wait out an outage that would never end -- and the retry queue treats the two codes differently too. It only misfired on NESTED paths, meaning every metadataActive experiment, since those write to data/raw/... The same expired token on a flat filename classified correctly. That asymmetry is why it survived: the obvious test case passes. The folder helpers now raise a DriveFolderError carrying the mapped error, and the walk re-uses it. The UNAVAILABLE fallback stays for genuinely non-provider throws -- network errors, bugs -- rather than being removed; a test pins that too. Also adds a live spike for Drive (scripts/gdrive-spike.mjs) and the coverage Drive was missing against the Dataverse suite: cold collision-cache rehydration through nested folders, two untested 403 reason strings, a malformed 403 body, listFiles throwing on a MID-RECURSION failure (the "never a partial list" rule the source calls load-bearing, previously only tested for a first-request failure), and createDataContainer's researcher-supplied parentId branch. The spike is UNRUN: it needs a real Drive access token, and none was available. Drive should not be considered verified until it executes -- every previous spike found adapter bugs on its first live run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/gdrive-emulator.test.js | 113 ++++- .../src/__tests__/providers-gdrive.test.js | 255 ++++++++++ functions/src/providers/gdrive.ts | 37 +- scripts/gdrive-spike.mjs | 449 ++++++++++++++++++ 4 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 scripts/gdrive-spike.mjs diff --git a/functions/src/__tests__/gdrive-emulator.test.js b/functions/src/__tests__/gdrive-emulator.test.js index 32dccd7..9d2aba1 100644 --- a/functions/src/__tests__/gdrive-emulator.test.js +++ b/functions/src/__tests__/gdrive-emulator.test.js @@ -48,7 +48,7 @@ // the gdrive generalization must not be able to break it. import { initializeApp } from "firebase-admin/app"; -import { getFirestore } from "firebase-admin/firestore"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; import { randomUUID } from "crypto"; import express from "express"; import MESSAGES from "../api-messages"; @@ -315,6 +315,57 @@ function createMockDriveServer() { getUploadCount: (name) => uploadCountsByName.get(name) || 0, getUpdateCount: (id) => updateCountsById.get(id) || 0, forceStatus: (nameOrId, status) => forcedStatus.set(nameOrId, status), + // Places a file several real folder levels deep under rootFolderId, + // creating the intermediate folders directly (bypassing the + // adapter/API) -- mirrors dataverse-emulator.test.js's seedFile and + // zenodo-emulator.test.js's seedFile: a file the COLLISION CACHE + // does not know about because DataPipe never wrote it, standing in + // for "an earlier session, or a researcher's own upload, already on + // the provider when the cache goes cold." fullPath is slash- + // separated (e.g. "data/raw/subject-1.json"); every segment but the + // last becomes a real nested folder, exactly as writeSessionFile's + // own segment walk would have created them. + seedNestedFile: (rootFolderId, fullPath, content = "seeded") => { + const segments = fullPath.split("/"); + const leafName = segments.pop(); + let parentId = rootFolderId; + for (const segment of segments) { + let folder = Array.from(filesById.values()).find( + (f) => f.name === segment && f.mimeType === FOLDER_MIME && f.parents.includes(parentId) + ); + if (!folder) { + const id = `mock-folder-${nextSeq}`; + folder = { + id, + name: segment, + mimeType: FOLDER_MIME, + parents: [parentId], + content: "", + contentType: FOLDER_MIME, + __seq: nextSeq++, + }; + filesById.set(id, folder); + } + parentId = folder.id; + } + const id = `mock-file-${nextSeq}`; + filesById.set(id, { + id, + name: leafName, + mimeType: "application/json", + parents: [parentId], + content, + contentType: "application/json", + __seq: nextSeq++, + }); + return id; + }, + // Leaf-name lookup (not full-path), matching how listFiles reports + // every file regardless of which folder it was found in. + getContentByLeafName: (name) => { + const file = Array.from(filesById.values()).find((f) => f.name === name && f.mimeType !== FOLDER_MIME); + return file ? file.content : null; + }, reset: () => { filesById.clear(); uploadCountsByName.clear(); @@ -550,3 +601,63 @@ describe("16. OSF experiment in the same run still works (legacy dispatch untouc expect(mockOSF.getUploadCount(filename)).toBe(1); }); }); + +// 17. cold collision-cache rehydration for gdrive. Dataverse (D9, +// dataverse-emulator.test.js) and Zenodo (Z8, zenodo-emulator.test.js) both +// have this test; gdrive did not, and it is the highest-value gap of the +// three -- Drive has no 409 backstop at all (mapDriveError has no +// NAME_CONFLICT case, see gdrive.ts), so the Firestore cache is the ONLY +// duplicate gate this provider has. A rehydration that misses a name lets a +// duplicate through completely silently. +// +// This is also the one place gdrive's rehydration test has to do MORE than +// its Dataverse/Zenodo counterparts: the seeded file has to sit several real +// folder levels deep (data/raw/<name>), because listFiles' recursion into +// subfolders -- and its collapsing of every result down to the bare leaf +// name, per storedNameFor -- is exactly the mechanism this test is meant to +// exercise. A file seeded flat at the folder root would not touch that path +// at all. +describe("17. cold collision cache rehydrates from a nested (data/raw/) folder listing", () => { + it("a file already sitting in a nested Drive folder is caught as a duplicate after the cache goes cold", async () => { + const experimentID = `gdrive-int17-${randomUUID()}`; + const folderId = `folder-${randomUUID()}`; + const filename = `case17-${randomUUID()}.json`; + // metadataActive so the raw submission is routed to data/raw/<filename> + // (see uploadPathFor in api-data.ts), matching where the file is seeded + // below and putting listFiles' recursion on the hook. + await createGdriveExperiment(experimentID, folderId, { + metadataActive: true, + // An experiment that collected data, went cold, and had its claims + // expire -- the salt is retained permanently, warmUntil is not. + collisionCache: { + salt: "case17-retained-salt", + warmUntil: Timestamp.fromMillis(Date.now() - 60 * 60 * 1000), + }, + }); + // Present on the provider, several folders deep, with no claim in + // Firestore -- exactly the state rehydration exists to recover from. + mockDrive.seedNestedFile(folderId, `data/raw/${filename}`, "an earlier session"); + + const response = await saveData({ experimentID, data: sampleData, filename }); + + expect(response.status).toBe(400); + // objectContaining, not exact equality: metadataActive:true runs the + // metadata block (which creates dataset_description.json on this, the + // experiment's first-ever submission) BEFORE the collision check that + // rejects the raw file, so metadataMessage carries whatever that block + // produced rather than the "" a metadataActive:false experiment would + // have -- mirrors dataverse-emulator.test.js's D9, the equivalent test. + expect(response.body).toEqual(expect.objectContaining(MESSAGES.OSF_FILE_EXISTS)); + // Never overwritten, and no second upload of the RAW file was ever + // attempted (the metadata file upload above is a separate, expected + // upload and is not what this gate is checking). + expect(mockDrive.getUploadCount(filename)).toBe(0); + expect(mockDrive.getContentByLeafName(filename)).toBe("an earlier session"); + + const expDataAfter = (await db.collection("experiments").doc(experimentID).get()).data(); + // Rehydration re-warms the cache and keeps the original salt (claims + // hashed under a new salt would never match the old ones). + expect(expDataAfter.collisionCache.salt).toBe("case17-retained-salt"); + expect(expDataAfter.collisionCache.warmUntil.toMillis()).toBeGreaterThan(Date.now()); + }); +}); diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js index 122a937..541f4be 100644 --- a/functions/src/__tests__/providers-gdrive.test.js +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -366,6 +366,85 @@ describe("3. error mapping", () => { }); }); + it("maps 403 rateLimitExceeded to RATE_LIMITED", async () => { + // mapDriveError's RATE_LIMITED branch lists three reason strings + // (userRateLimitExceeded, rateLimitExceeded, dailyLimitExceeded). Only + // the first was covered above -- this and the next test close the gap so + // a future edit that narrows or reorders that list gets caught. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "rateLimitExceeded", message: "slow down" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "RATE_LIMITED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + + it("maps 403 dailyLimitExceeded to RATE_LIMITED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 403, + statusText: "Forbidden", + jsonBody: { errors: [{ reason: "dailyLimitExceeded", message: "daily cap" }] }, + }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result.error).toBe("RATE_LIMITED"); + }); + + it("defaults to AUTH_EXPIRED on a 403 whose body isn't valid JSON (safe fallback, not a crash)", async () => { + // mapErrorResponse only parses the body on a 403 (to read the reason), + // and swallows a JSON-parse failure into `body = undefined` -- exercised + // here directly rather than assumed, since a throw escaping instead would + // turn a routine provider error into an unhandled rejection. + mockFetch.mockResolvedValueOnce({ + status: 403, + statusText: "Forbidden", + headers: { get: () => null }, + json: () => Promise.reject(new Error("not json")), + }); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "AUTH_EXPIRED", + providerStatus: 403, + providerMessage: "Forbidden", + retryAfter: null, + }); + }); + it("maps 429 to RATE_LIMITED and passes through Retry-After", async () => { mockFetch.mockResolvedValueOnce( mockResponse({ status: 429, statusText: "Too Many Requests", retryAfter: "15" }) @@ -505,6 +584,45 @@ describe("4. listFiles pagination", () => { gdriveProvider.listFiles(auth, { provider: "gdrive", folderId: "folder-err" }) ).rejects.toThrow(/listing failed/i); }); + + it("throws (never returns the partial list already collected) when a SUBFOLDER listing fails mid-recursion", async () => { + // The single-request-failure case above only proves the top-level listing + // is never swallowed. This is the case the "never a partial list" comment + // in listFiles is actually guarding: the top level succeeds and yields + // real files PLUS a subfolder to recurse into, and only the second + // request (that subfolder) fails. If that failure were swallowed instead + // of thrown, callers would get a partial list containing everything + // found before the failure, which is exactly the silently-incomplete + // rehydration the design note warns about. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { + files: [ + { id: "top-1", name: "top.csv", mimeType: "text/csv" }, + { id: "folder1", name: "subdir", mimeType: "application/vnd.google-apps.folder" }, + ], + }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 500, + statusText: "Internal Server Error", + jsonBody: undefined, + }) + ); + + await expect( + gdriveProvider.listFiles(auth, { provider: "gdrive", folderId: "folder-partial" }) + ).rejects.toThrow(/listing failed/i); + + // Both requests were actually made (top level succeeded, subfolder is + // what failed) -- confirms the throw is coming from the recursion step, + // not a coincidental early exit. + expect(mockFetch).toHaveBeenCalledTimes(2); + }); }); describe("5. updateFile", () => { @@ -617,6 +735,45 @@ describe("6. createDataContainer", () => { expect(result).toEqual({ provider: "gdrive", folderId: "child-id-B" }); }); + + it("skips the DataPipe-root lookup entirely when researcherInput carries a parentId (Picker-supplied folder)", async () => { + // The other two tests above only exercise the no-parentId fallback path. + // A researcher-chosen parent (via the Google Picker) is meant to bypass + // the shared "DataPipe" root convention entirely -- this is the one + // request shape where that matters and it was untested. + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-C", name: "My Experiment 3" } }) + ); + + const result = await gdriveProvider.createDataContainer(auth, { + name: "My Experiment 3", + parentId: "picker-chosen-folder-id", + }); + + // Exactly one call -- no root find, no root create. + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(callArgs(0).options.body)).toEqual({ + name: "My Experiment 3", + mimeType: "application/vnd.google-apps.folder", + parents: ["picker-chosen-folder-id"], + }); + + expect(result).toEqual({ provider: "gdrive", folderId: "child-id-C" }); + }); + + it("propagates a rejection (does not swallow it) when the DataPipe-root lookup fails", async () => { + // createDataContainer has no try/catch of its own around findFolder -- + // unlike writeSessionFile's segment walk (see "8. nested folder-creation + // failure" below), a failure here is expected to surface as a thrown + // Error, not get remapped into a WriteResult-shaped value (this method + // returns a bare ContainerRef, not a WriteResult, so there is nowhere to + // put an error code even if it wanted to). + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized", jsonBody: undefined })); + + await expect( + gdriveProvider.createDataContainer(auth, { name: "Doomed Experiment" }) + ).rejects.toThrow(/folder lookup failed/i); + }); }); describe("7. downloadFile", () => { @@ -668,6 +825,104 @@ describe("7. downloadFile", () => { }); }); +// Found while auditing this suite against providers-dataverse.test.js's +// coverage, not requested directly -- documents CURRENT (not obviously +// desired) behavior, the same way dataverse-emulator.test.js's D10 does. +// +// writeSessionFile wraps its whole segment-by-segment folder walk (the loop +// that finds-or-creates "data", then "raw", etc.) in one try/catch that maps +// ANY failure -- regardless of what actually went wrong -- to a generic +// { error: "UNAVAILABLE", providerStatus: null }. That collapses a real 401 +// mid-walk (an expired/revoked Drive token, discovered while looking up the +// "data" folder rather than during the final upload) into the SAME +// UNAVAILABLE code a genuine outage would produce, instead of the +// AUTH_EXPIRED that mapDriveError would assign a 401 hit directly against the +// upload endpoint (see "3. error mapping" above). +// +// This is user-facing, not just a logging nicety: components/dashboard/ +// QueuePanel.js shows researchers a distinct "your storage provider +// connection may need to be refreshed" message for AUTH_EXPIRED, versus a +// generic "temporarily unavailable" message for UNAVAILABLE. A researcher +// with an expired token submitting to a Psych-DS nested path (data/raw/..., +// i.e. any metadataActive experiment) would see the wrong guidance -- while +// the SAME expired token, on a flat (non-nested) filename, correctly reaches +// mapDriveError and gets AUTH_EXPIRED. Reported rather than fixed per this +// task's brief; see the audit notes for detail. +describe("8. a failure inside the nested folder walk keeps its real error code", () => { + // Regression. findFolder/createFolder compute a MappedDriveError and used to + // stringify it into a plain Error, so any failure part-way through a nested + // path collapsed to a generic UNAVAILABLE with providerStatus null. + // + // That is user-facing rather than cosmetic: QueuePanel.js prints "your + // storage provider connection may need to be refreshed" for AUTH_EXPIRED and + // "temporarily unavailable" for UNAVAILABLE, so a researcher whose Drive + // token had expired was told to wait out an outage that would never end. + // + // It only misfired on NESTED paths -- every metadataActive experiment, since + // those write to data/raw/... -- while the same expired token on a flat + // filename classified correctly. That asymmetry is why it survived: the + // obvious test case passes. + + it("a 401 while resolving an intermediate folder is AUTH_EXPIRED", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 401, statusText: "Unauthorized", jsonBody: undefined }) + ); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "data/raw/file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("AUTH_EXPIRED"); + // The status survives too -- it was being flattened to null. + expect(result.providerStatus).toBe(401); + }); + + it("classifies a nested-path failure the same as the identical flat-path one", async () => { + // The invariant that matters: which code comes back must not depend on + // whether the filename happened to contain a slash. + const call = async (filename) => { + mockFetch.mockClear(); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 401, statusText: "Unauthorized" })); + return gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + filename, + "data", + { size: 4, contentType: "application/json" } + ); + }; + + expect((await call("data/raw/file.json")).error).toBe((await call("file.json")).error); + }); + + it("still falls back to UNAVAILABLE for a non-provider throw", async () => { + // The fallback must remain for genuine network errors and bugs -- the fix + // narrows it to those, rather than removing it. + mockFetch.mockRejectedValueOnce(new Error("socket hang up")); + + const result = await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "folder-abc" }, + "data/raw/file.json", + "data", + { size: 4, contentType: "application/json" } + ); + + expect(result).toEqual({ + success: false, + error: "UNAVAILABLE", + providerStatus: null, + providerMessage: expect.stringContaining("socket hang up"), + retryAfter: null, + }); + }); +}); + // The collision cache hashes a name before the write and rehydrates a cold // cache from listFiles, so the two must share a namespace. Drive stores a // path prefix as real nested FOLDERS and the file under its bare leaf name, diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index 1770a9c..1933881 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -59,6 +59,27 @@ interface MappedDriveError { retryAfter: number | null; } +// Raised by the folder helpers so a failure part-way through a nested path +// keeps its CLASSIFICATION, not just its text. +// +// findFolder/createFolder both compute a MappedDriveError and then used to +// stringify it into a plain Error, so writeSessionFile's segment walk could +// only report a generic UNAVAILABLE. That is user-facing: QueuePanel.js shows +// "your storage provider connection may need to be refreshed" for +// AUTH_EXPIRED and "temporarily unavailable" for UNAVAILABLE, so a researcher +// with an expired Drive token was told to wait for an outage that would never +// clear. It only misfired on NESTED paths -- i.e. every metadataActive +// experiment, which writes to data/raw/... -- while the same expired token on +// a flat filename classified correctly, which is why it went unnoticed. +class DriveFolderError extends Error { + readonly mapped: MappedDriveError; + constructor(message: string, mapped: MappedDriveError) { + super(message); + this.name = "DriveFolderError"; + this.mapped = mapped; + } +} + // Shared error-mapping helper — every write/update/list/download call routes // its non-2xx response through this. Drive never yields a duplicate-name // conflict (NAME_CONFLICT): Drive allows multiple files with the same name @@ -139,7 +160,10 @@ async function findFolder( if (!isSuccessStatus(response.status)) { const mapped = await mapErrorResponse(response); - throw new Error(`Google Drive folder lookup failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + throw new DriveFolderError( + `Google Drive folder lookup failed: ${mapped.providerStatus} ${mapped.providerMessage}`, + mapped + ); } const body = (await response.json()) as { files?: { id: string }[] }; @@ -159,7 +183,10 @@ async function createFolder(auth: ResolvedAuth, name: string, parentId: string): if (!isSuccessStatus(response.status)) { const mapped = await mapErrorResponse(response); - throw new Error(`Google Drive folder creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`); + throw new DriveFolderError( + `Google Drive folder creation failed: ${mapped.providerStatus} ${mapped.providerMessage}`, + mapped + ); } const body = (await response.json()) as { id: string }; @@ -354,6 +381,12 @@ export const gdriveProvider: StorageProvider = { parentId = await findOrCreateFolder(auth, segment, parentId); } } catch (e) { + // A provider failure keeps whatever mapErrorResponse decided; only a + // genuinely non-provider throw (a network error, a bug) falls back to + // UNAVAILABLE. See DriveFolderError. + if (e instanceof DriveFolderError) { + return { success: false, ...e.mapped }; + } return { success: false, error: "UNAVAILABLE", diff --git a/scripts/gdrive-spike.mjs b/scripts/gdrive-spike.mjs new file mode 100644 index 0000000..aac855e --- /dev/null +++ b/scripts/gdrive-spike.mjs @@ -0,0 +1,449 @@ +// Google Drive gating spike (docs/provider-migration-design.md). +// +// Drives the REAL shipping adapter (functions/lib/providers/gdrive.js) +// against a live Google Drive, exactly the way scripts/zenodo-spike.mjs and +// scripts/dataverse-spike.mjs do for their providers -- so this validates our +// request shapes, response parsing and error mapping at the same time as it +// validates the service. +// +// Drive is the ONLY provider that had never been spiked before this file: 28 +// unit/emulator tests against Dataverse's 72, and every adapter-level bug +// found in this migration so far came from a spike, invisible to mocks built +// from the same assumptions as the code they test. +// +// Usage: +// cd functions && npm run build && cd .. +// GDRIVE_TOKEN=xxxx node scripts/gdrive-spike.mjs +// +// Getting a GDRIVE_TOKEN: +// 1. Open https://developers.google.com/oauthplayground +// 2. Click the gear icon (top right) -> check "Use your own OAuth +// credentials" -> paste a Client ID/Secret from a Google Cloud project +// that has the Drive API enabled (or leave unchecked to use Google's +// shared test credentials, which also works for this scope). +// 3. In "Step 1: Select & authorize APIs", enter the scope by hand: +// https://www.googleapis.com/auth/drive.file +// (this is the exact scope gdrive.ts requests -- see oauthConfig() in +// functions/src/providers/gdrive.ts) and click "Authorize APIs". +// 4. Sign in and consent with the Google account you want to spike against. +// 5. In "Step 2: Exchange authorization code for tokens", click "Exchange +// authorization code for tokens" and copy the resulting Access token. +// It expires in ~1 hour -- re-run step 5 to mint a fresh one if a run +// takes longer than that. +// +// The spike constructs `auth = { token }` directly, the same shortcut every +// other spike takes -- gdrive.ts's own resolveToken() (decrypt + refresh via +// Firestore) is a production concern this script deliberately bypasses. +// +// Env: +// GDRIVE_TOKEN (required) an OAuth2 access token with the drive.file +// scope, minted as described above +// GDRIVE_API_BASE (default https://www.googleapis.com) +// GDRIVE_BURST (default 8) concurrent writes for gate H +// GDRIVE_CLEANUP (default 1; set to 0 to leave the experiment folder +// behind for manual inspection) +// +// Everything this script creates lives under one fresh "DataPipe spike +// <timestamp>" folder (created via the adapter's own createDataContainer, the +// same call create-experiment.ts makes). With cleanup on, that single folder +// is deleted (Drive cascades the delete to every file/subfolder inside it); +// the shared "DataPipe" root folder above it is never touched, since real +// experiments share it too. + +import { gdriveProvider } from "../functions/lib/providers/gdrive.js"; + +const token = process.env.GDRIVE_TOKEN; +const apiBase = process.env.GDRIVE_API_BASE || "https://www.googleapis.com"; +const burst = Number(process.env.GDRIVE_BURST || 8); +const cleanup = process.env.GDRIVE_CLEANUP !== "0"; + +if (!token) { + console.error( + "GDRIVE_TOKEN is required. See the header of this file for how to mint one " + + "(Google OAuth Playground, https://www.googleapis.com/auth/drive.file scope)." + ); + process.exit(1); +} + +// getApiBase() in gdrive.ts reads process.env.GDRIVE_API_BASE at CALL time, +// so setting it here (even to its own default) is enough to make the adapter +// agree with this script about which host it's talking to. +process.env.GDRIVE_API_BASE = apiBase; + +const auth = { token }; +const results = []; +const record = (gate, verdict, detail) => { + results.push({ gate, verdict, detail }); + console.log(`\n[${verdict}] ${gate}\n ${detail}`); +}; + +const meta = (body, contentType = "application/json") => ({ + size: Buffer.byteLength(body), + contentType, +}); + +const payload = (label) => JSON.stringify({ label, at: new Date().toISOString(), pad: "x".repeat(64) }); + +// Raw Drive call, used only where the adapter has no method that answers the +// question directly (there is no gdriveProvider.validateStaticToken -- Drive +// is oauth2, not a static-token provider -- and no downloadFileBytes/delete +// method to inspect the API's own pagination contract with). Never used to +// bypass the adapter for anything the adapter itself does. +async function driveFetch(path, init = {}) { + const response = await fetch(`${apiBase}${path}`, { + ...init, + headers: { Authorization: `Bearer ${token}`, ...(init.headers || {}) }, + }); + return response; +} + +async function main() { + console.log(`Google Drive spike against ${apiBase} burst=${burst}\n`); + + // ---- sanity: does the token work at all -------------------------------- + // No validateStaticToken on this adapter (oauth2, not static-token), so ask + // Drive directly with the same "about" call the Picker UI relies on. + const about = await driveFetch("/drive/v3/about?fields=user"); + if (!about.ok) { + console.error( + `Token rejected: GET /drive/v3/about -> ${about.status} ${about.statusText}. ` + + "Check GDRIVE_TOKEN's scope (needs drive.file) and that it hasn't expired (~1hr lifetime)." + ); + process.exit(1); + } + const aboutBody = await about.json(); + console.log(`Token accepted. Signed in as ${aboutBody.user?.emailAddress ?? "(unknown)"}.`); + + // ---- create the experiment folder -------------------------------------- + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const container = await gdriveProvider.createDataContainer(auth, { name: `DataPipe spike ${stamp}` }); + console.log(`Experiment folder created: id=${container.folderId}`); + console.log(` https://drive.google.com/drive/folders/${container.folderId}\n`); + + // ---- Gate A: duplicate filenames ---------------------------------------- + // gdrive.ts's central load-bearing claim: "Drive never yields a + // duplicate-name conflict (NAME_CONFLICT): Drive allows multiple files with + // the same name in the same folder, so the collision cache (not the + // provider) is the only duplicate gate for gdrive experiments." If Drive + // actually rejects or silently de-duplicates a repeat name, that claim is + // wrong and the whole reliance on the Firestore cache as the ONLY backstop + // is unsound. + { + const first = payload("gate-a-first"); + const second = payload("gate-a-second"); + const w1 = await gdriveProvider.writeSessionFile(auth, container, "gate-a.json", first, meta(first)); + const w2 = await gdriveProvider.writeSessionFile(auth, container, "gate-a.json", second, meta(second)); + + if (!w1.success || !w2.success) { + record( + "A. duplicate filenames", + "FAIL", + `write(s) rejected: first.success=${w1.success} second.success=${w2.success}` + + (w1.success ? "" : ` first: ${w1.providerStatus} ${w1.providerMessage}`) + + (w2.success ? "" : ` second: ${w2.providerStatus} ${w2.providerMessage}`) + ); + } else { + const listed = await gdriveProvider.listFiles(auth, container); + const copies = listed.filter((f) => f.name === "gate-a.json"); + const distinctIds = new Set(copies.map((f) => f.id)); + const ok = w1.fileRef.id !== w2.fileRef.id && copies.length === 2 && distinctIds.size === 2; + record( + "A. duplicate filenames", + ok ? "PASS" : "FAIL", + `both writes succeeded, ids ${w1.fileRef.id} and ${w2.fileRef.id}; ` + + `folder listing shows ${copies.length} entr${copies.length === 1 ? "y" : "ies"} named "gate-a.json" ` + + `(${distinctIds.size} distinct id${distinctIds.size === 1 ? "" : "s"}).` + + (ok + ? " Drive genuinely allows and keeps both -- the Firestore cache really is the only gate." + : " <-- Drive rejected, merged, or otherwise did not keep two independent copies.") + ); + } + } + + // ---- Gate B: recursive listing collects the LEAF name ------------------- + // listFiles does a BFS through subfolders and reports every file under its + // bare leaf name regardless of depth, because storedNameFor collapses a + // path to its leaf and that's what the collision cache hashes. If listFiles + // instead reported a qualified/full path, cold-cache rehydration would + // never match a claim made on the leaf, and duplicates would slip through + // silently (Drive has no NAME_CONFLICT to fall back on -- see gate A). + { + const body = payload("gate-b"); + const w = await gdriveProvider.writeSessionFile(auth, container, "data/raw/subject-1.json", body, meta(body)); + if (!w.success) { + record("B. recursive listing / leaf-name collection", "FAIL", `write rejected: ${w.providerStatus} ${w.providerMessage}`); + } else { + const listed = await gdriveProvider.listFiles(auth, container); + const found = listed.find((f) => f.id === w.fileRef.id); + const ok = !!found && found.name === "subject-1.json"; + record( + "B. recursive listing / leaf-name collection", + ok ? "PASS" : "FAIL", + `wrote to "data/raw/subject-1.json"; listFiles found ` + + (found ? `it reported as name="${found.name}"` : "NO entry with a matching id at all") + + (ok + ? ` (expected "subject-1.json", the bare leaf -- matches storedNameFor).` + : ` <-- either the file is missing from the recursive listing, or it is not reported under its leaf name.`) + ); + } + } + + // ---- Gate C: nested folder creation -------------------------------------- + // Writing "data/raw/x.json" must create the folder CHAIN (not just one + // level), and the file must land in the deepest folder, not an intermediate + // one. Confirmed here with raw folder queries rather than trusting gate B's + // listFiles round-trip alone, since listFiles recursing correctly and the + // folder structure being correct are two different claims. + { + const dataFolder = await driveFetch( + `/drive/v3/files?q=${encodeURIComponent(`name='data' and '${container.folderId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`)}&supportsAllDrives=true&includeItemsFromAllDrives=true` + ).then((r) => r.json()); + const dataId = dataFolder.files?.[0]?.id; + + let rawId = null; + let fileInRaw = null; + if (dataId) { + const rawFolder = await driveFetch( + `/drive/v3/files?q=${encodeURIComponent(`name='raw' and '${dataId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`)}&supportsAllDrives=true&includeItemsFromAllDrives=true` + ).then((r) => r.json()); + rawId = rawFolder.files?.[0]?.id; + + if (rawId) { + const contents = await driveFetch( + `/drive/v3/files?q=${encodeURIComponent(`'${rawId}' in parents and trashed=false`)}&supportsAllDrives=true&includeItemsFromAllDrives=true` + ).then((r) => r.json()); + fileInRaw = (contents.files || []).find((f) => f.name === "subject-1.json"); + } + } + + const ok = !!dataId && !!rawId && !!fileInRaw; + record( + "C. nested folder creation", + ok ? "PASS" : "FAIL", + `"data" folder ${dataId ? `exists (${dataId})` : "MISSING"}; ` + + `"data/raw" folder ${rawId ? `exists (${rawId})` : "MISSING"}; ` + + `subject-1.json ${fileInRaw ? "is inside the deepest folder" : "NOT found in data/raw"}.` + ); + } + + // ---- Gate D: pagination CONTRACT (not the adapter's own loop) ----------- + // Creating 1000+ files to exercise listFiles' own pageSize=1000 loop is + // impractical. Instead, query the API directly with a small pageSize + // against files already created by gates A-C and confirm nextPageToken + // behaves the way listFiles assumes (appears when more remain, advances + // between requests, absent on the last page). This is a genuine gap + // between "the contract holds" and "the adapter's own loop was exercised" -- + // recorded as INFO, not PASS, so that gap stays visible. + { + const page1 = await driveFetch( + `/drive/v3/files?q=${encodeURIComponent(`'${container.folderId}' in parents and trashed=false`)}&fields=nextPageToken,files(id,name)&pageSize=1&supportsAllDrives=true&includeItemsFromAllDrives=true` + ).then((r) => r.json()); + + if (!page1.nextPageToken) { + record( + "D. pagination contract", + "INFO", + `Only ${page1.files?.length ?? 0} top-level entr${(page1.files?.length ?? 0) === 1 ? "y" : "ies"} in the ` + + "experiment folder at this point in the run -- not enough to force a second page with pageSize=1. " + + "Cannot exercise the contract here; run gate A/B before this gate or add more top-level writes." + ); + } else { + const page2 = await driveFetch( + `/drive/v3/files?q=${encodeURIComponent(`'${container.folderId}' in parents and trashed=false`)}&fields=nextPageToken,files(id,name)&pageSize=1&pageToken=${encodeURIComponent(page1.nextPageToken)}&supportsAllDrives=true&includeItemsFromAllDrives=true` + ).then((r) => r.json()); + const advanced = page2.nextPageToken !== page1.nextPageToken; + record( + "D. pagination contract", + "INFO", + `Confirmed the CONTRACT with a raw pageSize=1 query: page 1 returned nextPageToken="${page1.nextPageToken}", ` + + `page 2 (using it) returned nextPageToken="${page2.nextPageToken ?? "(absent)"}" (token ${advanced ? "advanced" : "DID NOT ADVANCE"}). ` + + "This is NOT a PASS on the adapter's own do/while loop (pageSize=1000, never exercised at scale here) -- " + + "just confirmation that the API contract listFiles' loop relies on is real." + ); + } + } + + // ---- Gate E: updateFile semantics ---------------------------------------- + // Confirms the media-PATCH actually replaces content in place, and whether + // the file id survives the update -- metadata-block.ts stores that id + // across submissions and PATCHes it again on the next one, so an id that + // changes on update would silently orphan every later metadata write. + { + const original = payload("gate-e-original"); + const w = await gdriveProvider.writeSessionFile(auth, container, "gate-e.json", original, meta(original)); + if (!w.success) { + record("E. updateFile semantics", "FAIL", `initial write rejected: ${w.providerStatus} ${w.providerMessage}`); + } else { + const updated = payload("gate-e-updated"); + const upd = await gdriveProvider.updateFile(auth, container, w.fileRef, updated, meta(updated)); + if (!upd.success) { + record("E. updateFile semantics", "FAIL", `update rejected: ${upd.providerStatus} ${upd.providerMessage}`); + } else { + const back = await gdriveProvider.downloadFile(auth, container, upd.fileRef); + const listed = await gdriveProvider.listFiles(auth, container); + const copies = listed.filter((f) => f.name === "gate-e.json"); + const idStable = upd.fileRef.id === w.fileRef.id; + const contentReplaced = back.success && back.content === updated; + const noNewCopy = copies.length === 1; + const ok = idStable && contentReplaced && noNewCopy; + record( + "E. updateFile semantics", + ok ? "PASS" : "FAIL", + `id stable=${idStable} (${w.fileRef.id} -> ${upd.fileRef.id}); content replaced in place=${contentReplaced}; ` + + `exactly one "gate-e.json" in the listing after update=${noNewCopy} (found ${copies.length}).` + ); + } + } + } + + // ---- Gate F: binary fidelity --------------------------------------------- + // gdrive.ts has no downloadFileBytes (maxFileCount is null, so the + // StorageProvider interface does not require one) -- its only read path, + // downloadFile, decodes the response as UTF-8 text, which is lossy for + // invalid-UTF-8 bytes by construction (same as every other adapter's + // downloadFile). That's fine for this adapter's only real caller + // (metadata-block.ts reads back JSON), but this gate checks the layer below + // that: whether DRIVE ITSELF stores and returns the bytes intact, by + // reading the raw response body directly rather than through + // response.text(). If Drive corrupts the bytes server-side, no adapter-side + // fix would help; if only the text() decode is lossy, that's expected and + // not a bug. + { + const raw = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00, 0x01, 0x80, 0xc0, 0xfd, 0x00, 0x00]); + const w = await gdriveProvider.writeSessionFile(auth, container, "gate-f.bin", raw, { + size: raw.length, + contentType: "application/octet-stream", + }); + if (!w.success) { + record("F. binary fidelity", "FAIL", `write rejected: ${w.providerStatus} ${w.providerMessage}`); + } else { + const rawResponse = await driveFetch(`/drive/v3/files/${w.fileRef.id}?alt=media&supportsAllDrives=true`); + const backBytes = Buffer.from(await rawResponse.arrayBuffer()); + const exact = Buffer.compare(backBytes, raw) === 0; + + const viaAdapter = await gdriveProvider.downloadFile(auth, container, w.fileRef); + const adapterLossy = !viaAdapter.success || Buffer.compare(Buffer.from(viaAdapter.content, "utf8"), raw) !== 0; + + record( + "F. binary fidelity", + exact ? "PASS" : "FAIL", + `raw byte-for-byte round trip (bypassing downloadFile's text() decode)=${exact}; ` + + `downloadFile (the adapter's only read path) is lossy-as-expected=${adapterLossy}` + + (exact + ? " -- Drive itself preserves bytes; the adapter has no lossless read method today, which is fine for its current caller (JSON-only metadata reads)." + : " <-- Drive corrupted the bytes server-side, independent of any adapter behavior.") + ); + } + } + + // ---- Gate G: error mapping ----------------------------------------------- + { + // G1: invalid token -> AUTH_EXPIRED (the minimum required check). + const badAuth = { token: "definitely-not-a-valid-token" }; + const body = payload("gate-g1"); + const w1 = await gdriveProvider.writeSessionFile(badAuth, container, "gate-g1.json", body, meta(body)); + record( + "G1. invalid token -> AUTH_EXPIRED", + !w1.success && w1.error === "AUTH_EXPIRED" ? "PASS" : "FAIL", + `success=${w1.success} error=${w1.error ?? "(none)"} providerStatus=${w1.providerStatus ?? "(none)"}` + ); + + // G2: write into a folder id that doesn't exist -> the code has no + // explicit case for a Drive 404 (mapDriveError's only branches are + // 401/403/429; everything else falls through to UNAVAILABLE) -- confirm + // that's really what a real 404 does, safely (a bad-folder write, not a + // quota-exhausting one). + const ghostContainer = { provider: "gdrive", folderId: "0000000000000000000ghost" }; + const body2 = payload("gate-g2"); + const w2 = await gdriveProvider.writeSessionFile(auth, ghostContainer, "gate-g2.json", body2, meta(body2)); + record( + "G2. nonexistent parent folder -> mapped error", + !w2.success ? (w2.error === "UNAVAILABLE" ? "PASS" : "INFO") : "FAIL", + w2.success + ? "write UNEXPECTEDLY succeeded against a folder id that should not exist" + : `success=false error=${w2.error} providerStatus=${w2.providerStatus} providerMessage="${w2.providerMessage}" ` + + (w2.error === "UNAVAILABLE" + ? "(falls through mapDriveError's default branch, as read from the source)" + : "(mapped differently than the 401/403/429/default branches in mapDriveError predict -- worth a closer look)") + ); + + // G3: updateFile against a fileRef id that doesn't exist -> same + // fall-through-to-UNAVAILABLE question, on the PATCH path instead of POST. + const ghostFileRef = { id: "0000000000000000000ghostfile", name: "ghost.json" }; + const body3 = payload("gate-g3"); + const w3 = await gdriveProvider.updateFile(auth, container, ghostFileRef, body3, meta(body3)); + record( + "G3. updateFile on a nonexistent file id -> mapped error", + !w3.success ? (w3.error === "UNAVAILABLE" ? "PASS" : "INFO") : "FAIL", + w3.success + ? "update UNEXPECTEDLY succeeded against a file id that should not exist" + : `success=false error=${w3.error} providerStatus=${w3.providerStatus} providerMessage="${w3.providerMessage}"` + ); + } + + // ---- Gate H: concurrent writes into the SAME NEW nested subfolder ------- + // Found while reading the adapter, not in the original brief: writeSessionFile + // walks a path's segments through findOrCreateFolder, which is find-THEN- + // create -- two calls, not one atomic operation. A burst of concurrent + // writes to a path whose intermediate folder does not exist YET can each + // see "not found" and each create their own copy, leaving Drive with + // multiple folders sharing the same name at the same level. listFiles would + // still find every file (it recurses into every folder it discovers, + // duplicates included), so this would not by itself cause a MISSED + // duplicate the way gate A's question would -- but it would leave the + // Drive folder structure duplicated and confusing, and is worth knowing + // about regardless. + { + const label = `gate-h-${Date.now()}`; + const writes = await Promise.all( + Array.from({ length: burst }, (_, i) => { + const body = payload(`gate-h-${i}`); + return gdriveProvider.writeSessionFile(auth, container, `${label}/file-${i}.json`, body, meta(body)); + }) + ); + const ok = writes.filter((w) => w.success).length; + + const folderListing = await driveFetch( + `/drive/v3/files?q=${encodeURIComponent(`name='${label}' and '${container.folderId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`)}&supportsAllDrives=true&includeItemsFromAllDrives=true` + ).then((r) => r.json()); + const folderCount = (folderListing.files || []).length; + + const listed = await gdriveProvider.listFiles(auth, container); + const filesLanded = listed.filter((f) => f.name.startsWith("file-")).length; + + record( + "H. concurrent writes into a new shared subfolder (race in findOrCreateFolder)", + ok === burst && folderCount === 1 ? "PASS" : folderCount > 1 ? "PARTIAL" : "FAIL", + `${ok}/${burst} writes succeeded; Drive now has ${folderCount} folder(s) named "${label}" ` + + `under the experiment root; listFiles still reports ${filesLanded}/${burst} of this gate's files ` + + "(recursion finds every folder regardless of duplication)." + + (folderCount > 1 + ? " <-- findOrCreateFolder raced: concurrent submissions to a brand-new nested path can " + + "fragment into sibling folders with the same name. Files are not lost (listFiles still finds " + + "them all), but the folder structure a researcher sees in Drive is duplicated." + : "") + ); + } + + // ---- cleanup -------------------------------------------------------------- + if (cleanup) { + const del = await driveFetch(`/drive/v3/files/${container.folderId}?supportsAllDrives=true`, { method: "DELETE" }); + console.log(`\nCleanup: DELETE experiment folder ${container.folderId} -> ${del.status || "(no content)"}`); + } else { + console.log(`\nLeaving experiment folder ${container.folderId} in place (GDRIVE_CLEANUP=0).`); + console.log(` https://drive.google.com/drive/folders/${container.folderId}`); + } + + console.log("\n==== SUMMARY ===="); + for (const r of results) { + console.log(`${r.verdict.padEnd(8)} ${r.gate}`); + } + const failed = results.filter((r) => r.verdict === "FAIL"); + console.log(failed.length === 0 ? "\nNo gate failed." : `\n${failed.length} gate(s) FAILED -- see detail above.`); + process.exit(failed.length > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error("\nSpike aborted:", e); + process.exit(1); +}); From baad59207363db8e0366e167076a4ee948e71033 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Fri, 21 Aug 2026 08:17:53 -0400 Subject: [PATCH 094/181] fix: stop concurrent Drive writes from fragmenting the folder tree Confirmed live by the new spike, not theorised: 8 concurrent writes to one brand-new nested path produced 8 sibling folders with the same name (gate H, 2026-08-21). findOrCreateFolder is find-then-create and Drive offers no create-if-absent, so first-time writes to a path race. It fires under exactly the load the system is designed for. Requirement 6 is 30-100 students submitting inside a minute, and on a fresh metadataActive experiment those are all first-time writes to data/raw/. No data is lost -- listFiles recurses and collects every file by leaf name, so the collision cache stays sound -- but the researcher's Drive folder ends up with the tree duplicated, which is not a valid Psych-DS layout. Two changes, because prevention and recovery are different problems. createDataContainer now creates the data/raw chain up front, when there is exactly one caller and therefore no race. Unconditionally rather than only for metadataActive experiments: metadata can be switched on long after the container exists, and two empty folders cost far less than the race they remove. Best-effort -- a failure there must not cost the researcher their experiment, since the write path can still create them on demand. findFolder now returns the lowest id rather than files[0]. Drive does not document an ordering for a name query, so with duplicates already present two concurrent writers could pick DIFFERENT folders and keep fragmenting. Sorting makes every caller converge on one. That is the backstop for experiments created before the fix above; it does not un-duplicate what already exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/providers-gdrive.test.js | 121 +++++++++++++++++- functions/src/providers/gdrive.ts | 41 +++++- 2 files changed, 157 insertions(+), 5 deletions(-) diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js index 541f4be..213ea2b 100644 --- a/functions/src/__tests__/providers-gdrive.test.js +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -690,9 +690,20 @@ describe("6. createDataContainer", () => { mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-A", name: "My Experiment" } }) ); + // The Psych-DS chain is now created up front (find+create for "data", + // then find+create for "raw") so the write path never races to make it. + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "data-folder-id", name: "data" } }) + ); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "raw-folder-id", name: "raw" } }) + ); + const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment" }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(6); const findUrl = new URL(callArgs(0).url); expect(findUrl.searchParams.get("q")).toContain("name='DataPipe'"); @@ -718,9 +729,20 @@ describe("6. createDataContainer", () => { mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-B", name: "My Experiment 2" } }) ); + // The Psych-DS chain is now created up front (find+create for "data", + // then find+create for "raw") so the write path never races to make it. + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "data-folder-id", name: "data" } }) + ); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "raw-folder-id", name: "raw" } }) + ); + const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment 2" }); - expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch).toHaveBeenCalledTimes(7); expect(JSON.parse(callArgs(1).options.body)).toEqual({ name: "DataPipe", @@ -744,14 +766,24 @@ describe("6. createDataContainer", () => { mockFetch.mockResolvedValueOnce( mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "child-id-C", name: "My Experiment 3" } }) ); + // Then the Psych-DS chain, same as the other two paths. + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "data-folder-id", name: "data" } }) + ); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "raw-folder-id", name: "raw" } }) + ); const result = await gdriveProvider.createDataContainer(auth, { name: "My Experiment 3", parentId: "picker-chosen-folder-id", }); - // Exactly one call -- no root find, no root create. - expect(mockFetch).toHaveBeenCalledTimes(1); + // One call for the experiment folder -- no root find, no root create -- + // then four for the Psych-DS chain. + expect(mockFetch).toHaveBeenCalledTimes(5); expect(JSON.parse(callArgs(0).options.body)).toEqual({ name: "My Experiment 3", mimeType: "application/vnd.google-apps.folder", @@ -928,6 +960,87 @@ describe("8. a failure inside the nested folder walk keeps its real error code", // path prefix as real nested FOLDERS and the file under its bare leaf name, // and listFiles collects every file it finds under that leaf regardless of // which folder it came from -- so the leaf is what the cache must hash. +describe("9. the findOrCreateFolder race (spike gate H)", () => { + // Confirmed live, not theorised: 8 concurrent writes to one brand-new nested + // path produced 8 sibling folders with the same name (gate H, 2026-08-21). + // findOrCreateFolder is find-then-create and Drive has no create-if-absent. + // + // It fires under exactly the designed-for load -- requirement 6 is 30-100 + // students inside a minute, and on a fresh metadataActive experiment those + // are all first-time writes to data/raw/. No data is lost (listFiles + // recurses and collects by leaf) but the researcher's Drive tree ends up + // duplicated, which is not a valid Psych-DS layout. + + it("pre-creates the data/raw chain so the write path never has to", async () => { + // The actual fix: make the folders at container-creation time, when there + // is exactly one caller and therefore no race. + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "exp-folder", name: "E" } }) + ); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "data-id", name: "data" } }) + ); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, statusText: "OK", jsonBody: { files: [] } })); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "raw-id", name: "raw" } }) + ); + + await gdriveProvider.createDataContainer(auth, { name: "E", parentId: "p" }); + + const created = mockFetch.mock.calls + .filter(([, opts]) => opts.method === "POST") + .map(([, opts]) => JSON.parse(opts.body)); + expect(created).toEqual([ + { name: "E", mimeType: "application/vnd.google-apps.folder", parents: ["p"] }, + { name: "data", mimeType: "application/vnd.google-apps.folder", parents: ["exp-folder"] }, + { name: "raw", mimeType: "application/vnd.google-apps.folder", parents: ["data-id"] }, + ]); + }); + + it("does not fail experiment creation if the chain cannot be pre-made", async () => { + // Best-effort: the write path can still create them on demand, so a + // failure here must not cost the researcher their experiment. + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "exp-folder-2", name: "E2" } }) + ); + mockFetch.mockResolvedValueOnce(mockResponse({ status: 500, statusText: "Server Error" })); + + const result = await gdriveProvider.createDataContainer(auth, { name: "E2", parentId: "p" }); + expect(result).toEqual({ provider: "gdrive", folderId: "exp-folder-2" }); + }); + + it("converges on one folder when duplicates already exist", async () => { + // The backstop, for experiments created before the fix above. Drive does + // not document an ordering for a name query, so returning files[0] let two + // concurrent writers pick DIFFERENT folders and keep fragmenting the tree. + // Sorting makes every caller agree. + mockFetch.mockResolvedValueOnce( + mockResponse({ + status: 200, + statusText: "OK", + jsonBody: { files: [{ id: "zzz-late" }, { id: "aaa-first" }, { id: "mmm-mid" }] }, + }) + ); + mockFetch.mockResolvedValueOnce( + mockResponse({ status: 200, statusText: "OK", jsonBody: { id: "file-id", name: "x.json" } }) + ); + + await gdriveProvider.writeSessionFile( + auth, + { provider: "gdrive", folderId: "root-folder" }, + "data/x.json", + "{}", + { size: 2, contentType: "application/json" } + ); + + // The upload names the deterministic winner, not whichever Drive listed + // first. + const upload = mockFetch.mock.calls.at(-1)[1].body; + expect(String(upload)).toContain("aaa-first"); + }); +}); + describe("storedNameFor (collision-cache namespace)", () => { it("keeps only the leaf, matching what listFiles reports", () => { expect(gdriveProvider.storedNameFor("data/raw/abc123.json")).toBe("abc123.json"); diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index 1933881..08d33e7 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -168,7 +168,18 @@ async function findFolder( const body = (await response.json()) as { files?: { id: string }[] }; const files = body.files || []; - return files.length > 0 ? files[0].id : null; + if (files.length === 0) { + return null; + } + + // Lowest id wins, deterministically, rather than "whatever Drive listed + // first". Drive allows duplicate folder names and does not document an + // ordering here, so with duplicates present two concurrent writers could + // otherwise pick DIFFERENT folders and keep fragmenting the tree. Sorting + // makes every caller converge on one, which is what stops a race from + // compounding once it has happened. Preventing it in the first place is + // createDataContainer's job (see the eager folder creation there). + return files.map((file) => file.id).sort()[0]; } async function createFolder(auth: ResolvedAuth, name: string, parentId: string): Promise<string> { @@ -344,6 +355,34 @@ export const gdriveProvider: StorageProvider = { // names, so there's nothing to find-or-create here. const folderId = await createFolder(auth, name, targetParentId); + // Create the Psych-DS folder chain NOW, so the write path only ever finds + // it. findOrCreateFolder is find-then-create with no atomicity, and Drive + // offers no create-if-absent, so a burst of first-time submissions to a + // brand-new nested path races and produces sibling folders with the same + // name. Confirmed live rather than theorised: 8 concurrent writes to one + // new path produced 8 folders (spike gate H, 2026-08-21). + // + // That is exactly the designed-for load -- requirement 6 is 30-100 + // students inside a minute, and on a fresh metadataActive experiment those + // are all first-time writes to data/raw/. No data is lost (listFiles + // recurses and collects by leaf name) but the researcher's Drive folder + // ends up with the tree duplicated, which is not a valid Psych-DS layout. + // + // Created unconditionally rather than only for metadataActive experiments: + // metadata can be switched on at any time, long after the container + // exists, and two empty folders cost far less than the race they remove. + // Best-effort -- a failure here must not fail experiment creation, since + // the write path can still create them itself. + try { + const dataId = await findOrCreateFolder(auth, "data", folderId); + await findOrCreateFolder(auth, "raw", dataId); + } catch (e) { + console.warn( + `gdrive: could not pre-create the data/raw folders for ${folderId}; the write path will create them on demand:`, + e instanceof Error ? e.message : e + ); + } + return { provider: "gdrive", folderId }; }, From a70c380c8b90f7b68ecf57347619b7981af2178b Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Fri, 21 Aug 2026 12:26:27 -0400 Subject: [PATCH 095/181] feat: move Zenodo storage from pasted tokens to OAuth2 Researchers no longer create a personal access token and paste it in; they authorize DataPipe on Zenodo like they already do for Drive. There were no Zenodo connections outside the test deployment, so this is a clean cut with no dual-mode period. Zenodo documents none of this -- developers.zenodo.org covers personal access tokens only -- so the behaviour was read out of invenio-oauth2server and then checked against the running service with scripts/zenodo-oauth-spike.mjs. Two things the source could not have told us, and one it told us wrongly: - Access tokens last SIXTY DAYS, not the one hour the source implies. Invenio never sets OAUTH2_PROVIDER_TOKEN_EXPIRES_IN, so reading it predicts oauthlib's 3600s default; Zenodo overrides it in deployment config. Refresh is therefore a rare event that will essentially never be exercised in testing. - The application MUST be registered as a confidential client. Registered as public, everything works except refresh, which fails with 400 invalid_grant -- get_token's refresh branch filters on Client.is_confidential. The symptom appears two months after connecting and looks nothing like its cause. - Zenodo answers 403 for every auth failure there is: revoked token, under-scoped token, no token at all, and an access token a concurrent refresh rotated away. It never returns 401. The refresh path gets its own module rather than reusing gdrive's, because Zenodo rotates the refresh token on every refresh and deletes the old one. That makes persisting the rotation part of the refresh's correctness -- a crash between the response and the Firestore write strands the researcher mid-experiment -- and it makes a concurrent refresh look identical to a revoked grant. recoverFromRotationRace() re-reads the stored connection to tell them apart, since only local state can. Scope is deposit:write and deposit:actions. Deliberately not user:email, which invenio-oauth2server also offers: Zenodo cannot be a sign-in provider regardless, as it implements no OIDC layer at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .github/workflows/firebase-deploy-test.yml | 4 + __tests__/provider-config.test.js | 12 +- components/account/ProviderConnections.js | 10 +- docs/provider-migration-design.md | 138 +++++++ functions/.env.datapipe-test | 19 + .../src/__tests__/compaction-emulator.test.js | 12 +- .../__tests__/finalization-emulator.test.js | 12 +- .../__tests__/providers-zenodo-oauth.test.js | 290 +++++++++++++ .../src/__tests__/providers-zenodo.test.js | 139 +++++-- .../src/__tests__/zenodo-emulator.test.js | 12 +- functions/src/api-messages.ts | 14 +- functions/src/providers/types.ts | 15 +- functions/src/providers/zenodo-oauth.ts | 264 ++++++++++++ functions/src/providers/zenodo.ts | 115 ++++-- lib/provider-config.js | 28 +- scripts/zenodo-oauth-spike.mjs | 382 ++++++++++++++++++ 16 files changed, 1350 insertions(+), 116 deletions(-) create mode 100644 functions/src/__tests__/providers-zenodo-oauth.test.js create mode 100644 functions/src/providers/zenodo-oauth.ts create mode 100644 scripts/zenodo-oauth-spike.mjs diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index ba8d073..19f8519 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -67,6 +67,10 @@ jobs: # Google Drive client secret for the deployed test site. Client id and # redirect uri are non-secret and live in functions/.env.datapipe-test. echo "GDRIVE_CLIENT_SECRET=${{ secrets.TEST_GDRIVE_CLIENT_SECRET }}" >> .env + # Zenodo OAuth client secret for the deployed test site. Client id, + # redirect uri and ZENODO_ENV are non-secret and live in + # functions/.env.datapipe-test. + echo "ZENODO_CLIENT_SECRET=${{ secrets.TEST_ZENODO_CLIENT_SECRET }}" >> .env - name: Install dependencies and build functions working-directory: functions run: | diff --git a/__tests__/provider-config.test.js b/__tests__/provider-config.test.js index 7672ec8..fe09812 100644 --- a/__tests__/provider-config.test.js +++ b/__tests__/provider-config.test.js @@ -111,15 +111,17 @@ describe("zenodo: which Zenodo a deployment points at", () => { } it("points production at the real zenodo.org", () => { - expect(loadWith("").zenodo.defaultServerUrl).toBe("https://zenodo.org"); + expect(loadWith("").zenodo.containerLink({})).toBe( + "https://zenodo.org/deposit/undefined" + ); }); it("points the test deployment at the sandbox", () => { // The whole reason this setting exists: without it the test site creates // real depositions on the live service using the researcher's real // account. - expect(loadWith("sandbox.").zenodo.defaultServerUrl).toBe( - "https://sandbox.zenodo.org" + expect(loadWith("sandbox.").zenodo.containerLink({})).toBe( + "https://sandbox.zenodo.org/deposit/undefined" ); }); @@ -127,7 +129,9 @@ describe("zenodo: which Zenodo a deployment points at", () => { // An unset variable must never resolve to something like // "https://undefinedzenodo.org", and defaulting to sandbox would be worse // -- a misconfigured production deploy would silently write nowhere real. - expect(loadWith(undefined).zenodo.defaultServerUrl).toBe("https://zenodo.org"); + expect(loadWith(undefined).zenodo.containerLink({})).toBe( + "https://zenodo.org/deposit/undefined" + ); }); it("containerLink follows the container's host, not the current deployment", () => { diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 42103b4..2de561d 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -69,10 +69,12 @@ export default function ProviderConnections() { idToken, token: apiToken.trim(), // connectstatictokenprovider ALWAYS requires a serverUrl, but not - // every static-token provider is federated. Dataverse is (the - // researcher types their institution's installation); Zenodo is not - // -- there is exactly one production host -- so its config supplies - // a fixed defaultServerUrl and renders no field at all. + // every static-token provider is federated, so a provider may supply + // a fixed defaultServerUrl and render no field at all. Dataverse is + // federated (the researcher types their institution's installation) + // and is currently the only provider reaching this branch -- Zenodo + // was the non-federated example until it moved to OAuth2 on + // 2026-08-21 and stopped coming through here entirely. serverUrl: STORAGE_PROVIDERS[providerId]?.needsServerUrl ? serverUrl.trim() : STORAGE_PROVIDERS[providerId]?.defaultServerUrl, diff --git a/docs/provider-migration-design.md b/docs/provider-migration-design.md index c122e14..1af821a 100644 --- a/docs/provider-migration-design.md +++ b/docs/provider-migration-design.md @@ -830,3 +830,141 @@ Google Drive provider is announced: one on `experiments`). The Firestore emulator does not enforce composite indexes, so the test suite passes without them and only a deploy can confirm the query shapes match. + +## Zenodo: static token → OAuth2 (2026-08-21) + +Zenodo shipped as a static-token provider: the researcher created a personal +access token on Zenodo and pasted it into DataPipe. It is now OAuth2. There +were no existing Zenodo connections anywhere but the test deployment, so this +was a clean cut with no dual-mode period and no migration path. + +### What the source says, because the documentation does not exist + +Zenodo publishes nothing about its OAuth application flow — +`developers.zenodo.org` documents personal access tokens only. Zenodo runs +InvenioRDM (it reports `InvenioRDM 15.0` in its page generator meta tag), so +the answers came from reading `invenio-oauth2server` and `oauthlib` directly: + +- **Endpoints** are `/oauth/authorize` and `/oauth/token`. `POST /oauth/token` + answers **404** when `client_id` matches no registered Client — the view + calls `abort(404)` before oauthlib sees the request. A 404 there means a + misconfigured client, not a missing endpoint. +- **Scopes**: `deposit:write`, `deposit:actions`, and — undocumented by Zenodo + — `user:email`, registered by `invenio-oauth2server` itself via its own + entry point, so it exists on every Invenio instance. DataPipe requests only + the two deposit scopes. +- **Identity comes back in the token response.** `save_token` attaches + `user: {id}` unconditionally and adds `email`/`email_verified` only when + `user:email` was granted. No `/api/me` round trip is needed by anyone who + wants it. +- **Access tokens expire after sixty days.** The `Token` model has a single + `expires` column, governing the access token. Reading the source predicts one + hour — Invenio never sets `OAUTH2_PROVIDER_TOKEN_EXPIRES_IN`, so it falls + through to oauthlib's `expires_in or 3600` — but the sandbox actually issues + `expires_in=5184000`, i.e. 60 days. Zenodo overrides it in deployment config, + which is not public. Measured by spike gate J on 2026-08-21; do not trust the + source figure. +- **Refresh tokens rotate, and the old one dies immediately.** oauthlib's + `rotate_refresh_token` defaults to `True`, and `save_token` deletes *every* + prior `Token` row for `(client_id, user_id)` before inserting the new one: + *"make sure that every client has only one token connected to a user."* + +### Why Zenodo is not a sign-in provider + +It was considered and rejected on evidence. `invenio-oauth2server` contains +**zero** references to `id_token`, `openid`, `oidc`, `jwks`, or `userinfo` — it +is a pure OAuth2 authorization server with no OIDC layer. Firebase's generic +OIDC provider has nothing to discover or verify, so Zenodo sign-in would mean +reintroducing a server-side custom-token path of the kind the OSF split +removed. ORCID already covers researcher identity and *is* a real OIDC +provider. See `lib/auth-providers.js`. + +### What the rotation rule forces + +Two things, both in `functions/src/providers/zenodo-oauth.ts`: + +1. **Persisting the rotated refresh token is part of the refresh's + correctness.** The presented token is dead the moment Zenodo answers, so a + crash between the HTTP response and the Firestore write leaves the + researcher unable to write data mid-experiment, with no symptom until the + access token lapses an hour later. Google's stable refresh tokens make the + same crash harmless, which is why `gdrive-oauth.ts` can be simpler. +2. **A concurrent refresh is not a dead connection.** Two submissions after an + idle hour both refresh; the loser presents a token that no longer exists and + gets `400 invalid_grant`. `recoverFromRotationRace()` re-reads the stored + connection and continues with whatever the winner persisted, rather than + tearing down a working account. Only an `invalid_grant` whose stored refresh + token is *unchanged* is a genuine revocation. + +Deployment config, not the stored connection, decides which Zenodo we mean: an +OAuth `client_id` is registered against one installation, so a sandbox client +cannot complete a flow on `zenodo.org`. `resolveToken` ignores any `serverUrl` +left on a connection. + +### What the spike found (2026-08-21, sandbox) + +`scripts/zenodo-oauth-spike.mjs` against sandbox.zenodo.org. All gates pass. + +| Gate | Result | +| --- | --- | +| J. authorization_code exchange | **PASS** — `expires_in=5184000` (60 days), refresh_token present | +| J0. client authentication | `client_secret_post` accepted; HTTP Basic never needed | +| K. identity without `user:email` | **PASS** — `user.id` returned, no email | +| O. OAuth token drives the adapter | **PASS** — created a deposition, wrote a file; the two deposit scopes suffice | +| L. refresh rotates the refresh token | **PASS** — new value differs, as `rotate_refresh_token=True` implies | +| M. superseded refresh token rejected | **PASS** — replay returns `400 invalid_grant` | +| N. previous access token survives | **NO** — it is dead immediately | + +**The application must be registered as a CONFIDENTIAL client.** Registered as +public, everything works except refresh, which fails with `400 invalid_grant` — +`get_token`'s refresh branch filters on `Client.is_confidential == True`, so the +lookup simply returns nothing. The symptom appears two months after connecting, +when the first refresh is due, and looks nothing like its cause. The +registration form defaults to Confidential; this was found by registering one +that was not. + +**Zenodo answers 403 for every auth failure.** Measured directly: a revoked +token, a garbage token, an access token rotated away by a concurrent refresh, +and a request with no `Authorization` header at all all return 403. It never +returns 401. So the response cannot distinguish a transient rotation from a +terminal revocation — only comparing the token used against the one now stored +can, which is what `recoverFromRotationRace()` does for refresh tokens. + +**Gate N's consequence, and the probe retry tier.** Because a refresh deletes +the prior access token, an upload already in flight when another request +refreshes fails with 403 and maps to `AUTH_EXPIRED` — a code that meant +"reconnect by hand" and sat on the retry queue's hours-scale tier, when this +particular instance of it heals itself in seconds. + +The fix is a third tier in `queue-upload.ts`: `PROBE_RETRY_CODES`. A probe code +takes ONE minute-scale look and then, if that fails, resumes the ordinary +hours-scale chain. It is free rather than additive — the early attempt +displaces the 1-hour first attempt instead of extending the chain, so an item +still gets five tries across ~30 hours instead of ~31, with the first look ~59 +minutes earlier. Nothing in `scheduled-upload-retry.ts` changes: the probe only +moves the first delay, and that file's existing backoff already yields 2 hours +for the next attempt. + +Its members are `AUTH_EXPIRED` and `UNAVAILABLE` — the two codes that cannot +distinguish a transient failure from a terminal one, so looking once is the +cheapest way to find out. `RATE_LIMITED` is excluded because the provider has +stated how long it will keep refusing and probing inside its own window is the +one thing it asked us not to do; `QUOTA_EXCEEDED` because it needs compaction +or a human and neither happens within a minute. `CONTENTION` keeps its full +fast tier, which is a different thing: a minutes-scale schedule for all five +attempts rather than a single early look. + +Note that the retry worker runs on `*/5`, so a 60-second delay really means +"the next five-minute tick" — that is the number to judge this against, and it +is still an order of magnitude better than an hour. + +This generalizes beyond Zenodo: any provider that conflates a transient auth +failure with a terminal one now gets one cheap look before the long wait. + +### Production checklist + +Register an application at `zenodo.org/account/settings/applications/` +(confidential client, redirect `https://pipe.jspsych.org/oauth2/connect`), then +set `ZENODO_CLIENT_ID` / `ZENODO_REDIRECT_URI` in the production functions env +and add the secret to the production deploy workflow. Leave `ZENODO_ENV` unset +so it resolves to `zenodo.org`. diff --git a/functions/.env.datapipe-test b/functions/.env.datapipe-test index 285c4e1..37a8a2e 100644 --- a/functions/.env.datapipe-test +++ b/functions/.env.datapipe-test @@ -20,3 +20,22 @@ GDRIVE_REDIRECT_URI=https://datapipe-test.web.app/oauth2/connect # .env.<project> beats .env. Left as-is to avoid orphaning already-encrypted # test tokens; worth revisiting as a separate change. TOKEN_ENCRYPTION_KEY=abababababababababababababababababababababababababababababababab + +# --- Zenodo OAuth2 (since 2026-08-21; previously a pasted personal token) --- +# ZENODO_ENV picks the installation the OAuth client is registered against: +# "sandbox." here, UNSET in production. This is not a preference -- a sandbox +# client_id cannot complete a flow on zenodo.org, so this and the credentials +# below have to agree. +# +# ZENODO_AUTHORIZE_URL / ZENODO_TOKEN_URL are intentionally unset so the code +# derives them from ZENODO_ENV, mirroring the GDRIVE_* convention above. +# +# The client SECRET is not here (this file is committed). It is injected at +# deploy time from the TEST_ZENODO_CLIENT_SECRET GitHub secret, and for a local +# `firebase deploy` from the git-ignored functions/.env. +ZENODO_ENV=sandbox. +ZENODO_REDIRECT_URI=https://datapipe-test.web.app/oauth2/connect +# TODO: fill in from the OAuth application registered at +# https://sandbox.zenodo.org/account/settings/applications/ -- connecting a +# Zenodo account fails until this is set. +ZENODO_CLIENT_ID=b7FnwoIGfECAs3UCSuJNQqHvXizQG9TYargM52Vu diff --git a/functions/src/__tests__/compaction-emulator.test.js b/functions/src/__tests__/compaction-emulator.test.js index d9770da..16ae0da 100644 --- a/functions/src/__tests__/compaction-emulator.test.js +++ b/functions/src/__tests__/compaction-emulator.test.js @@ -284,11 +284,19 @@ beforeAll(async () => { await db.collection("users").doc(OWNER_ID).set({ connectedAccounts: { zenodo: { - authMethod: "static-token", + authMethod: "oauth2", + // Zenodo moved from a pasted personal access token to OAuth2 on + // 2026-08-21. tokenExpiresAt has to sit comfortably in the future or + // resolveToken would try to refresh -- these suites exercise the write + // path against the mock, not the OAuth path, which + // providers-zenodo-oauth.test.js covers against the emulator. + // serverUrl is deliberately absent: it is deployment config now, and + // in any case ZENODO_API_BASE overrides it for every call here. // Plaintext: no "v1:" prefix, so decrypt() passes it through. Same // convention as gdrive-emulator.test.js. encryptedToken: "compaction-token", - serverUrl: ZENODO_SERVER_URL, + encryptedRefreshToken: "compaction-refresh", + tokenExpiresAt: Date.now() + 24 * 60 * 60 * 1000, }, }, }); diff --git a/functions/src/__tests__/finalization-emulator.test.js b/functions/src/__tests__/finalization-emulator.test.js index a79c972..8f6f160 100644 --- a/functions/src/__tests__/finalization-emulator.test.js +++ b/functions/src/__tests__/finalization-emulator.test.js @@ -246,9 +246,17 @@ beforeAll(async () => { await db.collection("users").doc(OWNER_ID).set({ connectedAccounts: { zenodo: { - authMethod: "static-token", + authMethod: "oauth2", + // Zenodo moved from a pasted personal access token to OAuth2 on + // 2026-08-21. tokenExpiresAt has to sit comfortably in the future or + // resolveToken would try to refresh -- these suites exercise the write + // path against the mock, not the OAuth path, which + // providers-zenodo-oauth.test.js covers against the emulator. + // serverUrl is deliberately absent: it is deployment config now, and + // in any case ZENODO_API_BASE overrides it for every call here. encryptedToken: "finalization-token", - serverUrl: ZENODO_SERVER_URL, + encryptedRefreshToken: "finalization-refresh", + tokenExpiresAt: Date.now() + 24 * 60 * 60 * 1000, }, }, }); diff --git a/functions/src/__tests__/providers-zenodo-oauth.test.js b/functions/src/__tests__/providers-zenodo-oauth.test.js new file mode 100644 index 0000000..398f27f --- /dev/null +++ b/functions/src/__tests__/providers-zenodo-oauth.test.js @@ -0,0 +1,290 @@ +/** + * @jest-environment node + */ + +// Firestore-emulator-backed tests for zenodo-oauth.ts, following +// resolve-token-gdrive.test.js's harness exactly (own admin app for fixtures, +// one fresh uid per test, global.fetch mocked because zenodo-oauth.ts uses the +// runtime's fetch rather than the node-fetch package). +// +// WHAT MAKES THIS WORTH A SEPARATE FILE FROM providers-zenodo.test.js: Zenodo +// rotates the refresh token on EVERY refresh and deletes the previous one +// server-side (invenio-oauth2server's save_token: "make sure that every client +// has only one token connected to a user"). That is not a detail -- it means +// persistence is part of the refresh's correctness, not a side effect, so +// these cases have to assert what landed in Firestore rather than just what +// the function returned. Mocks alone cannot express the failure it guards +// against. + +jest.mock("node-fetch", () => ({ + __esModule: true, + default: jest.fn(), +})); + +import { initializeApp, getApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { randomUUID } from "crypto"; +import { refreshZenodoToken, EXPIRY_MARGIN_MS } from "../../lib/providers/zenodo-oauth.js"; +import { decrypt, encrypt } from "../../lib/crypto-utils.js"; + +process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; + +jest.setTimeout(30000); + +let db; + +const ORIGINAL_ENV = { + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + ZENODO_TOKEN_URL: process.env.ZENODO_TOKEN_URL, + ZENODO_CLIENT_ID: process.env.ZENODO_CLIENT_ID, + ZENODO_CLIENT_SECRET: process.env.ZENODO_CLIENT_SECRET, +}; +const ORIGINAL_FETCH = global.fetch; + +const TOLERANCE_MS = 5000; + +beforeAll(() => { + let app; + try { + app = getApp("zenodo-oauth-test"); + } catch { + app = initializeApp({ projectId: "datapipe-test" }, "zenodo-oauth-test"); + } + db = getFirestore(app); + + process.env.TOKEN_ENCRYPTION_KEY = "22".repeat(32); + process.env.ZENODO_TOKEN_URL = "https://zenodo-token.mock.test/oauth/token"; + process.env.ZENODO_CLIENT_ID = "test-zenodo-client-id"; + process.env.ZENODO_CLIENT_SECRET = "test-zenodo-client-secret"; +}); + +afterAll(() => { + process.env.TOKEN_ENCRYPTION_KEY = ORIGINAL_ENV.TOKEN_ENCRYPTION_KEY; + process.env.ZENODO_TOKEN_URL = ORIGINAL_ENV.ZENODO_TOKEN_URL; + process.env.ZENODO_CLIENT_ID = ORIGINAL_ENV.ZENODO_CLIENT_ID; + process.env.ZENODO_CLIENT_SECRET = ORIGINAL_ENV.ZENODO_CLIENT_SECRET; + global.fetch = ORIGINAL_FETCH; +}); + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +function tokenResponse(body, { ok = true, status = 200 } = {}) { + return { + ok, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)), + }; +} + +function errorResponse(text, status = 400) { + return { + ok: false, + status, + json: () => Promise.resolve({}), + text: () => Promise.resolve(text), + }; +} + +// Seeds users/{uid} and returns both the uid and the connection object a +// caller would have read a moment earlier. Tests that model a race pass a +// DIFFERENT connection than what was stored. +async function seedUser(overrides = {}) { + const uid = `zenodo-oauth-${randomUUID()}`; + const connection = { + authMethod: "oauth2", + encryptedToken: encrypt("access-old"), + encryptedRefreshToken: encrypt("refresh-old"), + tokenExpiresAt: Date.now() - 1000, + ...overrides, + }; + await db.doc(`users/${uid}`).set({ connectedAccounts: { zenodo: connection } }); + return { uid, connection }; +} + +async function readConnection(uid) { + const snap = await db.doc(`users/${uid}`).get(); + return snap.data().connectedAccounts.zenodo; +} + +describe("refreshZenodoToken: the ordinary path", () => { + it("exchanges the refresh token and persists the new access token", async () => { + const { uid, connection } = await seedUser(); + global.fetch.mockResolvedValueOnce( + tokenResponse({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 }) + ); + + const result = await refreshZenodoToken(uid, connection); + + expect(result).toEqual({ success: true, accessToken: "access-new" }); + + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("https://zenodo-token.mock.test/oauth/token"); + const body = new URLSearchParams(options.body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh-old"); + expect(body.get("client_id")).toBe("test-zenodo-client-id"); + + const stored = await readConnection(uid); + expect(decrypt(stored.encryptedToken)).toBe("access-new"); + expect(stored.tokenExpiresAt).toBeGreaterThan(Date.now() + 3600 * 1000 - TOLERANCE_MS); + }); + + // THE ONE THAT MATTERS MOST. Zenodo has already deleted "refresh-old" by the + // time we get this response; failing to store "refresh-new" would leave the + // researcher permanently unable to write data, mid-experiment, with no + // symptom until the access token lapses an hour later. + it("persists the rotated refresh token, not just the access token", async () => { + const { uid, connection } = await seedUser(); + global.fetch.mockResolvedValueOnce( + tokenResponse({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 }) + ); + + await refreshZenodoToken(uid, connection); + + const stored = await readConnection(uid); + expect(decrypt(stored.encryptedRefreshToken)).toBe("refresh-new"); + }); + + it("keeps the previous refresh token when the response omits one", async () => { + const { uid, connection } = await seedUser(); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + global.fetch.mockResolvedValueOnce( + tokenResponse({ access_token: "access-new", expires_in: 3600 }) + ); + + const result = await refreshZenodoToken(uid, connection); + + expect(result.success).toBe(true); + // Keeping a probably-dead token beats clearing the field: it leaves the + // next refresh something to fail loudly on rather than a missing property. + expect(decrypt((await readConnection(uid)).encryptedRefreshToken)).toBe("refresh-old"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("reports a response missing access_token as a failure", async () => { + const { uid, connection } = await seedUser(); + global.fetch.mockResolvedValueOnce(tokenResponse({ expires_in: 3600 })); + + const result = await refreshZenodoToken(uid, connection); + + expect(result.success).toBe(false); + expect(result.error).toBe("INVALID_REFRESH_TOKEN"); + // Nothing partial written. + expect(decrypt((await readConnection(uid)).encryptedToken)).toBe("access-old"); + }); +}); + +describe("refreshZenodoToken: the rotation race", () => { + // Two submissions arrive after an idle hour, both see an expired access + // token, both refresh. The first rotates refresh-old -> refresh-winner; the + // second still holds refresh-old in memory and Zenodo no longer knows it. + // The connection is HEALTHY -- treating this as a dead grant would tear down + // a working account in the middle of data collection. + it("recovers by using the credentials the winner persisted", async () => { + const { uid } = await seedUser({ + encryptedToken: encrypt("access-winner"), + encryptedRefreshToken: encrypt("refresh-winner"), + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + }); + const staleConnection = { + authMethod: "oauth2", + encryptedToken: encrypt("access-old"), + encryptedRefreshToken: encrypt("refresh-old"), + tokenExpiresAt: Date.now() - 1000, + }; + global.fetch.mockResolvedValueOnce(errorResponse('{"error": "invalid_grant"}')); + + const result = await refreshZenodoToken(uid, staleConnection); + + expect(result).toEqual({ success: true, accessToken: "access-winner" }); + // One exchange only -- the winner's token was still fresh, so there was + // nothing to ask Zenodo for. + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("exchanges once more when the winner's access token is also stale", async () => { + const { uid } = await seedUser({ + encryptedToken: encrypt("access-winner"), + encryptedRefreshToken: encrypt("refresh-winner"), + tokenExpiresAt: Date.now() + EXPIRY_MARGIN_MS - 1000, + }); + const staleConnection = { + authMethod: "oauth2", + encryptedToken: encrypt("access-old"), + encryptedRefreshToken: encrypt("refresh-old"), + tokenExpiresAt: Date.now() - 1000, + }; + global.fetch + .mockResolvedValueOnce(errorResponse('{"error": "invalid_grant"}')) + .mockResolvedValueOnce( + tokenResponse({ access_token: "access-final", refresh_token: "refresh-final", expires_in: 3600 }) + ); + + const result = await refreshZenodoToken(uid, staleConnection); + + expect(result).toEqual({ success: true, accessToken: "access-final" }); + expect(global.fetch).toHaveBeenCalledTimes(2); + // The retry must present the WINNER's refresh token, not the dead one. + expect(new URLSearchParams(global.fetch.mock.calls[1][1].body).get("refresh_token")).toBe( + "refresh-winner" + ); + expect(decrypt((await readConnection(uid)).encryptedRefreshToken)).toBe("refresh-final"); + }); + + // Same invalid_grant, but nothing rotated -- the stored token is still the + // one we presented. This is a genuinely revoked grant and must be reported + // as such, or a researcher who disconnected DataPipe on Zenodo would see + // retries forever instead of being told to reconnect. + it("reports a genuinely revoked grant when nothing rotated", async () => { + const { uid, connection } = await seedUser(); + global.fetch.mockResolvedValueOnce(errorResponse('{"error": "invalid_grant"}')); + + const result = await refreshZenodoToken(uid, connection); + + expect(result.success).toBe(false); + expect(result.error).toBe("INVALID_REFRESH_TOKEN"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("reports PROVIDER_NOT_CONNECTED when the account vanished mid-flight", async () => { + const { uid, connection } = await seedUser(); + await db.doc(`users/${uid}`).set({ connectedAccounts: {} }); + global.fetch.mockResolvedValueOnce(errorResponse('{"error": "invalid_grant"}')); + + const result = await refreshZenodoToken(uid, connection); + + expect(result.success).toBe(false); + expect(result.error).toBe("PROVIDER_NOT_CONNECTED"); + }); + + // invalid_client and unsupported_grant_type are also 400s, but they are + // configuration faults -- a wrong client secret, say. Sending those down the + // race-recovery path would waste a Firestore read hunting for a rotation + // that never happened, and could mask a broken deployment as a transient. + it("does not treat other 400s as a race", async () => { + const { uid, connection } = await seedUser(); + global.fetch.mockResolvedValueOnce(errorResponse('{"error": "invalid_client"}')); + + const result = await refreshZenodoToken(uid, connection); + + expect(result.success).toBe(false); + expect(result.error).toBe("INVALID_REFRESH_TOKEN"); + expect(result.detail).toContain("invalid_client"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("reports a network error without touching stored credentials", async () => { + const { uid, connection } = await seedUser(); + global.fetch.mockRejectedValueOnce(new Error("socket hang up")); + + const result = await refreshZenodoToken(uid, connection); + + expect(result.success).toBe(false); + expect(result.detail).toContain("socket hang up"); + expect(decrypt((await readConnection(uid)).encryptedRefreshToken)).toBe("refresh-old"); + }); +}); diff --git a/functions/src/__tests__/providers-zenodo.test.js b/functions/src/__tests__/providers-zenodo.test.js index e5b0b62..d027d17 100644 --- a/functions/src/__tests__/providers-zenodo.test.js +++ b/functions/src/__tests__/providers-zenodo.test.js @@ -21,6 +21,14 @@ const DEPOSITION_ID = 987654; beforeEach(() => { mockFetch.mockClear(); + // zenodoOAuthHost() reads this at CALL time, so it has to be set per test + // rather than at import. Pinned to the sandbox so it agrees with SERVER_URL + // below and a stray real-host URL in an assertion stands out. + process.env.ZENODO_ENV = "sandbox."; +}); + +afterAll(() => { + delete process.env.ZENODO_ENV; }); function mockResponse({ status, statusText, jsonBody, textBody, headers }) { @@ -56,21 +64,29 @@ const container = { const meta = { size: 12, contentType: "application/json" }; describe("1. resolveToken", () => { - it("returns the decrypted token and serverUrl for a connected account", async () => { - const userData = { + // decrypt() falls back to plaintext for values without the "v1:" prefix, so + // plain strings round-trip without needing TOKEN_ENCRYPTION_KEY here. The + // refresh path -- which encrypts and persists -- is exercised against the + // Firestore emulator in providers-zenodo-oauth.test.js instead. + function connected(overrides) { + return { connectedAccounts: { zenodo: { - authMethod: "static-token", - // decrypt() falls back to plaintext for values without the "v1:" - // prefix, so a plain string round-trips without needing - // TOKEN_ENCRYPTION_KEY set up for this test. + authMethod: "oauth2", encryptedToken: "plain-token", - serverUrl: SERVER_URL, + encryptedRefreshToken: "plain-refresh", + tokenExpiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, }, }, }; - const result = await zenodoProvider.resolveToken(userData, "owner-uid"); + } + + it("returns the stored token while it is still comfortably fresh", async () => { + const result = await zenodoProvider.resolveToken(connected(), "owner-uid"); expect(result).toEqual({ success: true, token: "plain-token", serverUrl: SERVER_URL }); + // No refresh request: a live token must not cost a round trip. + expect(mockFetch).not.toHaveBeenCalled(); }); it("fails with PROVIDER_NOT_CONNECTED when there is no zenodo account", async () => { @@ -79,28 +95,48 @@ describe("1. resolveToken", () => { expect(result.error).toBe("PROVIDER_NOT_CONNECTED"); }); - // Zenodo tokens have no documented expiry so tokenExpiresAt is normally - // absent -- but if one is ever stored it must still be honored rather than - // ignored. - it("honors a stored tokenExpiresAt in the past", async () => { - const userData = { - connectedAccounts: { - zenodo: { - authMethod: "static-token", - encryptedToken: "plain-token", - serverUrl: SERVER_URL, - tokenExpiresAt: Date.now() - 1000, - }, - }, - }; - const result = await zenodoProvider.resolveToken(userData, "owner-uid"); - expect(result.success).toBe(false); - expect(result.error).toBe("PROVIDER_TOKEN_EXPIRED"); + // The host is now deployment configuration, not per-connection data. An + // OAuth client_id is registered against ONE installation, so a serverUrl + // left on a stored connection -- by a migration, a hand edit, or an old + // static-token document -- must never be able to point a live client at the + // other Zenodo. + it("ignores any serverUrl left on the stored connection", async () => { + const result = await zenodoProvider.resolveToken( + connected({ serverUrl: "https://zenodo.org" }), + "owner-uid" + ); + expect(result.success).toBe(true); + expect(result.serverUrl).toBe(SERVER_URL); + }); + + it("defaults to production zenodo.org when ZENODO_ENV is unset", async () => { + delete process.env.ZENODO_ENV; + const result = await zenodoProvider.resolveToken(connected(), "owner-uid"); + // Never "https://undefinedzenodo.org", and never the sandbox: a + // misconfigured production deploy must fail loudly against the real host + // rather than quietly writing test data nobody looks at. + expect(result.serverUrl).toBe("https://zenodo.org"); }); - it("does not implement staticTokenExpiry (Zenodo reports no expiry)", () => { + it("no longer implements the static-token hooks", () => { + // connect-provider.ts's connectStaticTokenProvider rejects any provider + // missing validateStaticToken, which is what stops a researcher pasting a + // personal access token into a flow that now expects OAuth. + expect(zenodoProvider.validateStaticToken).toBeUndefined(); expect(zenodoProvider.staticTokenExpiry).toBeUndefined(); }); + + // Zenodo access tokens last an hour, short enough that one checked as valid + // at the top of a request can die during a slow upload -- a compaction pass + // moves up to MAX_BATCH_BYTES in a single call. + it("treats a token expiring within the margin as already stale", async () => { + const userData = connected({ tokenExpiresAt: Date.now() + 30 * 1000 }); + // Refreshing needs Firestore, which this suite has no emulator for, so + // assert the decision rather than the outcome: it must NOT hand back the + // nearly-dead token. + const result = await zenodoProvider.resolveToken(userData, "owner-uid").catch(() => null); + expect(result?.token).not.toBe("plain-token"); + }); }); describe("2. server allowlist", () => { @@ -512,18 +548,47 @@ describe("8. downloadFile", () => { }); }); -describe("9. validateStaticToken", () => { - it("returns true on 200", async () => { - mockFetch.mockResolvedValueOnce(mockResponse({ status: 200, jsonBody: [] })); - expect(await zenodoProvider.validateStaticToken(auth)).toBe(true); - expect(callArgs(0).url).toBe(`${SERVER_URL}/api/deposit/depositions?size=1`); +describe("9. oauthConfig", () => { + it("targets the installation named by ZENODO_ENV", () => { + const config = zenodoProvider.oauthConfig(); + expect(config.authorizeUrl).toBe("https://sandbox.zenodo.org/oauth/authorize"); + expect(config.tokenUrl).toBe("https://sandbox.zenodo.org/oauth/token"); + }); + + it("defaults to production when ZENODO_ENV is unset", () => { + delete process.env.ZENODO_ENV; + const config = zenodoProvider.oauthConfig(); + expect(config.authorizeUrl).toBe("https://zenodo.org/oauth/authorize"); + }); + + // THE SCOPE LIST IS A DECISION, NOT A DETAIL. invenio-oauth2server also + // registers a user:email scope, and asking for it would hand us the + // researcher's identity in the token response itself. We deliberately do + // not: identity is Firebase's job (lib/auth-providers.js), and Zenodo could + // not serve as a sign-in provider regardless -- invenio-oauth2server + // implements no OIDC layer at all, so there is no id_token for Firebase to + // verify. Widening this list would widen the consent screen for nothing. + it("requests exactly the two deposit scopes and no identity scope", () => { + const scopes = zenodoProvider.oauthConfig().scope.split(" ").sort(); + expect(scopes).toEqual(["deposit:actions", "deposit:write"]); + }); + + // Google needs access_type=offline&prompt=consent to issue a refresh token + // at all. Zenodo issues one unconditionally on the authorization_code + // grant, so there is nothing to add here -- but the field must still be an + // object, because generate-oauth-state.ts iterates it unguarded. + it("adds no extra authorize parameters, but still supplies the object", () => { + expect(zenodoProvider.oauthConfig().extraAuthParams).toEqual({}); }); - // An under-scoped token is "not valid" here rather than an exception -- - // catching it at connect time is the point. - it("returns false on 403 without throwing", async () => { - mockFetch.mockResolvedValueOnce(mockResponse({ status: 403, jsonBody: { message: "Insufficient scope" } })); - expect(await zenodoProvider.validateStaticToken(auth)).toBe(false); + it("reads client credentials from the environment at call time", () => { + process.env.ZENODO_CLIENT_ID = "test-client"; + process.env.ZENODO_REDIRECT_URI = "https://datapipe-test.web.app/oauth2/connect"; + const config = zenodoProvider.oauthConfig(); + expect(config.clientId).toBe("test-client"); + expect(config.redirectUri).toBe("https://datapipe-test.web.app/oauth2/connect"); + delete process.env.ZENODO_CLIENT_ID; + delete process.env.ZENODO_REDIRECT_URI; }); }); @@ -744,7 +809,7 @@ describe("9e. writeStreamedFile", () => { describe("10. registry wiring", () => { it("declares the capability surface the framework reads", () => { expect(zenodoProvider.id).toBe("zenodo"); - expect(zenodoProvider.authMethod).toBe("static-token"); + expect(zenodoProvider.authMethod).toBe("oauth2"); // No folder concept in either Zenodo API generation -- the framework's // filename-prefix fallback has to apply. expect(zenodoProvider.capabilities.nativeSubfolders).toBe(false); diff --git a/functions/src/__tests__/zenodo-emulator.test.js b/functions/src/__tests__/zenodo-emulator.test.js index 6433579..f6f5aaf 100644 --- a/functions/src/__tests__/zenodo-emulator.test.js +++ b/functions/src/__tests__/zenodo-emulator.test.js @@ -245,9 +245,17 @@ beforeAll(async () => { await db.collection("users").doc(ZENODO_OWNER_ID).set({ connectedAccounts: { zenodo: { - authMethod: "static-token", + authMethod: "oauth2", + // Zenodo moved from a pasted personal access token to OAuth2 on + // 2026-08-21. tokenExpiresAt has to sit comfortably in the future or + // resolveToken would try to refresh -- these suites exercise the write + // path against the mock, not the OAuth path, which + // providers-zenodo-oauth.test.js covers against the emulator. + // serverUrl is deliberately absent: it is deployment config now, and + // in any case ZENODO_API_BASE overrides it for every call here. encryptedToken: "zenodo-integration-token", // plaintext fallback, see header - serverUrl: ZENODO_SERVER_URL, + encryptedRefreshToken: "zenodo-integration-refresh", + tokenExpiresAt: Date.now() + 24 * 60 * 60 * 1000, }, }, }); diff --git a/functions/src/api-messages.ts b/functions/src/api-messages.ts index b2daa8e..df7a9b3 100644 --- a/functions/src/api-messages.ts +++ b/functions/src/api-messages.ts @@ -47,12 +47,14 @@ const MESSAGES = { error: "PROVIDER_NOT_CONNECTED", message: "The experiment owner has not connected an account for this experiment's storage provider", }, - // Named no provider. Both static-token adapters emit this code (dataverse.ts - // and zenodo.ts), so hardcoding "Dataverse" told a Zenodo owner to go fix a - // token on a service they may not even use. The wording still carries what - // makes this code distinct from AUTH_EXPIRED -- a static token cannot be - // refreshed, so the researcher has to CREATE a new one and reconnect, not - // just re-authorize. + // Names no provider. It once had to cover two static-token adapters, and + // hardcoding "Dataverse" told a Zenodo owner to go fix a token on a service + // they may not even use. Zenodo moved to OAuth2 on 2026-08-21 and no longer + // emits this at all, leaving dataverse.ts as the only source -- but the + // wording stays provider-neutral, since the next static-token provider would + // reintroduce exactly the same bug. What it carries is what makes this code + // distinct from AUTH_EXPIRED: a static token cannot be refreshed, so the + // researcher has to CREATE a new one and reconnect, not just re-authorize. PROVIDER_TOKEN_EXPIRED: { error: "PROVIDER_TOKEN_EXPIRED", message: diff --git a/functions/src/providers/types.ts b/functions/src/providers/types.ts index 6df398b..adbe128 100644 --- a/functions/src/providers/types.ts +++ b/functions/src/providers/types.ts @@ -355,11 +355,16 @@ export interface ConnectedAccounts { gdrive?: OAuth2AccountConnection; figshare?: OAuth2AccountConnection; dataverse?: StaticTokenAccountConnection; - // Zenodo reuses the static-token shape, but its tokenExpiresAt is expected - // to stay ABSENT: Zenodo personal access tokens have no documented expiry - // and no endpoint reports one, so zenodo.ts implements no staticTokenExpiry - // and connect-provider.ts therefore omits the field. - zenodo?: StaticTokenAccountConnection; + // Zenodo was a static-token provider until 2026-08-21 and is now OAuth2. + // Its tokenExpiresAt is always PRESENT, unlike the personal access tokens + // this replaced, which never expired at all -- but it is SIXTY DAYS out, not + // the one hour the upstream source implies (Zenodo overrides oauthlib's + // default in deployment config; measured by spike gate J, 2026-08-21). + // The refresh token behind it carries no expiry of its own, so a connection + // can survive indefinitely -- but only if every rotation is persisted, + // because Zenodo destroys the previous refresh token on each refresh. See + // zenodo-oauth.ts. + zenodo?: OAuth2AccountConnection; } // experiments/{id}.collisionCache (additive Firestore schema). The salt is a diff --git a/functions/src/providers/zenodo-oauth.ts b/functions/src/providers/zenodo-oauth.ts new file mode 100644 index 0000000..c2eee14 --- /dev/null +++ b/functions/src/providers/zenodo-oauth.ts @@ -0,0 +1,264 @@ +// Zenodo OAuth token refresh. +// +// SEPARATE FROM gdrive-oauth.ts DESPITE THE SIMILAR SHAPE, because Zenodo's +// refresh semantics differ in one way that changes the whole failure model: +// ZENODO ROTATES THE REFRESH TOKEN ON EVERY REFRESH AND DESTROYS THE OLD ONE. +// +// Verified by reading invenio-oauth2server (Zenodo runs InvenioRDM, which +// reports itself in the page generator meta tag), 2026-08-21 -- none of this +// is in Zenodo's developer documentation, which only covers personal access +// tokens: +// +// oauthlib RequestValidator.rotate_refresh_token -> defaults to True +// invenio_oauth2server.provider.save_token -> deletes EVERY prior +// Token row for (client_id, user_id) before inserting the new one +// ("make sure that every client has only one token connected to a user") +// +// Google keeps a refresh token stable for years and reissues the same value, +// so gdrive's path can treat a lost refresh response as harmless. Here it is +// not. Two consequences, both handled below: +// +// 1. LOSING THE RESPONSE LOSES THE ACCOUNT. The old refresh token is dead the +// instant Zenodo answers, so a crash between the HTTP response and the +// Firestore write leaves the researcher unable to write data until they +// reconnect by hand -- mid-experiment. persistRefreshedToken() is +// therefore the very next thing that happens after parsing, and a failure +// there is logged distinctly rather than folded into "refresh failed". +// +// 2. CONCURRENT REFRESHES RACE. Two submissions arriving after an idle hour +// both see an expired access token and both refresh. The first rotates +// R -> R1; the second presents R, which no longer exists, and Zenodo +// answers 400 invalid_grant. That is NOT a dead connection -- R1 is +// perfectly good -- and treating it as one would tear down a working +// account in the middle of data collection. recoverFromRotationRace() +// re-reads the stored connection and continues with whatever the winner +// persisted. +// +// Uses the runtime's global `fetch` rather than the "node-fetch" package, +// matching gdrive-oauth.ts and refresh-token.ts so tests can mock global.fetch. + +import { decrypt, encrypt } from "../crypto-utils.js"; +import { db } from "../app.js"; +import { OAuth2AccountConnection } from "./types.js"; + +export type ZenodoRefreshResult = + | { success: true; accessToken: string } + | { success: false; error: string; detail: string }; + +const CONNECTION_PATH = "connectedAccounts.zenodo"; + +// Treat a token as expired this long before it actually is, so a token checked +// as valid at the top of a request cannot expire during a slow upload -- a +// compaction pass moves up to MAX_BATCH_BYTES in a single call. +// +// MEASURED, NOT ASSUMED: sandbox.zenodo.org issues expires_in=5184000, i.e. +// SIXTY DAYS (spike gate J, 2026-08-21). Reading the source would tell you one +// hour -- Invenio never sets OAUTH2_PROVIDER_TOKEN_EXPIRES_IN, so it falls +// through to oauthlib's `expires_in or 3600` -- but Zenodo overrides it in +// deployment config, which is not public. This is exactly the class of fact a +// spike exists to establish. +// +// A five-minute margin against a sixty-day lifetime is therefore vanishingly +// small, and deliberately so: it is here to stop a mid-request expiry, not to +// schedule refreshes. What the long lifetime really means is that refresh is a +// RARE event -- roughly one per connection per two months -- so it will almost +// never be exercised in testing and has to be right by construction. +export const EXPIRY_MARGIN_MS = 5 * 60 * 1000; + +// The Zenodo installation this DEPLOYMENT talks to: "" -> zenodo.org, +// "sandbox." -> sandbox.zenodo.org. Mirrors NEXT_PUBLIC_ZENODO_ENV on the +// frontend and NEXT_PUBLIC_OSF_ENV's host-prefix shape. +// +// Unlike the static-token era, this is NOT a per-researcher choice any more +// and must not be read off the stored connection: an OAuth client_id is +// registered against ONE installation, so a sandbox client simply cannot +// complete a flow on zenodo.org. Deployment config is the only thing that can +// legitimately decide which host we mean. +export function zenodoOAuthHost(): string { + return `https://${process.env.ZENODO_ENV ?? ""}zenodo.org`; +} + +export function zenodoAuthorizeUrl(): string { + return process.env.ZENODO_AUTHORIZE_URL || `${zenodoOAuthHost()}/oauth/authorize`; +} + +export function zenodoTokenUrl(): string { + return process.env.ZENODO_TOKEN_URL || `${zenodoOAuthHost()}/oauth/token`; +} + +type ExchangeOutcome = + | { kind: "ok"; data: Record<string, unknown> } + | { kind: "invalid-grant"; detail: string } + | { kind: "failed"; detail: string }; + +/** + * One POST to Zenodo's token endpoint with the refresh_token grant. + * + * `invalid-grant` is split out from `failed` because only that one is + * ambiguous: it means the presented refresh token does not exist, which is + * true both when the grant was revoked AND when a concurrent refresh rotated + * it a moment ago. The caller resolves that ambiguity; everything else here + * is a plain failure. + */ +async function exchangeRefreshToken(refreshToken: string): Promise<ExchangeOutcome> { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: process.env.ZENODO_CLIENT_ID as string, + client_secret: process.env.ZENODO_CLIENT_SECRET as string, + }); + + let response: Response; + try { + response = await fetch(zenodoTokenUrl(), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown network error"; + return { kind: "failed", detail }; + } + + if (!response.ok) { + const body = await response.text(); + // oauthlib answers 400 {"error": "invalid_grant"}. Matched on the body + // rather than the status alone: 400 also covers invalid_client and + // unsupported_grant_type, which are configuration faults, not races, and + // must not send us hunting for a rotation that never happened. + if (body.includes("invalid_grant")) { + return { kind: "invalid-grant", detail: body }; + } + return { kind: "failed", detail: body || `Token endpoint returned ${response.status}` }; + } + + return { kind: "ok", data: (await response.json()) as Record<string, unknown> }; +} + +/** + * Writes a freshly issued token back to the user document. + * + * This is the dangerous half of a Zenodo refresh: by the time it runs, the + * refresh token we presented has ALREADY been deleted server-side, so if this + * write does not land the researcher's connection is unrecoverable without a + * manual reconnect. Hence the explicit, greppable error log -- a silent throw + * here would be indistinguishable from an ordinary transient failure. + */ +async function persistRefreshedToken( + uid: string, + data: Record<string, unknown> +): Promise<ZenodoRefreshResult> { + const accessToken = data.access_token as string | undefined; + const expiresIn = data.expires_in as number | undefined; + const refreshToken = data.refresh_token as string | undefined; + + if (!accessToken || typeof expiresIn !== "number") { + return { + success: false, + error: "INVALID_REFRESH_TOKEN", + detail: "Zenodo token response was missing access_token or expires_in", + }; + } + + const update: Record<string, unknown> = { + [`${CONNECTION_PATH}.encryptedToken`]: encrypt(accessToken), + [`${CONNECTION_PATH}.tokenExpiresAt`]: Date.now() + expiresIn * 1000, + }; + + if (refreshToken) { + update[`${CONNECTION_PATH}.encryptedRefreshToken`] = encrypt(refreshToken); + } else { + // Should not happen -- oauthlib's refresh grant always issues one -- but + // if it ever does, keeping the old value is strictly better than clearing + // it, and the warning says why the next refresh may fail. + console.warn( + `zenodo: refresh for ${uid} returned no refresh_token; keeping the previous one, ` + + "which Zenodo has probably already invalidated" + ); + } + + try { + await db.doc(`users/${uid}`).update(update); + } catch (e) { + const detail = e instanceof Error ? e.message : "Unknown Firestore error"; + console.error( + `zenodo: FAILED TO PERSIST ROTATED CREDENTIALS for ${uid} -- the previous refresh ` + + `token is already dead server-side, so this account must be reconnected by hand: ${detail}` + ); + return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; + } + + return { success: true, accessToken }; +} + +/** + * Resolves an invalid_grant by asking whether somebody else rotated the token. + * + * Re-reads the stored connection and compares it against the refresh token we + * just tried. A DIFFERENT stored value means a concurrent refresh won the race + * and our token was collateral damage, so the connection is healthy and we + * continue with the winner's credentials. The SAME stored value means nothing + * rotated and the grant really is gone. + * + * Bounded to a single extra exchange: if the winner's access token is already + * expired too we try once more with its refresh token and then stop, rather + * than recursing into a race that a busy experiment could sustain. + */ +async function recoverFromRotationRace( + uid: string, + presentedRefreshToken: string, + detail: string +): Promise<ZenodoRefreshResult> { + const snapshot = await db.doc(`users/${uid}`).get(); + const stored = snapshot.data()?.connectedAccounts?.zenodo as + | OAuth2AccountConnection + | undefined; + + if (!stored?.encryptedRefreshToken) { + return { + success: false, + error: "PROVIDER_NOT_CONNECTED", + detail: "No connected Zenodo account for this experiment's owner", + }; + } + + if (decrypt(stored.encryptedRefreshToken) === presentedRefreshToken) { + return { success: false, error: "INVALID_REFRESH_TOKEN", detail }; + } + + console.log( + `zenodo: refresh for ${uid} lost a rotation race; continuing with the stored credentials` + ); + + if (stored.tokenExpiresAt > Date.now() + EXPIRY_MARGIN_MS) { + return { success: true, accessToken: decrypt(stored.encryptedToken) }; + } + + const retry = await exchangeRefreshToken(decrypt(stored.encryptedRefreshToken)); + if (retry.kind !== "ok") { + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: retry.detail }; + } + return persistRefreshedToken(uid, retry.data); +} + +/** + * Refreshes a single user's Zenodo access token and persists the result, + * rotating the stored refresh token to match. Does not check whether the + * current token is actually expired -- callers decide when to invoke this. + */ +export async function refreshZenodoToken( + uid: string, + connection: OAuth2AccountConnection +): Promise<ZenodoRefreshResult> { + const presentedRefreshToken = decrypt(connection.encryptedRefreshToken); + const outcome = await exchangeRefreshToken(presentedRefreshToken); + + if (outcome.kind === "invalid-grant") { + return recoverFromRotationRace(uid, presentedRefreshToken, outcome.detail); + } + if (outcome.kind === "failed") { + return { success: false, error: "INVALID_REFRESH_TOKEN", detail: outcome.detail }; + } + + return persistRefreshedToken(uid, outcome.data); +} diff --git a/functions/src/providers/zenodo.ts b/functions/src/providers/zenodo.ts index 64ba50c..7027dcf 100644 --- a/functions/src/providers/zenodo.ts +++ b/functions/src/providers/zenodo.ts @@ -12,7 +12,15 @@ import { DeleteResult, ProviderErrorCode, TokenResult, + OAuthConfig, } from "./types.js"; +import { + refreshZenodoToken, + zenodoAuthorizeUrl, + zenodoTokenUrl, + zenodoOAuthHost, + EXPIRY_MARGIN_MS, +} from "./zenodo-oauth.js"; // --------------------------------------------------------------------------- // WHICH ZENODO API THIS TARGETS, AND WHY @@ -252,12 +260,26 @@ function mapZenodoError( let error: ProviderErrorCode; if (status === 401) { + // Kept as a defensive branch, but Zenodo appears never to use it: measured + // 2026-08-21, a revoked token, a garbage token and NO token at all all + // return 403. error = "AUTH_EXPIRED"; } else if (status === 403) { - // Zenodo returns 403 both for an invalid/revoked token and for a token - // whose scopes are insufficient (a PAT created without deposit:write). - // Neither is retryable and both are fixed the same way -- reconnect with a - // correctly scoped token -- so both map here. + // Zenodo returns 403 for every authentication and authorization failure + // there is -- an invalid or revoked token, a token whose scopes are + // insufficient, an access token a concurrent refresh rotated away, and a + // request with no Authorization header at all. All four were measured + // against the sandbox on 2026-08-21 and all four are 403. + // + // MOST of those are fixed the same way (reconnect) and are not retryable, + // which is why they map here. The rotated-away case is the exception: it + // is transient and self-healing, and mapping it to AUTH_EXPIRED puts the + // submission on the retry queue's HOURS-scale tier when it would succeed + // seconds later. The status code cannot separate them -- only comparing + // the token we used against the one now stored can, which this function + // has no access to. See the rotation-race note in zenodo-oauth.ts for why + // that window is one upload wide, once per sixty days, and why the + // consequence is "arrives late", never "lost". error = "AUTH_EXPIRED"; } else if (status === 413 || status === 507) { error = "QUOTA_EXCEEDED"; @@ -341,7 +363,7 @@ interface DepositionFileResponse { export const zenodoProvider: StorageProvider = { id: "zenodo", - authMethod: "static-token", + authMethod: "oauth2", capabilities: { // Zenodo file keys are a flat namespace -- there is no folder concept in // either API generation. The framework's filename-prefix fallback applies. @@ -363,10 +385,34 @@ export const zenodoProvider: StorageProvider = { { name: "affiliation", label: "Affiliation", required: false, placeholder: "Your institution" }, ], - async resolveToken(userData: UserData, _owner: string): Promise<TokenResult> { - // _owner is unused: Zenodo is a static-token provider with no refresh token - // to rotate, so there is no persist-back step (cf. gdrive's resolveToken, - // which calls refreshGdriveToken(owner, ...)). + // A method rather than a static object so env vars are read at CALL time, + // not module load -- same reason as getApiBase() above. + oauthConfig(): OAuthConfig { + return { + authorizeUrl: zenodoAuthorizeUrl(), + tokenUrl: zenodoTokenUrl(), + clientId: process.env.ZENODO_CLIENT_ID as string, + clientSecret: process.env.ZENODO_CLIENT_SECRET as string, + redirectUri: process.env.ZENODO_REDIRECT_URI as string, + // deposit:write uploads files; deposit:actions publishes and edits. Both + // are on the write path and neither is optional. + // + // Deliberately NOT user:email, which invenio-oauth2server also offers: + // identity is Firebase's job (lib/auth-providers.js), and Zenodo could + // not serve as a sign-in provider even if we wanted it to -- + // invenio-oauth2server implements no OIDC layer at all (no id_token, no + // discovery document, no userinfo endpoint), so there is nothing for + // Firebase to verify. Asking for an identity scope we have no use for + // would only widen the consent screen. + scope: "deposit:write deposit:actions", + // Nothing to add: Zenodo issues a refresh token on the + // authorization_code grant unconditionally, so there is no equivalent of + // Google's access_type=offline&prompt=consent dance. + extraAuthParams: {}, + }; + }, + + async resolveToken(userData: UserData, owner: string): Promise<TokenResult> { const zenodo = userData.connectedAccounts?.zenodo; if (!zenodo) { @@ -377,36 +423,33 @@ export const zenodoProvider: StorageProvider = { }; } - // No expiry branch here, unlike dataverse.ts. Zenodo personal access - // tokens have no documented expiry and the API exposes no endpoint that - // reports one, which is also why staticTokenExpiry is deliberately NOT - // implemented on this provider -- its absence means "this provider cannot - // report an expiry", which connect-provider.ts already handles by omitting - // tokenExpiresAt. If a stored tokenExpiresAt ever does appear (e.g. set by - // a future Zenodo change), it is still honored rather than ignored. - if (zenodo.tokenExpiresAt && zenodo.tokenExpiresAt < Date.now()) { - return { - success: false, - error: "PROVIDER_TOKEN_EXPIRED", - detail: "The Zenodo API token for this experiment's owner has expired", - }; + // serverUrl comes from deployment config, NOT from the stored connection. + // Under static tokens a researcher pasted a token and told us which + // installation it belonged to; an OAuth client_id is registered against + // exactly one installation, so a sandbox client physically cannot complete + // a flow on zenodo.org. Trusting a stored value here would let a stale + // connection point a live client at the wrong host. + const serverUrl = zenodoOAuthHost(); + + if (zenodo.tokenExpiresAt > Date.now() + EXPIRY_MARGIN_MS) { + return { success: true, token: decrypt(zenodo.encryptedToken), serverUrl }; } - return { success: true, token: decrypt(zenodo.encryptedToken), serverUrl: zenodo.serverUrl }; - }, + // Refresh inline on demand rather than from a scheduled pass, which is + // also why zenodo implements no refreshExpiringTokens (cf. gdrive, whose + // 10-minute window rides the weekly pass). + // + // Zenodo access tokens last SIXTY DAYS (measured, spike gate J), so a + // proactive sweep would spend two months of weekly wake-ups per account to + // save one inline request. The submission that needs the token is the only + // caller that knows it is needed, and an experiment idle past expiry pays + // for the refresh exactly once, on its next submission. + const refreshResult = await refreshZenodoToken(owner, zenodo); + if (!refreshResult.success) { + return { success: false, error: refreshResult.error, detail: refreshResult.detail }; + } - async validateStaticToken(auth: ResolvedAuth): Promise<boolean> { - const serverUrl = resolveServerUrl(auth); - // size=1 keeps the response tiny -- this only needs the status code. A - // token missing the deposit:write scope still 403s here, which is the - // point: it would fail at the first upload otherwise, months later. - const response = await fetch(`${serverUrl}/api/deposit/depositions?size=1`, { - method: "GET", - headers: authHeaders(auth), - }); - // Never throw on a non-200 -- a bad or under-scoped token is "not valid", - // not an exceptional condition. - return response.status === 200; + return { success: true, token: refreshResult.accessToken, serverUrl }; }, async createDataContainer(auth: ResolvedAuth, researcherInput: Record<string, unknown>): Promise<ContainerRef> { diff --git a/lib/provider-config.js b/lib/provider-config.js index 4584680..2d99873 100644 --- a/lib/provider-config.js +++ b/lib/provider-config.js @@ -84,24 +84,16 @@ export const STORAGE_PROVIDERS = { zenodo: { id: "zenodo", name: "Zenodo", - authMethod: "static-token", - // NOT federated, unlike Dataverse: a researcher never picks an - // installation, so no field is rendered and this value is sent on their - // behalf (see ProviderConnections.js's handleTokenConnect). - // - // It is still per-DEPLOYMENT, which is what NEXT_PUBLIC_ZENODO_ENV - // controls: "" on production -> zenodo.org, "sandbox." on the test site -> - // sandbox.zenodo.org. Same host-prefix shape as NEXT_PUBLIC_OSF_ENV, and - // for the same reason -- without it the test deployment writes real - // depositions to the live service using the researcher's real account. - // The two Zenodos have entirely separate accounts and tokens, so a - // sandbox token pasted into a production-pointed deployment is rejected - // at connect time rather than silently misfiling data. - needsServerUrl: false, - defaultServerUrl: ZENODO_HOST, - tokenLabel: "Personal access token", - tokenHelp: - "Create one under Applications → Personal access tokens in your Zenodo account settings. It needs the deposit:write and deposit:actions scopes. Zenodo tokens do not expire.", + // OAuth2 since 2026-08-21, previously a pasted personal access token. + // The switch is not cosmetic: an OAuth client_id is registered against ONE + // Zenodo installation, so which Zenodo this deployment talks to is now + // fixed by NEXT_PUBLIC_ZENODO_ENV ("" -> zenodo.org, "sandbox." -> the + // sandbox) and by the matching server-side client credentials, rather than + // being sent along with the researcher's token at connect time. Same + // host-prefix shape as NEXT_PUBLIC_OSF_ENV, and for the same reason: + // without it the test deployment writes real depositions to the live + // service using the researcher's real account. + authMethod: "oauth2", isConnected: (userDoc) => !!userDoc?.connectedAccounts?.zenodo, // Zenodo depositions stay unpublished while data is being collected, so // the researcher-facing link is the deposit editor rather than a public diff --git a/scripts/zenodo-oauth-spike.mjs b/scripts/zenodo-oauth-spike.mjs new file mode 100644 index 0000000..de58db0 --- /dev/null +++ b/scripts/zenodo-oauth-spike.mjs @@ -0,0 +1,382 @@ +// Zenodo OAuth2 gating spike (docs/provider-migration-design.md). +// +// Zenodo publishes NO documentation for its OAuth application flow -- +// developers.zenodo.org covers personal access tokens only. Everything +// DataPipe assumes about it was read out of invenio-oauth2server's source on +// 2026-08-21. This script exists to check those readings against the running +// service, because a source file on GitHub master is not the same thing as +// whatever revision Zenodo actually deploys. +// +// Usage: +// cd functions && npm run build && cd .. +// ZENODO_CLIENT_ID=xxx ZENODO_CLIENT_SECRET=yyy node scripts/zenodo-oauth-spike.mjs +// +// Env: +// ZENODO_CLIENT_ID (required) from the registered OAuth application +// ZENODO_CLIENT_SECRET (required) same application, confidential client +// ZENODO_ENV (default "sandbox.") "" targets production zenodo.org +// ZENODO_OAUTH_PORT (default 3000) port for the local redirect catcher +// ZENODO_REDIRECT_URI (default http://localhost:<port>/oauth2/connect) +// ZENODO_CODE an authorization code obtained by hand (see below) +// ZENODO_CLEANUP (default 1; set to 0 to leave the deposition behind) +// +// TWO WAYS TO GET THE AUTHORIZATION CODE: +// +// 1. AUTOMATIC (default). Requires http://localhost:<port>/oauth2/connect to +// be one of the application's registered redirect URIs -- Zenodo's +// validate_redirect_uri permits plain http ONLY for localhost/127.0.0.1, +// so this needs no tunnel. The script starts a throwaway server, prints a +// consent URL, and catches the redirect itself. +// +// 2. MANUAL, when only the deployed redirect URI is registered. Set +// ZENODO_REDIRECT_URI to it; the script prints the consent URL and stops. +// Approve it, then copy the `code` query parameter out of the browser's +// address bar and re-run with ZENODO_CODE=<that value>. The deployed +// connect page will show a CSRF error, which is correct and harmless -- +// the state did not come from that browser, so the page refuses to spend +// the code and it is still yours to use. Codes are single-use and +// short-lived, so re-run promptly. +// +// Leaves the deposition UNPUBLISHED -- nothing here mints a DOI. + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { zenodoProvider } from "../functions/lib/providers/zenodo.js"; + +const clientId = process.env.ZENODO_CLIENT_ID; +const clientSecret = process.env.ZENODO_CLIENT_SECRET; +const env = process.env.ZENODO_ENV ?? "sandbox."; +const port = Number(process.env.ZENODO_OAUTH_PORT || 3000); +const cleanup = process.env.ZENODO_CLEANUP !== "0"; +const suppliedCode = process.env.ZENODO_CODE; + +const host = `https://${env}zenodo.org`; +const redirectUri = process.env.ZENODO_REDIRECT_URI || `http://localhost:${port}/oauth2/connect`; +// Only a loopback redirect can be caught locally; anything else has to come +// back through a real browser, so the code is supplied by hand instead. +const canCatchRedirect = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(redirectUri); + +if (!clientId || !clientSecret) { + console.error("ZENODO_CLIENT_ID and ZENODO_CLIENT_SECRET are required. See the header of this file."); + process.exit(1); +} + +const results = []; +const record = (gate, verdict, detail) => { + results.push({ gate, verdict, detail }); + console.log(`\n[${verdict}] ${gate}\n ${detail}`); +}; + +// Waits for Zenodo to redirect back with ?code=, then shuts the server down. +function awaitAuthorizationCode(state) { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${port}`); + if (url.pathname !== "/oauth2/connect") { + res.writeHead(404).end(); + return; + } + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(`<html><body style="font-family:system-ui;padding:3rem"> + <h2>${code ? "Authorized." : "Authorization failed."}</h2> + <p>You can close this tab and return to the terminal.</p></body></html>`); + server.close(); + if (error) return reject(new Error(`Zenodo returned error=${error}`)); + if (!code) return reject(new Error("No code in the redirect")); + if (returnedState !== state) return reject(new Error("State mismatch")); + resolve(code); + }); + server.listen(port, "127.0.0.1"); + const waitMs = Number(process.env.ZENODO_OAUTH_WAIT_MS || 15 * 60 * 1000); + setTimeout(() => { + server.close(); + reject(new Error(`Timed out waiting for the consent redirect (${Math.round(waitMs / 60000)} min)`)); + }, waitMs).unref(); + }); +} + +async function postToken(params, { basicAuth = false } = {}) { + const headers = { "Content-Type": "application/x-www-form-urlencoded" }; + const body = { ...params }; + if (basicAuth) { + // client_secret_basic: credentials move to the Authorization header and + // must NOT also appear in the body, or oauthlib treats it as two competing + // authentication attempts. + delete body.client_id; + delete body.client_secret; + headers.Authorization = + "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); + } + const response = await fetch(`${host}/oauth/token`, { + method: "POST", + headers, + body: new URLSearchParams(body).toString(), + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + /* keep text for the failure detail */ + } + return { ok: response.ok, status: response.status, json, text }; +} + +// Cheapest authenticated call that distinguishes a live token from a dead one. +async function tokenIsLive(accessToken) { + const response = await fetch(`${host}/api/deposit/depositions?size=1`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + return response.status === 200; +} + +async function main() { + console.log(`Zenodo OAuth spike against ${host}\n`); + + const state = randomUUID(); + const authorizeUrl = new URL(`${host}/oauth/authorize`); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("response_type", "code"); + // Exactly the scopes zenodo.ts's oauthConfig() requests -- deliberately no + // user:email, which gate K checks the consequence of. + authorizeUrl.searchParams.set("scope", "deposit:write deposit:actions"); + authorizeUrl.searchParams.set("state", state); + + let code; + if (suppliedCode) { + // State cannot be checked on this path -- the redirect never came back + // through this process. That is acceptable here and nowhere else: this is a + // local diagnostic run by the person who just approved the consent, not a + // login flow exposed to anyone. connect-provider.ts validates state + // server-side for the real thing. + code = suppliedCode; + console.log("Using the authorization code supplied via ZENODO_CODE."); + } else if (canCatchRedirect) { + console.log("Open this URL and approve:\n"); + console.log(` ${authorizeUrl}\n`); + console.log(`Listening on ${redirectUri} ...`); + code = await awaitAuthorizationCode(state); + console.log("\nGot an authorization code."); + } else { + console.log("Open this URL and approve:\n"); + console.log(` ${authorizeUrl}\n`); + console.log(`${redirectUri} is not a loopback address, so this script cannot catch the`); + console.log("redirect. Approve the consent, copy the `code` parameter out of the address"); + console.log("bar, and re-run with:\n"); + console.log(" ZENODO_CODE=<code> node scripts/zenodo-oauth-spike.mjs\n"); + console.log("The connect page will show a CSRF error -- that is expected, and it means the"); + console.log("code was not spent. Codes are short-lived, so re-run promptly."); + return; + } + + // ---- Gate J: does the exchange work, and how long does a token live? ---- + // + // Tried two ways, because Zenodo documents neither and the answer decides + // what connect-provider.ts has to send. RFC 6749 lets a confidential client + // authenticate either by putting client_id/client_secret in the body + // (client_secret_post) or by HTTP Basic (client_secret_basic), and servers + // are free to accept only one. A rejected authentication does not spend the + // authorization code, so falling through to the second attempt is safe. + const grantParams = { + grant_type: "authorization_code", + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }; + + console.log(`\n redirect_uri sent: ${redirectUri}`); + let first = await postToken(grantParams); + let authStyle = "client_secret_post (credentials in the body)"; + + if (!first.ok) { + console.log(` client_secret_post -> ${first.status} ${first.text.slice(0, 200)}`); + console.log(" retrying with HTTP Basic ..."); + first = await postToken(grantParams, { basicAuth: true }); + authStyle = "client_secret_basic (HTTP Basic)"; + } + + if (!first.ok || !first.json?.access_token) { + record( + "J. authorization_code exchange", + "FAIL", + `Both authentication styles rejected. Last: ${first.status} ${first.text.slice(0, 300)}` + ); + return; + } + + record("J0. client authentication style", "INFO", `Zenodo accepted ${authStyle}.`); + + // Persist the token response when asked. A 60-day access token is still + // usable long after this run, and it means probing the refresh grant does + // not cost a fresh consent round trip every time. + if (process.env.ZENODO_TOKEN_OUT) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(process.env.ZENODO_TOKEN_OUT, JSON.stringify(first.json, null, 2)); + console.log(` token response written to ${process.env.ZENODO_TOKEN_OUT}`); + } + + const expiresIn = first.json.expires_in; + record( + "J. authorization_code exchange", + "PASS", + `expires_in=${expiresIn}s (${(expiresIn / 3600).toFixed(2)}h), ` + + `refresh_token=${first.json.refresh_token ? "present" : "ABSENT"}, ` + + `scope="${first.json.scope}"` + ); + + if (expiresIn !== 3600) { + record( + "J2. access-token lifetime matches the source default", + "INFO", + `Zenodo issued ${expiresIn}s, not oauthlib's 3600s default -- it overrides ` + + "OAUTH2_PROVIDER_TOKEN_EXPIRES_IN in deployment config. EXPIRY_MARGIN_MS in " + + "zenodo-oauth.ts is sized against this number; check it still makes sense." + ); + } + + // ---- Gate K: identity leakage without the user:email scope ---- + const user = first.json.user; + if (!user) { + record("K. identity in the token response", "INFO", "No `user` object at all in the token response."); + } else if (user.email) { + record( + "K. identity in the token response", + "FAIL", + `Zenodo returned an email (${user.email}) even though user:email was NOT requested. ` + + "We would be receiving identity data we never asked for and do not want." + ); + } else { + record( + "K. identity in the token response", + "PASS", + `user.id=${user.id} present, no email -- matches invenio-oauth2server's save_token, ` + + "which only adds the address when the user:email scope is granted." + ); + } + + // ---- Gate O: can an OAuth token actually drive the adapter? ---- + const auth = { token: first.json.access_token, serverUrl: host }; + let container = null; + try { + container = await zenodoProvider.createDataContainer(auth, { + title: `DataPipe OAuth spike ${new Date().toISOString()}`, + creatorName: "DataPipe, Spike", + description: "Temporary deposition created by scripts/zenodo-oauth-spike.mjs. Never published.", + }); + const body = JSON.stringify({ hello: "oauth" }); + const write = await zenodoProvider.writeSessionFile(auth, container, "data/raw/oauth-probe.json", body, { + size: Buffer.byteLength(body), + contentType: "application/json", + }); + record( + "O. OAuth token drives the real adapter", + write.success ? "PASS" : "FAIL", + write.success + ? `deposition ${container.depositionId}, wrote "${write.storedFilename}" -- deposit:write is sufficient` + : `write failed: ${write.error} ${write.providerMessage ?? ""}` + ); + } catch (e) { + record("O. OAuth token drives the real adapter", "FAIL", e.message); + } + + // ---- Gate L: does refresh rotate the refresh token? ---- + const useBasic = authStyle.startsWith("client_secret_basic"); + const second = await postToken( + { + grant_type: "refresh_token", + refresh_token: first.json.refresh_token, + client_id: clientId, + client_secret: clientSecret, + }, + { basicAuth: useBasic } + ); + + if (!second.ok || !second.json?.access_token) { + record("L. refresh_token grant", "FAIL", `${second.status}: ${second.text.slice(0, 300)}`); + } else { + const rotated = second.json.refresh_token !== first.json.refresh_token; + record( + "L. refresh rotates the refresh token", + rotated ? "PASS" : "FAIL", + rotated + ? "New refresh_token differs from the old one, as oauthlib's rotate_refresh_token=True implies. " + + "zenodo-oauth.ts MUST persist it -- this is the whole reason that module exists." + : "Refresh token came back UNCHANGED. Rotation is off on this deployment, which would make " + + "recoverFromRotationRace() dead code and the persist far less dangerous. Re-read that module." + ); + + // ---- Gate M: is the superseded refresh token dead immediately? ---- + const replay = await postToken( + { + grant_type: "refresh_token", + refresh_token: first.json.refresh_token, + client_id: clientId, + client_secret: clientSecret, + }, + { basicAuth: useBasic } + ); + const deadRefresh = !replay.ok; + record( + "M. superseded refresh token is rejected", + deadRefresh ? "PASS" : "FAIL", + deadRefresh + ? `Replay returned ${replay.status} (${(replay.json?.error ?? replay.text).toString().slice(0, 80)}) -- ` + + "this is exactly the invalid_grant that recoverFromRotationRace() has to distinguish " + + "from a genuinely revoked grant." + : "The OLD refresh token still works, so two concurrent refreshes cannot collide. " + + "recoverFromRotationRace() would be unnecessary." + ); + + // ---- Gate N: does refreshing kill the PREVIOUS access token? ---- + // invenio-oauth2server's save_token deletes every prior Token row for + // (client_id, user_id), which would take the old ACCESS token with it. If + // true, a long upload holding the old token breaks the moment anything + // else refreshes -- a failure mode no amount of expiry margin prevents. + const oldAccessLive = await tokenIsLive(first.json.access_token); + record( + "N. previous access token survives a refresh", + oldAccessLive ? "PASS" : "INFO", + oldAccessLive + ? "The pre-refresh access token still authenticates, so an in-flight upload is unaffected " + + "by a concurrent refresh." + : "The pre-refresh access token is ALREADY DEAD, as save_token deleting every prior " + + "row for (client_id, user_id) implies. A refresh by a concurrent request invalidates " + + "a token an in-flight upload may be using. Zenodo reports that as 403 -- the same " + + "403 it returns for a revoked token, a garbage token and no token at all -- so the " + + "response cannot distinguish transient from terminal; only comparing against the " + + "stored token can." + ); + } + + if (cleanup && container) { + try { + await fetch(`${host}/api/deposit/depositions/${container.depositionId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${second.json?.access_token ?? first.json.access_token}` }, + }); + console.log(`\nCleaned up deposition ${container.depositionId}.`); + } catch (e) { + console.log(`\nCould not delete deposition ${container.depositionId}: ${e.message}`); + } + } + + console.log("\n\n=== SUMMARY ==="); + for (const r of results) { + console.log(`[${r.verdict}] ${r.gate}`); + } + const failed = results.filter((r) => r.verdict === "FAIL"); + if (failed.length) { + console.log(`\n${failed.length} gate(s) FAILED.`); + process.exitCode = 1; + } +} + +main().catch((e) => { + console.error("\nSpike aborted:", e.message); + process.exitCode = 1; +}); From c10eb93764145b6f7e8e6abde5694bc9bc751310 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Fri, 21 Aug 2026 12:26:41 -0400 Subject: [PATCH 096/181] feat: give self-healing failures one early retry before the long wait AUTH_EXPIRED and UNAVAILABLE both conflate a transient failure with a terminal one, and neither the status code nor the message can separate them. Until now both waited an hour for their first retry, so a submission that would have succeeded on the next tick sat idle. PROBE_RETRY_CODES gives them one minute-scale look and then, if that fails, the ordinary hours-scale chain. The attempt is free rather than additive: it displaces the 1-hour first attempt instead of extending the chain, so an item still gets five tries across ~30 hours instead of ~31. scheduled-upload-retry.ts needs no change at all -- the probe only moves the first delay, and that file's existing backoff already yields 2 hours for the next attempt. The motivating case is Zenodo's rotated access token (spike gate N): a refresh deletes the previous token, so an upload already in flight fails with a 403 that is indistinguishable from a revoked grant but heals itself in seconds. RATE_LIMITED stays excluded -- the provider has stated how long it will keep refusing, and probing inside its own window is the one thing it asked us not to do. QUOTA_EXCEEDED stays excluded because it needs compaction or a human. CONTENTION keeps its full fast tier, which is a different mechanism: minutes-scale for all five attempts. Known cost, accepted: Dataverse's same-content 400 maps to UNAVAILABLE and is deterministic, so the probe cannot succeed and is one wasted request every time. D6 now documents that rather than asserting the hour it used to wait. The retry budget and total window are unchanged, so the worst case is one extra request, not a shortened lifetime. Note that scheduled-upload-retry.ts runs on */5, so 60 seconds means "the next five-minute tick" -- still an order of magnitude better than an hour, but not a one-minute retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/__tests__/dataverse-emulator.test.js | 24 +++++- functions/src/__tests__/upload-queue.test.js | 79 +++++++++++++++++-- functions/src/queue-upload.ts | 59 ++++++++++++-- 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/functions/src/__tests__/dataverse-emulator.test.js b/functions/src/__tests__/dataverse-emulator.test.js index 0b86054..9bf7784 100644 --- a/functions/src/__tests__/dataverse-emulator.test.js +++ b/functions/src/__tests__/dataverse-emulator.test.js @@ -483,7 +483,7 @@ describe("D5. contention is classified as transient and retried on the fast tier }); describe("D6. duplicate CONTENT is not mistaken for contention", () => { - it("the same-content 400 queues on the slow tier, not the 60-second one", async () => { + it("the same-content 400 queues as UNAVAILABLE, never as CONTENTION", async () => { const experimentID = `dataverse-e2e-6-${randomUUID()}`; const filename = `d6-${randomUUID()}.json`; await createDataverseExperiment(experimentID); @@ -499,8 +499,28 @@ describe("D6. duplicate CONTENT is not mistaken for contention", () => { const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); const queueData = (await db.collection("uploadQueue").doc(docId).get()).data(); + // The point of this test, and unchanged: NOT CONTENTION. Misclassifying it + // would put a permanently-failing upload on the fast tier, spending all + // five attempts inside ~31 minutes on bytes Dataverse will reject + // identically every time. expect(queueData.providerErrorCode).toBe("UNAVAILABLE"); - expect(queueData.nextRetryAt.toMillis()).toBeGreaterThan(queuedAt + 30 * 60 * 1000); + + // This assertion used to require >30 minutes. UNAVAILABLE joined + // PROBE_RETRY_CODES on 2026-08-21, so the first look is now ~60 seconds + // (really the next */5 tick) before the ordinary hours-scale chain + // resumes. + // + // THIS CASE IS THE COST OF THAT DECISION, AND IT IS DELIBERATE. A + // same-content 400 is deterministic -- the probe cannot possibly succeed, + // so it is one wasted request, every time, for this specific Dataverse + // rejection. It is accepted because UNAVAILABLE overwhelmingly means a + // transient 5xx or network fault that clears in seconds, and because the + // probe DISPLACES the 1-hour first attempt rather than adding to the + // chain: the retry budget and the ~30-hour total window are unchanged, so + // the worst case is one extra request, not a shortened lifetime. + const deltaMs = queueData.nextRetryAt.toMillis() - queuedAt; + expect(deltaMs).toBeGreaterThanOrEqual(59 * 1000); + expect(deltaMs).toBeLessThan(10 * 60 * 1000); }); }); diff --git a/functions/src/__tests__/upload-queue.test.js b/functions/src/__tests__/upload-queue.test.js index df679db..764f58e 100644 --- a/functions/src/__tests__/upload-queue.test.js +++ b/functions/src/__tests__/upload-queue.test.js @@ -45,6 +45,7 @@ let db; let app; let queueUpload; let isFastRetry; +let isProbeRetry; beforeAll(async () => { try { @@ -61,7 +62,7 @@ beforeAll(async () => { // first), and its app.js does a bare, unnamed initializeApp() -- distinct // from this suite's own NAMED "upload-queue-test" app above, so the two // don't collide. - ({ default: queueUpload, isFastRetry } = await import("../../lib/queue-upload.js")); + ({ default: queueUpload, isFastRetry, isProbeRetry } = await import("../../lib/queue-upload.js")); }); // Only the docs THIS suite created. A collection-wide wipe here used to @@ -228,6 +229,37 @@ describe("scheduled-upload-retry tiered backoff arithmetic", () => { test("a missing or cleared providerErrorCode is slow tier", () => { expect(isFastRetry(undefined)).toBe(false); expect(isFastRetry(null)).toBe(false); + expect(isProbeRetry(undefined)).toBe(false); + expect(isProbeRetry(null)).toBe(false); + }); + + // The probe tier is NOT the fast tier: it buys one early look, not a + // minutes-scale schedule. AUTH_EXPIRED must therefore be a probe code and + // NOT a fast code -- if it ever became both, five attempts would burn + // inside ~31 minutes against what is usually a genuinely revoked token. + test("probe codes take one early look but are not on the fast tier", () => { + for (const code of ["AUTH_EXPIRED", "UNAVAILABLE"]) { + expect(isProbeRetry(code)).toBe(true); + expect(isFastRetry(code)).toBe(false); + } + }); + + // RATE_LIMITED is excluded because the provider has stated how long it will + // keep refusing; QUOTA_EXCEEDED because nothing clears it within a minute. + test("RATE_LIMITED and QUOTA_EXCEEDED never probe", () => { + for (const code of ["RATE_LIMITED", "QUOTA_EXCEEDED", "NAME_CONFLICT", "CONTENTION"]) { + expect(isProbeRetry(code)).toBe(false); + } + }); + + // What makes the probe free rather than additive: once it fails, the worker + // reads AUTH_EXPIRED as slow tier and resumes the ordinary chain, so the + // item still gets five attempts across ~30 hours instead of ~31. Mirrors the + // production formula, same convention as the rest of this block. + test("a failed probe reverts to the hours-scale chain, not a second minute", () => { + const baseMs = isFastRetry("AUTH_EXPIRED") ? 60 * 1000 : 60 * 60 * 1000; + const afterProbe = Math.min(Math.pow(2, 1) * baseMs, SLOW_MAX_BACKOFF_MS); + expect(afterProbe).toBe(2 * 60 * 60 * 1000); }); test("slow tier (everything else) is unchanged: ~2, 4, 8, 16, 24 hours", () => { @@ -309,6 +341,39 @@ describe("queueUpload tiers the first nextRetryAt by providerErrorCode", () => { expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); }); + // The gate-N case: a Zenodo 403 caused by a concurrent refresh rotating the + // access token away is indistinguishable from a revoked one in the response, + // but heals itself in seconds. An hour is the wrong first look. + test("an AUTH_EXPIRED providerErrorCode sets nextRetryAt ~60 seconds out (probe)", async () => { + const experimentID = `queue-probe-auth-${randomUUID()}`; + const filename = `file-${randomUUID()}.json`; + const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); + queueDoc(docId); + + const before = Date.now(); + await queueUpload({ + experimentID, + owner: "upload-queue-test-owner", + filename, + data: "[]", + dataType: "data", + osfFilesLink: "https://osf.io/files/", + errorCode: 403, + providerErrorCode: "AUTH_EXPIRED", + sessionIncremented: true, + }); + const after = Date.now(); + + const doc = await db.collection("uploadQueue").doc(docId).get(); + expect(doc.exists).toBe(true); + expect(doc.data().providerErrorCode).toBe("AUTH_EXPIRED"); + + const deltaMs = doc.data().nextRetryAt.toMillis() - before; + expect(deltaMs).toBeGreaterThanOrEqual(59 * 1000); + // Comfortably short of the hour it would be without the probe tier. + expect(deltaMs).toBeLessThan(after - before + 5 * 60 * 1000); + }); + // Regression guard: RATE_LIMITED was briefly fast-tiered, which cut the // whole retry budget for an OSF/Drive 429 from ~31 hours to ~2. test("a RATE_LIMITED providerErrorCode sets nextRetryAt ~1 hour out (slow tier)", async () => { @@ -339,8 +404,11 @@ describe("queueUpload tiers the first nextRetryAt by providerErrorCode", () => { expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); }); - test("an UNAVAILABLE providerErrorCode also sets nextRetryAt ~1 hour out (slow tier)", async () => { - const experimentID = `queue-slow-tier-unavailable-${randomUUID()}`; + // Was a slow-tier assertion until UNAVAILABLE joined PROBE_RETRY_CODES. A + // 5xx is usually a blip that clears in seconds, and the cost of being wrong + // is a single extra request before the same hours-scale chain resumes. + test("an UNAVAILABLE providerErrorCode sets nextRetryAt ~60 seconds out (probe)", async () => { + const experimentID = `queue-probe-unavailable-${randomUUID()}`; const filename = `file-${randomUUID()}.json`; const docId = `${experimentID}:${filename}`.replace(/[/\\]/g, "_"); queueDoc(docId); @@ -357,14 +425,15 @@ describe("queueUpload tiers the first nextRetryAt by providerErrorCode", () => { providerErrorCode: "UNAVAILABLE", sessionIncremented: true, }); + const after = Date.now(); const doc = await db.collection("uploadQueue").doc(docId).get(); expect(doc.exists).toBe(true); expect(doc.data().providerErrorCode).toBe("UNAVAILABLE"); const deltaMs = doc.data().nextRetryAt.toMillis() - before; - expect(deltaMs).toBeGreaterThan(55 * 60 * 1000); - expect(deltaMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5000); + expect(deltaMs).toBeGreaterThanOrEqual(59 * 1000); + expect(deltaMs).toBeLessThan(after - before + 5 * 60 * 1000); }); }); diff --git a/functions/src/queue-upload.ts b/functions/src/queue-upload.ts index eaf133e..06a9313 100644 --- a/functions/src/queue-upload.ts +++ b/functions/src/queue-upload.ts @@ -53,6 +53,47 @@ export function isFastRetry(code?: string | null): boolean { return !!code && FAST_RETRY_CODES.has(code); } +// Codes that get ONE minute-scale look before falling back to the hours-scale +// schedule. This is not the fast tier: a probe code takes a single early +// attempt and, if that fails, resumes the ordinary slow backoff (2, 4, 8, 16 +// hours) from there. Nothing in scheduled-upload-retry.ts needs to know about +// this set -- the probe only moves the FIRST delay, and the worker's existing +// `Math.pow(2, newRetryCount) * baseMs` already produces 2 hours for the next +// attempt. The asymmetry with FAST_RETRY_CODES is deliberate, not an omission. +// +// The attempt is free rather than additive: it displaces the 1-hour first +// attempt instead of extending the chain, so an item still gets five tries +// across ~30 hours -- the same budget and nearly the same total window as +// before, with the first look ~59 minutes earlier. +// +// Both members are codes that CANNOT distinguish a transient failure from a +// terminal one, so the cheapest way to find out is to look once. +// +// AUTH_EXPIRED: on Zenodo this conflates the two outright. Zenodo answers 403 +// for a revoked token, an under-scoped token, no token at all, AND an access +// token that a concurrent refresh rotated away moments ago (all four measured +// against the sandbox, 2026-08-21). That last case heals itself the instant +// the winning refresh persists -- so waiting an hour to look again is an hour +// of delay for a submission that would have succeeded on the next tick. See +// docs/provider-migration-design.md, spike gate N. +// +// UNAVAILABLE: a 5xx or a network fault, which covers everything from a +// one-off blip to a multi-hour outage. The blip is common and clears in +// seconds; the outage costs one extra request to discover, then falls back to +// the same hours-scale chain it would have used anyway. +// +// DELIBERATELY NOT HERE: +// RATE_LIMITED -- the provider has told us how long it will keep refusing; +// probing inside its own stated window is the one thing it +// asked us not to do. +// QUOTA_EXCEEDED -- needs compaction to free a slot or a human to raise a +// limit. Neither happens within a minute. +export const PROBE_RETRY_CODES: ReadonlySet<string> = new Set(["AUTH_EXPIRED", "UNAVAILABLE"]); + +export function isProbeRetry(code?: string | null): boolean { + return !!code && PROBE_RETRY_CODES.has(code); +} + export default async function queueUpload(params: QueueUploadParams): Promise<string> { const deduplicationKey = `${params.experimentID}:${params.filename}`; const docId = deduplicationKey.replace(/[/\\]/g, "_"); @@ -60,11 +101,19 @@ export default async function queueUpload(params: QueueUploadParams): Promise<st const docRef = db.collection("uploadQueue").doc(docId); const now = Timestamp.now(); - // Fast tier (CONTENTION): 60 seconds — the cadence of - // scheduled-upload-retry.ts is what makes this meaningful (see that file). - // Everything else, including no providerErrorCode at all: 1 hour, exactly - // as before this change. - const firstRetryDelayMs = isFastRetry(params.providerErrorCode) ? 60 * 1000 : 60 * 60 * 1000; + // 60 seconds for the fast tier (CONTENTION) and for a one-off probe + // (AUTH_EXPIRED); 1 hour for everything else, including no providerErrorCode + // at all. The two differ in what happens NEXT, not here: CONTENTION keeps a + // minutes-scale schedule for all five attempts, a probe code takes this one + // early look and then reverts to hours. + // + // 60 seconds is a floor, not a promise. scheduled-upload-retry.ts runs on + // */5, so the real first attempt lands at the next 5-minute tick -- which is + // the number to reason about when judging whether this is worth it. + const firstRetryDelayMs = + isFastRetry(params.providerErrorCode) || isProbeRetry(params.providerErrorCode) + ? 60 * 1000 + : 60 * 60 * 1000; const nextRetryAt = Timestamp.fromMillis(now.toMillis() + firstRetryDelayMs); // If the doc already exists: a "processing" entry is actively being From 5d8dd3e1d93c960935ca8cd528a8a7ebedc097fb Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Fri, 21 Aug 2026 21:43:50 -0400 Subject: [PATCH 097/181] fix: say which token exchange failed, and why "Token exchange failed" covered three unrelated causes that look identical from the outside, and the bare provider error body was not enough to tell them apart -- distinguishing them cost a live debugging cycle on the test site. Now logs the classification (invalid_client / invalid_grant / unclassified) plus whether the client id, secret and redirect URI are PRESENT -- booleans and the redirect URI only, never the credentials themselves. The invalid_client case is worth calling out in particular, because a green deploy does not prove the function has current credentials: `firebase deploy` reports "Skipped (No changes detected)" when only .env changed, so adding a GitHub secret and re-running the deploy workflow leaves the running function on its previous environment. That is how a correctly-set TEST_ZENODO_CLIENT_SECRET never reached connectprovider despite two successful deploys. This commit also exists to change the functions source, which is what busts that hash and forces the redeploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- functions/src/connect-provider.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/functions/src/connect-provider.ts b/functions/src/connect-provider.ts index ae3e687..c585a22 100644 --- a/functions/src/connect-provider.ts +++ b/functions/src/connect-provider.ts @@ -164,7 +164,36 @@ export const connectProvider = onRequest({ cors: true }, async (req, res) => { if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); - console.error('Token exchange failed:', errorText); + // Log WHICH failure this is, not just that one happened. These three + // look identical to the researcher ("Token exchange failed") and have + // completely different causes, and a bare error body cost a live + // debugging cycle on 2026-08-21 working out which one we were seeing: + // + // invalid_client -- OUR misconfiguration. The client id/secret the + // function is running with are wrong or absent. Note that a deploy + // can look green and still leave these stale: `firebase deploy` + // reports "Skipped (No changes detected)" when only .env changed, + // so adding a secret and re-running the workflow does NOT + // necessarily update the function. + // invalid_grant -- the authorization code was rejected: already + // spent, or expired. Codes are single-use and short-lived, so a + // page reload after a failed attempt produces exactly this. + // anything else -- provider-side or a request-shape problem. + // + // Whether the credentials are merely PRESENT is logged as a boolean, so + // a stale/empty deploy is distinguishable from a wrong value without + // ever putting the credential itself in a log line. + const kind = errorText.includes('invalid_client') + ? 'invalid_client (our client credentials are wrong or missing)' + : errorText.includes('invalid_grant') + ? 'invalid_grant (the authorization code was already spent or expired)' + : 'unclassified'; + console.error( + `Token exchange failed for ${provider}: ${kind}; ` + + `clientId=${config.clientId ? 'present' : 'MISSING'}, ` + + `clientSecret=${config.clientSecret ? 'present' : 'MISSING'}, ` + + `redirectUri=${config.redirectUri || 'MISSING'}; body: ${errorText}` + ); res.status(400).json({ error: 'Token exchange failed' }); return; } From 31ed6990c2046cccb710c88a1dce2bdfd79cb618 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Fri, 21 Aug 2026 22:44:53 -0400 Subject: [PATCH 098/181] fix: never merge a loose original that is already sealed in a batch Compaction verifies an archive and seals its claims BEFORE deleting the originals, so a failed delete is survivable by design: nothing is lost, and the next pass skips the survivor because its claim is sealed. What it does leave behind is the same content in two places. Finalization merges everything it can see, so those survivors were merged a second time -- once at data/raw/x.json from the batch archive, and once under mergeEntries' collision fallback as "x.json__data/raw/x.json". A Psych-DS dataset carrying a mangled duplicate of every unluckily-undeleted file is not publishable, and the comment above that fallback claimed the collision "cannot happen under any archivePathFor DataPipe ships" -- true as written, and still falsified in practice by a route it did not consider. Finalization now skips a loose file whose claim hash is already sealed into a COMPLETED batch, while still REMOVING it: the redundant copy has no reason to outlive finalization, and leaving it would break the "record ends as exactly one file" invariant just as surely as archiving it twice would. Only sealed batches count -- an "uploading" batch has not proven its archive landed, and trusting it would turn a skipped merge into real data loss. Found live rather than theorised. A compaction pass on the test site archived 75 files on 2026-08-22 and Zenodo rejected 3 of the follow-up deletes with "Not a valid value."; the keys were valid and still addressable afterwards (GET 200), so this is a provider fault we cannot prevent, only decline to propagate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../__tests__/finalization-emulator.test.js | 135 ++++++++++++++++++ functions/src/compaction.ts | 30 ++++ functions/src/finalization.ts | 51 ++++++- 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/functions/src/__tests__/finalization-emulator.test.js b/functions/src/__tests__/finalization-emulator.test.js index 8f6f160..1a0ebf6 100644 --- a/functions/src/__tests__/finalization-emulator.test.js +++ b/functions/src/__tests__/finalization-emulator.test.js @@ -1107,3 +1107,138 @@ describe("F11. the merged archive is a valid Psych-DS dataset on its own", () => expect(claim.data().sealed).toBe(true); }); }); + +describe("F12. a loose original that compaction failed to delete is not merged twice", () => { + // FOUND LIVE, NOT THEORISED. On 2026-08-22 a compaction pass on the test + // site archived 75 files and Zenodo rejected 3 of the follow-up deletes with + // "Not a valid value." -- the keys were valid and still addressable + // afterwards, so this is a provider fault, not a bug in the delete path. + // + // Compaction tolerates that by design: the archive was verified before any + // delete, the claims are sealed, and the next pass skips the survivors. + // Nothing is lost. But it leaves the same bytes in two places, and + // finalization merges everything it can see -- so the final archive used to + // get the content twice, the second copy under mergeEntries' collision + // fallback name ("x.json__data/raw/x.json"). A Psych-DS dataset carrying a + // mangled duplicate of every unluckily-undeleted file is not publishable. + async function seedWithUndeletedOriginal() { + const seeded = await seedFinalizableExperiment(); + const { experimentID, batch1 } = seeded; + + // The loose original of a file that is ALREADY inside batch1: exactly what + // a failed delete leaves behind. + const survivor = batch1.memberNames[0]; + mock.seed(survivor, batch1.contents.get(survivor)); + + // Its claim is sealed and recorded against the batch, which is what makes + // the duplicate provable rather than guessed at. + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: "datapipe-batch-0001.zip", + status: "sealed", + memberHashes: batch1.memberNames.map((name) => claimDocId(SALT, name)), + expectedMd5: "seeded", + fileCount: batch1.memberNames.length, + }); + + return { ...seeded, survivor }; + } + + it("carries the content exactly once, under its real Psych-DS path", async () => { + const { experimentID, survivor, batch1 } = await seedWithUndeletedOriginal(); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + + const entries = readZipEntries(finalArchiveBytes()); + const paths = [...entries.keys()]; + + // No member name carries mergeEntries' "__" collision fallback. + expect(paths.filter((p) => p.includes("__"))).toEqual([]); + + // And the real path appears once, with the right bytes. + const realPath = archivePathsFor(zenodoProvider, true, [survivor]).get(survivor); + expect(paths.filter((p) => p === realPath)).toHaveLength(1); + expect(entries.get(realPath).equals(batch1.contents.get(survivor))).toBe(true); + }); + + it("still REMOVES the redundant original, leaving the record as one file", async () => { + const { experimentID, survivor } = await seedWithUndeletedOriginal(); + + expect(mock.has(survivor)).toBe(true); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + + // Skipping it from the MERGE must not spare it from the DELETE -- the + // redundant copy has no reason to outlive finalization, and leaving it + // would break the "record ends as exactly one file" invariant just as + // surely as archiving it twice would. + expect(mock.has(survivor)).toBe(false); + expect(mock.size()).toBe(1); + expect(mock.has("datapipe-final.zip")).toBe(true); + }); + + // Guard on the belt-and-braces branch: NEVER_ARCHIVE already keeps the + // descriptor out of every batch, so it cannot be sealed -- but if that ever + // changed, silently dropping it would produce an archive that is not a + // Psych-DS dataset at all, which F11 exists to prevent. + it("never skips dataset_description.json, even if its hash is sealed", async () => { + const { experimentID } = await seedFinalizableExperiment(); + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: "datapipe-batch-0001.zip", + status: "sealed", + memberHashes: [claimDocId(SALT, "dataset_description.json")], + expectedMd5: "seeded", + fileCount: 1, + }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + expect([...readZipEntries(finalArchiveBytes()).keys()]).toContain("dataset_description.json"); + }); + + // An "uploading" batch has NOT proven its archive landed. Treating its + // members as already-stored is precisely the mistake that would lose data: + // skip the loose copy, then discover the archive never uploaded. + it("ignores an unsealed batch and merges the loose copy normally", async () => { + const seeded = await seedFinalizableExperiment(); + const { experimentID, batch1 } = seeded; + const survivor = batch1.memberNames[0]; + mock.seed(survivor, batch1.contents.get(survivor)); + + await db + .collection("experiments") + .doc(experimentID) + .collection("compactionBatches") + .doc("0001") + .set({ + index: 1, + archiveName: "datapipe-batch-0001.zip", + status: "uploading", + memberHashes: batch1.memberNames.map((name) => claimDocId(SALT, name)), + expectedMd5: "seeded", + fileCount: batch1.memberNames.length, + }); + + const result = await finalizeExperiment(experimentID); + expect(result.status).toBe("finalized"); + + // Merged from the loose file rather than skipped, so the content survives + // even though the batch zip's own upload was never confirmed. + const entries = readZipEntries(finalArchiveBytes()); + const realPath = archivePathsFor(zenodoProvider, true, [survivor]).get(survivor); + expect(entries.has(realPath)).toBe(true); + }); +}); diff --git a/functions/src/compaction.ts b/functions/src/compaction.ts index 57bf5d7..04a5f22 100644 --- a/functions/src/compaction.ts +++ b/functions/src/compaction.ts @@ -420,6 +420,36 @@ export async function releaseLease( }); } +/** + * Every claim hash sealed into a COMPLETED batch archive for this experiment. + * + * Membership answers one question: "are these bytes already inside a sealed + * archive?" Sealing happens only after the archive has been uploaded AND its + * md5 verified (see sealAndDelete), so a hash in this set is proof the content + * is safely stored, independent of whether the loose original was successfully + * deleted afterwards. + * + * That distinction is the point. Deletes can fail -- Zenodo returned "Not a + * valid value." for 3 of 75 on the test site, 2026-08-22 -- and compaction + * deliberately tolerates it: the originals stay loose and are skipped by the + * next pass. Correct for compaction, but it leaves the same content in two + * places, and finalization merges everything it can see. + * + * Only "sealed" batches count. An "uploading" batch is a pass that has not yet + * proven its archive landed; treating its members as already-stored would be + * exactly the mistake this set exists to prevent. + */ +export async function sealedBatchMemberHashes(experimentID: string): Promise<Set<string>> { + const snapshot = await batchesCollection(experimentID).where("status", "==", "sealed").get(); + const hashes = new Set<string>(); + for (const doc of snapshot.docs) { + for (const hash of (doc.data() as BatchRecord).memberHashes ?? []) { + hashes.add(hash); + } + } + return hashes; +} + interface BatchRecord { index: number; archiveName: string; diff --git a/functions/src/finalization.ts b/functions/src/finalization.ts index 5092a7d..c974c36 100644 --- a/functions/src/finalization.ts +++ b/functions/src/finalization.ts @@ -45,6 +45,7 @@ import { isArchiveName, acquireLease, releaseLease, + sealedBatchMemberHashes, } from "./compaction.js"; // Fixed name, unlike compaction's numbered datapipe-batch-NNNN.zip: the @@ -256,8 +257,54 @@ async function runFinalization(experimentID: string): Promise<Omit<FinalizationR }; } - // Every file is both archived and removed -- see DESCRIPTOR above. - const members = files; + // Every file is removed -- see DESCRIPTOR above -- but not every file is + // ARCHIVED, and the two sets genuinely differ. + // + // A loose file whose claim hash is already sealed into a batch archive is + // a duplicate of content this record already holds: compaction verified + // the archive, sealed the claim, and then failed to delete the original. + // That is a tolerated outcome for compaction (nothing is lost, and the + // next pass skips it), but finalization merges everything it can see, so + // without this filter the same bytes land in the final archive twice -- + // once at data/raw/x.json from the batch, and once at a mangled + // "x.json__data/raw/x.json" from mergeEntries' collision fallback. A + // Psych-DS dataset containing a duplicate of every unluckily-undeleted + // file is not one anybody would want to publish. + // + // Observed live rather than theorised: Zenodo rejected 3 of 75 deletes + // with "Not a valid value." on 2026-08-22, and the keys were valid and + // still addressable afterwards, so this is a provider fault we cannot + // prevent -- only decline to propagate. + // + // They are still REMOVED. The redundant copy has no reason to outlive + // finalization, and leaving it would defeat the "record ends as exactly + // one file" invariant just as surely as archiving it twice would. + const sealedHashes = await sealedBatchMemberHashes(experimentID); + const alreadyArchived = new Set( + files + .filter( + (file) => + !isArchiveName(file.name) && + // Belt and braces: NEVER_ARCHIVE keeps the two Psych-DS control + // files out of every batch, so neither can be sealed and neither + // can reach this branch. Excluding the descriptor explicitly + // anyway, because silently dropping it would produce an archive + // that is not a Psych-DS dataset, and that failure mode is worth + // more than one line of defence. + file.name !== DESCRIPTOR && + sealedHashes.has(claimDocId(salt, file.name)) + ) + .map((file) => file.name) + ); + + if (alreadyArchived.size > 0) { + console.warn( + `finalization: ${experimentID} skipping ${alreadyArchived.size} loose file(s) already sealed ` + + `into a batch archive; they will be removed but not merged twice` + ); + } + + const members = files.filter((file) => !alreadyArchived.has(file.name)); const removable = files; // "Nothing collected" is judged on DATA, not on file count: an experiment From 267570fb0302304d1b0289a6fecee32c4110bec6 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 10:03:28 -0400 Subject: [PATCH 099/181] =?UTF-8?q?docs:=20add=20PRODUCT.md=20=E2=80=94=20?= =?UTF-8?q?strategic=20design=20context=20for=20the=20site-wide=20design?= =?UTF-8?q?=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product register, personas (first-time setup, rare maintenance), brand personality (trustworthy, plain, unfussy), anti-references, five design principles, and a WCAG 2.1 AA commitment. Written as the contract that impeccable design commands read before doing any work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- PRODUCT.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 PRODUCT.md diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..3597b0b --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,108 @@ +# Product + +## Register + +product + +## Users + +Behavioral scientists — psychology, cognitive science, linguistics, education — +running online experiments (usually jsPsych) and needing the resulting data to +land somewhere durable and citable without standing up a server. + +They are domain experts, not web developers. Many are graduate students or +postdocs configuring this once for a study that will then run unattended for +weeks. Their technical confidence varies enormously: some write their own +plugins, others are pasting a snippet from a tutorial. + +Two states of mind bring someone to the account settings page: + +- **First-time setup.** A new signup cannot create an experiment until a + storage provider is connected. Settings is a *required step in activation*, + not a maintenance screen — the researcher is here because the product sent + them here, and they are blocked until they finish. +- **Rare maintenance.** An established user returning once every few months to + rotate an expired token, change a password, add a sign-in method, or + disconnect a provider. They have forgotten how this page works. Nothing here + is muscle memory. + +Neither persona visits often. Nothing on this page can rely on recall. + +## Product Purpose + +DataPipe is free, grant-funded infrastructure that accepts data from a running +experiment over a simple HTTP API and writes it to a repository the researcher +controls (Google Drive, Dataverse, Zenodo; OSF historically). It exists so that +"born-open data" is the path of least resistance rather than a project in +itself. + +Success is invisibility: an experiment collects for six weeks and every +participant's data arrives, without the researcher logging in once. The product +is working when nobody thinks about it. + +That inverts the usual attention economics. Because the researcher is almost +never looking, the interface's real job is to make the *consequential* states — +a token about to expire, a provider not connected, a sign-in method that would +lock them out — legible in the few seconds of attention it ever gets. + +## Brand Personality + +**Trustworthy, plain, unfussy.** + +Infrastructure that stays out of the way. Calm and legible; no marketing +energy, no persuasion, no celebration. The voice states what is true and what +will happen next, in the researcher's own vocabulary, without hedging or +jargon. Confidence is expressed through precision and through never losing +data — not through visual assertiveness. + +Emotional goal: quiet certainty. A researcher should leave this interface +believing their data is safe, and should be able to say exactly where it went. + +## Anti-references + +- **SaaS growth-marketing UI.** No gradient hero metrics, upsell nudges, + engagement prompts, confetti, or celebratory language. This is grant-funded + academic infrastructure, not a conversion funnel. Nothing on screen should be + trying to get the researcher to do more of anything. +- **OSF's own interface.** The tool being migrated away from. Do not inherit + its density, deep nesting, or navigational ambiguity. +- **Enterprise admin console.** No sprawling nav trees, role matrices, or dense + configuration tables. One researcher owns one account; the IA should say so. +- **Playful / consumer app.** No mascots, illustrated empty states, emoji, or + animated flourishes. Wrong register for a tool holding irreplaceable research + data. + +## Design Principles + +1. **Assume no recall.** Every visit is effectively a first visit. Labels, + states, and consequences must be readable cold, without memory of a previous + session or of documentation read months ago. +2. **Consequence before mechanism.** Say what will happen to the researcher's + data and access first; explain the machinery second, and only if it helps + them act. "Your experiments stopped sending data" beats "refresh token + expired." +3. **Never strand a researcher.** Destructive or lock-out-adjacent actions — + unlinking a last sign-in method, disconnecting a provider mid-study, + deleting an account — must be prevented or explained in terms of what is + lost, never merely confirmed. +4. **Legibility over density.** A page glanced at twice a year earns its space + by being scannable, not by fitting more in. Status is a first-class citizen, + not a decoration on a row. +5. **Practice what you preach.** DataPipe argues for open, careful data + handling. The interface should visibly embody that care — accurate states, + honest errors, no silent failures. + +## Accessibility & Inclusion + +Target **WCAG 2.1 AA**. + +- Body text ≥4.5:1 against the permanently dark `#1C1F22` surface; large text + and non-text UI boundaries ≥3:1. `lib/theme.js` already re-points the gray + palette for the dark surface with measured ratios — hold that line. +- Status must never be conveyed by color or icon alone; every state needs a + text equivalent. +- Full keyboard operability with a visible focus ring on every interactive + element, including icon-only and link-styled controls. +- Honor `prefers-reduced-motion` for any motion added. +- Users span a wide age range and include international researchers reading + English as a second language; favor plain vocabulary over idiom. From 9f510658bc8aede88dc8878a63611416d719a2ad Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 11:14:38 -0400 Subject: [PATCH 100/181] docs: retire OSF from the researcher-facing guides The product moved to multiple storage backends, but Getting Started, the FAQ and the API reference still read as if OSF were the only destination. Getting Started is rebuilt around the choice a researcher now has to make first: step 1 compares Google Drive, Dataverse and Zenodo on what DataPipe creates there, how you connect, and the limit that will eventually bite -- each taken from the adapter's own capabilities rather than restated by hand. No provider is framed as the default; the three get parallel "choose this if" lines instead. Steps 2 and 3 follow the real Account Settings and New Experiment forms, including the per-provider container fields declared in containerInput. OSF drops to a collapsible aimed only at researchers already collecting there, and a closing step covers finalization -- the piece of the story Zenodo's 100-file cap implies. The FAQ loses its OSF framing throughout. Two answers needed more than a find-and-replace: who can see the data now differs per provider, and "what is one-click authentication" was an OSF-shaped question, so it becomes how DataPipe gets permission to write, answered per provider. A new entry covers the OSF wind-down for people arriving with that question. The API reference had errors beyond naming, found by checking the endpoints against api-messages.ts: - INVALID_METADATA_ERROR, OSF_METADATA_UPLOAD_ERROR and METADATA_ERROR were documented but are never returned to a client (the last is only written to the log). Removed. - Seven codes that are returned were missing, including EXPERIMENT_FINALIZED and the PROVIDER_NOT_CONNECTED / PROVIDER_TOKEN_EXPIRED pair that reaches clients through the token resolution branch in api-data.ts. - The 202 queued response was undocumented. A client treating "not 201" as failure would resubmit and store a participant's data twice, so it now says plainly not to retry. The OSF_-prefixed code names stay: they are the wire contract and still what the server sends. A note distinguishes the three that fire for every provider from the two that only occur on legacy OSF experiments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJnHtjEQWtadFwWEZkdKac --- pages/api-docs.js | 132 +++++++++++++++++----- pages/faq.js | 141 ++++++++++++++--------- pages/getting-started.js | 233 +++++++++++++++++++++++++++++---------- 3 files changed, 373 insertions(+), 133 deletions(-) diff --git a/pages/api-docs.js b/pages/api-docs.js index 9a2fd2d..30f34e2 100644 --- a/pages/api-docs.js +++ b/pages/api-docs.js @@ -54,6 +54,16 @@ function Param({ name, type, children }) { ); } +function ErrorRow({ code, status, children }) { + return ( + <Table.Row> + <Table.Cell><Code>{code}</Code></Table.Cell> + <Table.Cell><Text fontSize="sm" color="gray.400">{status}</Text></Table.Cell> + <Table.Cell>{children}</Table.Cell> + </Table.Row> + ); +} + export default function ApiDocs() { return ( <Stack w={["95%", 960]} gap={12} py={4}> @@ -69,6 +79,12 @@ export default function ApiDocs() { on DataPipe. Code examples for jsPsych and JavaScript are available on each experiment's dashboard. </Text> + <Text> + The API is the same whichever storage provider an experiment uses. + DataPipe routes each submission to that experiment's own + destination — a Google Drive folder, a Dataverse dataset, or a + Zenodo deposition — so your experiment code never names a provider. + </Text> </Stack> {/* Save text data */} @@ -77,16 +93,16 @@ export default function ApiDocs() { Save text data </EndpointHeading> <Text> - Save a text file (CSV, JSON, etc.) to your OSF project. If you - have validation rules configured, the data will be checked before - it is sent to the OSF. + Save a text file (CSV, JSON, etc.) to your experiment's + storage. If you have validation rules configured, DataPipe checks + the data before sending it on. </Text> <ParamTable> <Param name="experimentID" type="string"> Your experiment ID, found on the experiment dashboard. </Param> <Param name="filename" type="string"> - Name for the file on OSF (e.g., <Code>subject01.csv</Code>). + Name for the stored file (e.g., <Code>subject01.csv</Code>). Must be unique — the request will fail if a file with this name already exists. </Param> @@ -114,14 +130,14 @@ export default function ApiDocs() { <Text> Save a binary file (audio, video, images) encoded as a base64 string. DataPipe decodes the string and stores the resulting - file in your OSF project. + file alongside the experiment's other data. </Text> <ParamTable> <Param name="experimentID" type="string"> Your experiment ID. </Param> <Param name="filename" type="string"> - Name for the decoded file on OSF (e.g., <Code>recording_01.webm</Code>). + Name for the decoded file (e.g., <Code>recording_01.webm</Code>). Must be unique. </Param> <Param name="data" type="string"> @@ -161,36 +177,98 @@ export default function ApiDocs() { Responses </Heading> <Text> - All responses are JSON. A successful request returns{" "} - <Code>{`{ "message": "Success" }`}</Code>. The condition - endpoint also includes a <Code>condition</Code> field. - On failure, the response contains an <Code>error</Code> code - and a <Code>message</Code> describing the problem. + All responses are JSON. On failure, the body carries an{" "} + <Code>error</Code> code from the table below and a{" "} + <Code>message</Code> describing the problem. When metadata + production is enabled, write responses also include a{" "} + <Code>metadataMessage</Code> field reporting what happened to the + metadata file; it never affects whether the data itself was stored. + </Text> + <Table.Root variant="outline"> + <Table.Header> + <Table.Row> + <Table.ColumnHeader color="white">Status</Table.ColumnHeader> + <Table.ColumnHeader color="white">Meaning</Table.ColumnHeader> + </Table.Row> + </Table.Header> + <Table.Body> + <Table.Row> + <Table.Cell><Code>201</Code></Table.Cell> + <Table.Cell> + Stored. The body is{" "} + <Code>{`{ "message": "Success" }`}</Code>. The condition + endpoint returns <Code>200</Code> with a{" "} + <Code>condition</Code> field instead. + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell><Code>202</Code></Table.Cell> + <Table.Cell> + Accepted and queued. DataPipe has your data safely but could + not reach your storage provider yet, so it will retry + automatically. <Code>error</Code> is <Code>null</Code>.{" "} + <strong>Treat this as success and do not resubmit</strong> — + retrying would store the participant's data twice. + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell><Code>400</Code></Table.Cell> + <Table.Cell> + The request was rejected and the data was not stored. + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell><Code>500</Code></Table.Cell> + <Table.Cell> + Something failed on our side. See the individual codes below + for whether the data was stored. + </Table.Cell> + </Table.Row> + </Table.Body> + </Table.Root> + + <Heading as="h3" size="sm" mt={4}> + Error codes + </Heading> + <Text fontSize="sm" color="gray.400"> + Codes beginning <Code>OSF_</Code> are historical names kept for + backward compatibility. <Code>OSF_FILE_EXISTS</Code>,{" "} + <Code>OSF_UPLOAD_ERROR</Code> and <Code>OSF_UPLOAD_EXCEPTION</Code>{" "} + are returned for every storage provider, not only OSF.{" "} + <Code>INVALID_OSF_TOKEN</Code> and <Code>INVALID_REFRESH_TOKEN</Code>{" "} + occur only on experiments still collecting to OSF. </Text> <Table.Root variant="outline"> <Table.Header> <Table.Row> <Table.ColumnHeader color="white">Error code</Table.ColumnHeader> + <Table.ColumnHeader color="white">Status</Table.ColumnHeader> <Table.ColumnHeader color="white">Meaning</Table.ColumnHeader> </Table.Row> </Table.Header> <Table.Body> - <Table.Row><Table.Cell><Code>MISSING_PARAMETER</Code></Table.Cell><Table.Cell>One or more required fields are missing from the request body.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>EXPERIMENT_NOT_FOUND</Code></Table.Cell><Table.Cell>No experiment matches the provided ID.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>DATA_COLLECTION_NOT_ACTIVE</Code></Table.Cell><Table.Cell>Data collection is not enabled for this experiment.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>BASE64DATA_COLLECTION_NOT_ACTIVE</Code></Table.Cell><Table.Cell>Base64 data collection is not enabled for this experiment.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>CONDITION_ASSIGNMENT_NOT_ACTIVE</Code></Table.Cell><Table.Cell>Condition assignment is not enabled for this experiment.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>SESSION_LIMIT_REACHED</Code></Table.Cell><Table.Cell>The experiment has reached its session limit. Increase the limit in the dashboard.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>INVALID_DATA</Code></Table.Cell><Table.Cell>The data did not pass the validation rules configured for this experiment.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>INVALID_BASE64_DATA</Code></Table.Cell><Table.Cell>The data is not valid base64.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>OSF_FILE_EXISTS</Code></Table.Cell><Table.Cell>A file with this name already exists in the OSF project. Filenames must be unique.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>OSF_UPLOAD_ERROR</Code></Table.Cell><Table.Cell>DataPipe could not upload the file to OSF. Try again later.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>INVALID_OWNER</Code></Table.Cell><Table.Cell>The experiment owner does not match a valid user account.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>INVALID_OSF_TOKEN</Code></Table.Cell><Table.Cell>The OSF token for this account is invalid or expired. Reconnect your OSF account in settings.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>INVALID_METADATA_ERROR</Code></Table.Cell><Table.Cell>The metadata generated from the data is invalid.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>OSF_METADATA_UPLOAD_ERROR</Code></Table.Cell><Table.Cell>DataPipe could not upload the metadata file to OSF.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>METADATA_ERROR</Code></Table.Cell><Table.Cell>An error occurred while processing metadata.</Table.Cell></Table.Row> - <Table.Row><Table.Cell><Code>UNKNOWN_ERROR_GETTING_CONDITION</Code></Table.Cell><Table.Cell>An unexpected error occurred while assigning a condition.</Table.Cell></Table.Row> + <ErrorRow code="MISSING_PARAMETER" status={400}>One or more required fields are missing from the request body.</ErrorRow> + <ErrorRow code="EXPERIMENT_NOT_FOUND" status={400}>No experiment matches the provided ID.</ErrorRow> + <ErrorRow code="EXPERIMENT_DATA_NOT_FOUND" status={400}>The experiment exists but its configuration could not be read.</ErrorRow> + <ErrorRow code="USER_DATA_NOT_FOUND" status={400}>The account that owns the experiment could not be read.</ErrorRow> + <ErrorRow code="INVALID_OWNER" status={400}>The experiment owner does not match a valid user account.</ErrorRow> + <ErrorRow code="EXPERIMENT_FINALIZED" status={400}>The experiment has been finalized and no longer accepts submissions.</ErrorRow> + <ErrorRow code="DATA_COLLECTION_NOT_ACTIVE" status={400}>Data collection is not enabled for this experiment.</ErrorRow> + <ErrorRow code="BASE64DATA_COLLECTION_NOT_ACTIVE" status={400}>Base64 data collection is not enabled for this experiment.</ErrorRow> + <ErrorRow code="CONDITION_ASSIGNMENT_NOT_ACTIVE" status={400}>Condition assignment is not enabled for this experiment.</ErrorRow> + <ErrorRow code="SESSION_LIMIT_REACHED" status={400}>The experiment has reached its session limit. Raise the limit in the dashboard.</ErrorRow> + <ErrorRow code="INVALID_DATA" status={400}>The data did not pass the validation rules configured for this experiment.</ErrorRow> + <ErrorRow code="INVALID_BASE64_DATA" status={400}>The data is not valid base64.</ErrorRow> + <ErrorRow code="OSF_FILE_EXISTS" status={400}>A file with this name already exists in the experiment's storage. Filenames must be unique.</ErrorRow> + <ErrorRow code="OSF_UPLOAD_ERROR" status={400}>The storage provider rejected the upload.</ErrorRow> + <ErrorRow code="PROVIDER_NOT_CONNECTED" status={400}>The owner has not connected an account for this experiment's storage provider.</ErrorRow> + <ErrorRow code="PROVIDER_TOKEN_EXPIRED" status={400}>The API token for the storage provider has expired. The owner must create a new one and reconnect it.</ErrorRow> + <ErrorRow code="INVALID_OSF_TOKEN" status={400}>The OSF token for this account is invalid or expired.</ErrorRow> + <ErrorRow code="INVALID_REFRESH_TOKEN" status={400}>The owner's OSF refresh token is no longer valid.</ErrorRow> + <ErrorRow code="UNKNOWN_ERROR_GETTING_CONDITION" status={400}>An unexpected error occurred while assigning a condition.</ErrorRow> + <ErrorRow code="TOKEN_RESOLUTION_ERROR" status={500}>DataPipe could not resolve the owner's storage credentials.</ErrorRow> + <ErrorRow code="OSF_UPLOAD_EXCEPTION" status={500}>An unexpected error occurred while uploading to the storage provider.</ErrorRow> + <ErrorRow code="DATA_PERSIST_ERROR" status={500}>DataPipe could not save the data. It was not stored, and a live participant may need to resubmit.</ErrorRow> </Table.Body> </Table.Root> </Stack> diff --git a/pages/faq.js b/pages/faq.js index a1c3bf0..2a9ed13 100644 --- a/pages/faq.js +++ b/pages/faq.js @@ -8,9 +8,14 @@ import { } from "@chakra-ui/react"; import NextLink from "next/link"; import { useState, useEffect } from "react"; +import { osfSunsetLabel } from "../lib/osf-sunset"; export default function FAQ() { const [openItems, setOpenItems] = useState(["item-0"]); + // Read from lib/osf-sunset.js rather than restated here, so this answer, the + // dashboard banners and the getting-started guide can never disagree about + // the date. Tolerates a null date the same way they do. + const osfDeadline = osfSunsetLabel(); useEffect(() => { function scrollToHash() { @@ -47,20 +52,44 @@ export default function FAQ() { <Link asChild> <NextLink href="/getting-started">getting started guide</NextLink> </Link> - . In short: create an OSF project, link your OSF account to - DataPipe, set up an experiment, and add a few lines of code to - your study to send data through DataPipe to the OSF. The easiest - way to get started is to sign in to DataPipe with your OSF - account, which automatically authorizes DataPipe to write data - on your behalf. + . In short: connect a storage provider — Google Drive, + Dataverse, or Zenodo — to your DataPipe account, create an + experiment, and add a few lines of code to your study to send data + through DataPipe to that provider. Google Drive and Zenodo connect + in one click; Dataverse asks for an API token from your + institution's installation. + </Text> + </FAQItem> + <FAQItem value="item-0b" question="I collect data on OSF. What happens now?"> + <Text mb={2}> + OSF is shutting down its projects feature, so DataPipe can no + longer create new experiments there.{" "} + {osfDeadline + ? `Experiments already collecting keep running until ${osfDeadline}, after which DataPipe stops writing to OSF.` + : "Experiments already collecting keep running for now."} + </Text> + <Text mb={2}> + Data already on OSF is unaffected. It stays in your OSF account, + and DataPipe never removes it. + </Text> + <Text> + To keep collecting, connect Google Drive, Dataverse, or Zenodo in + your{" "} + <Link asChild> + <NextLink href="/admin/account">account settings</NextLink> + </Link> + , create a new experiment on that provider, and point your study + at the new experiment ID. Your existing data does not move, so it + is worth finishing a study on OSF if you are close to done rather + than switching mid-collection. </Text> </FAQItem> <FAQItem value="item-1" question="Will DataPipe host my experiment?"> <Text mb={2}> No. You need a separate service to host your experiment online (e.g., GitHub Pages, Netlify, or university hosting). DataPipe - only handles sending data to the OSF, so you do not need to - configure any backend or server components yourself. + only handles sending data to your storage provider, so you do not + need to configure any backend or server components yourself. </Text> <Text> <Link href="https://pages.github.com/" target="_blank" rel="noopener noreferrer"> @@ -72,26 +101,28 @@ export default function FAQ() { </FAQItem> <FAQItem value="item-2" question="Will DataPipe store my data?"> <Text mb={2}> - Under normal operation, no. DataPipe routes your data to the Open - Science Framework but does not keep a copy. Data passes through - DataPipe for optional validation and is then sent directly to your - OSF project. + Under normal operation, no. DataPipe routes your data to the + storage provider you connected but does not keep a copy. Data + passes through DataPipe for optional validation and then goes + straight to your Drive folder, Dataverse dataset, or Zenodo + deposition. </Text> <Text> - The one exception is when an upload to the OSF fails (for example, - due to a temporary OSF outage or rate limit). In that case, - DataPipe temporarily caches the data so it can retry the upload + The one exception is when an upload fails (for example, because + your provider is briefly unavailable or rate-limits us). In that + case, DataPipe temporarily caches the data so it can retry the upload automatically. Cached data is encrypted at rest, stored for up to one week, and deleted as soon as the upload succeeds. See the question below for details. </Text> </FAQItem> - <FAQItem value="item-2b" question="What happens if an upload to the OSF fails?"> + <FAQItem value="item-2b" question="What happens if an upload fails?"> <Text mb={2}> - If the OSF is temporarily unavailable or returns an error, DataPipe - will not lose your data. Failed uploads are automatically cached - and retried with increasing intervals (starting at one hour, up to - 24 hours) for up to five attempts over approximately one week. + If your storage provider is temporarily unavailable or returns an + error, DataPipe will not lose your data. Failed uploads are + automatically cached and retried with increasing intervals + (starting at one hour, up to 24 hours) for up to five attempts + over approximately one week. </Text> <Text mb={2}> Your experiment dashboard will show an orange badge for pending @@ -136,9 +167,10 @@ export default function FAQ() { <FAQItem value="item-4" question="Why is DataPipe free?"> <Text> The expensive parts of running an online experiment — hosting files - and storing data — are handled by free services like GitHub Pages - and the OSF. DataPipe is a lightweight bridge between them, which - makes it inexpensive to operate. + and storing data — are handled by services you already have access + to, like GitHub Pages for hosting and Google Drive, Dataverse, or + Zenodo for storage. DataPipe is a lightweight bridge between them, + which makes it inexpensive to operate. </Text> </FAQItem> <FAQItem value="item-5" question="How expensive is it to run DataPipe?"> @@ -169,9 +201,13 @@ export default function FAQ() { <FAQItem value="item-6" question="Who can see the data I collect?"> <Text> DataPipe does not store or log your data. Once data reaches your - OSF project, visibility depends on your OSF settings. If the - receiving component is private, only you and your collaborators - can see the data. If it is public, anyone can. + storage provider, visibility is governed entirely by that + provider's own sharing settings. A Google Drive folder is + private until you share it. A Zenodo deposition stays a private + draft until you publish it. A Dataverse dataset stays a draft + until you publish it, and its access is then set by your + installation's policies. In every case DataPipe changes + nothing about who can see your data — you do. </Text> </FAQItem> <FAQItem value="item-7" question="What are the risks of using DataPipe?"> @@ -181,17 +217,18 @@ export default function FAQ() { <ol style={{ paddingLeft: "1.5em" }}> <li style={{ marginBottom: "0.5em" }}> <strong>Authorization tokens.</strong> DataPipe needs permission - to write to your OSF account. All tokens are stored encrypted. - If you sign in with your OSF account, tokens are managed and - refreshed automatically. If you use a personal access token - instead, create one specifically for DataPipe and revoke it when - you are done collecting data. + to write to your storage account, and all tokens are stored + encrypted. For Google Drive and Zenodo you authorize DataPipe + directly, and it manages and refreshes those tokens for you. For + Dataverse you supply an API token, so create one specifically for + DataPipe and revoke it when you are done collecting data. You can + disconnect any provider from your account settings at any time. </li> <li style={{ marginBottom: "0.5em" }}> <strong>Fake or spam data.</strong> As with any online experiment, a technically savvy user could submit fabricated data or spam - files to your OSF project. DataPipe provides validation rules - and session limits to reduce this risk. + files to your storage. DataPipe provides validation rules and + session limits to reduce this risk. </li> <li> <strong>Support availability.</strong> DataPipe is not a @@ -207,16 +244,17 @@ export default function FAQ() { </FAQItem> <FAQItem value="item-8" question="How does data validation work?"> <Text mb={2}> - When enabled, DataPipe checks incoming data before sending it to - the OSF. You can validate that files are well-formed JSON or CSV, + When enabled, DataPipe checks incoming data before sending it on + to your storage provider. You can validate that files are + well-formed JSON or CSV, and you can specify a list of required columns or fields that must be present. For JSON arrays (like jsPsych output), DataPipe checks whether the required fields appear in at least one object across the array. </Text> <Text> - Invalid files are rejected and not sent to the OSF. Rejected data - cannot be recovered. This feature is designed to block malicious + Invalid files are rejected and never sent to your storage + provider. Rejected data cannot be recovered. This feature is designed to block malicious submissions, not to catch errors in legitimate data. </Text> </FAQItem> @@ -224,8 +262,9 @@ export default function FAQ() { <Text mb={2}> Base64 data collection lets you send binary files — like audio recordings, video, or images — encoded as base64 strings. DataPipe - decodes the string and stores the resulting file in your OSF - project. Each request sends one file at a time. + decodes the string and stores the resulting file alongside the rest + of your experiment's data. Each request sends one file at a + time. </Text> <Text> Validation is not currently supported for base64 data, so enabling @@ -290,8 +329,8 @@ export default function FAQ() { </FAQItem> <FAQItem value="item-11" question="How does metadata production work?"> <Text mb={2}> - When enabled, DataPipe generates a dataset_description.json file in - your OSF project that describes your dataset and its variables + When enabled, DataPipe generates a dataset_description.json file + alongside your data that describes the dataset and its variables according to the Psych-DS specification. The file is updated automatically as new sessions are uploaded. </Text> @@ -315,18 +354,20 @@ export default function FAQ() { to learn more. </Text> </FAQItem> - <FAQItem value="item-12" question="What is one-click authentication?"> + <FAQItem value="item-12" question="How does DataPipe get permission to write to my storage?"> <Text mb={2}> - One-click authentication lets you sign in to DataPipe with your - OSF account. DataPipe then manages your authorization tokens - automatically, including refreshing them when they expire. This - is the recommended approach for most users. + It depends on the provider. <strong>Google Drive</strong> and{" "} + <strong>Zenodo</strong> use a one-click authorization: you approve + DataPipe on their site, and DataPipe then manages the resulting + tokens for you, including refreshing them before they expire. </Text> <Text> - The alternative is a personal access token, which you create on - the OSF and paste into DataPipe. This gives you direct control - but requires you to manage the token yourself. Both methods store - tokens encrypted. + <strong>Dataverse</strong> uses an API token that you create on + your institution's installation and paste into DataPipe. That + gives you direct control, but nothing can renew it for you: when + the token expires, data stops arriving until you create a new one + and reconnect. Tokens are stored encrypted either way, and you can + disconnect a provider at any time from your account settings. </Text> </FAQItem> <FAQItem value="item-13" question="How should I cite DataPipe?"> diff --git a/pages/getting-started.js b/pages/getting-started.js index abade5f..03a9105 100644 --- a/pages/getting-started.js +++ b/pages/getting-started.js @@ -11,6 +11,12 @@ import { } from "@chakra-ui/react"; import { ChevronDown, ChevronRight, Shield } from "lucide-react"; import { useState } from "react"; +import { osfSunsetLabel } from "../lib/osf-sunset"; + +// Which Zenodo this deployment points at -- "" on production, "sandbox." on +// the test site. Same reasoning as lib/provider-config.js: the test +// deployment must not send researchers to sign up on the live service. +const ZENODO_HOST = `https://${process.env.NEXT_PUBLIC_ZENODO_ENV ?? ""}zenodo.org`; function StepNumber({ number }) { return ( @@ -107,7 +113,48 @@ function FeatureItem({ name, children }) { ); } +// One storage provider, in the terms a researcher choosing between them +// actually needs: whether it is the right fit, where the data physically ends +// up, how they connect, and the limit that will eventually matter. The +// decision sentence leads because that is the only line someone skimming for +// "which one do I pick" needs to read. Plain rows rather than a comparison +// table so it stays readable on a phone. +function ProviderOption({ name, summary, landsIn, connect, limits }) { + return ( + <Box borderWidth="1px" borderColor="gray.700" borderRadius={8} p={4}> + <Text fontWeight="semibold" color="brandOrange.300" mb={1}> + {name} + </Text> + <Text fontSize="sm" mb={3}> + {summary} + </Text> + <Stack gap={1}> + <Text fontSize="sm" color="gray.400"> + <Text as="span" fontWeight="semibold">Data lands in:</Text> {landsIn} + </Text> + <Text fontSize="sm" color="gray.400"> + <Text as="span" fontWeight="semibold">Connect with:</Text> {connect} + </Text> + <Text fontSize="sm" color="gray.400"> + <Text as="span" fontWeight="semibold">Limits:</Text> {limits} + </Text> + </Stack> + </Box> + ); +} + export default function GettingStarted() { + const osfDeadline = osfSunsetLabel(); + // Consequence first (PRODUCT.md principle 2): what stops working, then why. + // Both sentences degrade gracefully when no cutoff has been announced -- + // lib/osf-sunset.js tolerates a null date and so must every reader of it. + const osfStops = osfDeadline + ? `DataPipe will stop writing to OSF after ${osfDeadline}.` + : "DataPipe is winding down its support for OSF."; + const osfUntil = osfDeadline + ? "Experiments already collecting keep running until that date." + : "Experiments already collecting keep running for now."; + return ( <Stack w={["95%", 960]} gap={8} py={4}> <VStack gap={2} align="start"> @@ -115,80 +162,134 @@ export default function GettingStarted() { Getting Started </Heading> <Text color="gray.400" fontSize="lg"> - Set up DataPipe to send experiment data directly to the OSF. This - guide covers a typical online experiment using free tools. + DataPipe sends data from your experiment straight to storage you + control — Google Drive, Dataverse, or Zenodo. This guide sets up one + experiment end to end, from choosing a provider to your first test + run. </Text> </VStack> - <StepCard number={1} title="Create an OSF project"> + <StepCard number={1} title="Choose where your data goes"> <Text> - Create a project at{" "} - <Link href={`https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io`} target="_blank" rel="noopener noreferrer" color="brandOrange.300"> - osf.io - </Link> - {" "}to store your experiment data. You will need an OSF account - — create one if you do not have one already. + DataPipe writes each participant's data into your own account + with one of the three storage providers below. The data is yours + throughout — DataPipe only ever asks for permission to add files. + You can use a different provider for each experiment, so this choice + is not permanent. </Text> + <Stack gap={3}> + <ProviderOption + name="Google Drive" + summary="Your own Google Drive. Choose this if you want the fewest steps and do not need a citable dataset." + landsIn="a folder in My Drive/DataPipe, or in a parent folder you pick." + connect="your Google account, in one click." + limits="free Google accounts share 15 GB across Drive, Gmail, and Photos. Uploads stop once that is full." + /> + <ProviderOption + name="Dataverse" + summary="Institutional repositories run by universities and consortia — Harvard Dataverse, Borealis, DataverseNL, and others. Choose this if your institution or funder expects data to live there." + landsIn="a draft dataset, in a collection you name." + connect="an API token from your institution's installation." + limits="your installation sets its own file size and storage limits. API tokens expire, often yearly, and DataPipe cannot renew them — data stops arriving until you reconnect." + /> + <ProviderOption + name="Zenodo" + summary="An open repository run by CERN that issues a DOI for every published record. Choose this if you want your data citable without an institutional repository." + landsIn="a deposition that stays private until you publish it." + connect="your Zenodo account, in one click." + limits="100 files and 50 GB per record. DataPipe merges completed sessions into archives so a long study stays under the file limit." + /> + </Stack> <Text> - Once you have an account, click <strong>Create Project</strong> and - give it any name you like. Your OSF account can also be used to - sign in to DataPipe directly. + You need an account with whichever provider you choose:{" "} + <Link href="https://drive.google.com" target="_blank" rel="noopener noreferrer" color="brandOrange.300"> + Google Drive + </Link> + ,{" "} + <Link href="https://dataverse.org/institutions" target="_blank" rel="noopener noreferrer" color="brandOrange.300"> + your Dataverse installation + </Link> + , or{" "} + <Link href={ZENODO_HOST} target="_blank" rel="noopener noreferrer" color="brandOrange.300"> + Zenodo + </Link> + . </Text> + <CollapsibleSection title="Already collecting data on OSF?"> + <Text> + {osfStops} OSF is shutting down its projects feature, so DataPipe + can no longer create new experiments there. {osfUntil} + </Text> + <Text> + Data already on OSF is unaffected and stays in your OSF account. To + keep collecting past that point, connect one of the providers + above, create a new experiment on it, and point your experiment + code at the new experiment ID. + </Text> + </CollapsibleSection> </StepCard> - <StepCard number={2} title="Link your OSF account to DataPipe"> - <Text> - DataPipe needs authorization to create files in your OSF projects. - If you signed up for DataPipe using your OSF account, this is - already done. - </Text> + <StepCard number={2} title="Connect your storage provider"> <Text> - Otherwise, go to your{" "} + Go to your{" "} <Link href="/admin/account" color="brandOrange.300"> Account Settings </Link> - , switch to one-click authentication if not already enabled, and - click <strong>Link OSF Account</strong>. You will be redirected to - OSF to authorize DataPipe, then sent back automatically. + {" "}and find the <strong>Storage Providers</strong> section. Click{" "} + <strong>Connect</strong> next to the provider you chose. A green{" "} + <strong>Connected</strong> label confirms it worked. + </Text> + <Text> + <strong>Google Drive</strong> and <strong>Zenodo</strong> hand you + to their own sign-in page to authorize DataPipe, then bring you + straight back. + </Text> + <Text> + <strong>Dataverse</strong> opens a short form instead. It needs the + full address of your institution's installation — for + example, <em>https://dataverse.harvard.edu</em> — and an API token, + which you create under the <strong>API Token</strong> tab of your + Dataverse account. + </Text> + <Text color="gray.400" fontSize="sm"> + You can connect more than one provider, and disconnect any of them + from the same screen. Disconnecting stops new data from reaching + that provider. It never removes data already stored there. </Text> - <CollapsibleSection title="Using a personal access token instead (legacy)"> - <Text> - Go to your DataPipe Account Settings and switch to "Personal - access token" mode. Then on OSF, go to your account settings, - click the <strong>Personal Access Tokens</strong> tab, and create - a new token with the <strong>osf.full_write</strong> scope. - </Text> - <Text> - Copy the token value and paste it into your DataPipe Account - Settings. A green checkmark will confirm the token is valid. - </Text> - </CollapsibleSection> </StepCard> <StepCard number={3} title="Create a DataPipe experiment"> <Text> - Click <strong>New Experiment</strong> in the navigation bar. You will - need to provide: + Click <strong>New Experiment</strong> in the navigation bar. Pick + your storage provider at the top of the form and give the experiment + a <strong>Title</strong>. The rest of the form changes with the + provider: </Text> <Stack gap={2} pl={4}> <Text> - <Text as="span" fontWeight="semibold">Title</Text> — a name for - your experiment. + <Text as="span" fontWeight="semibold">Google Drive</Text> — nothing + else is required. To keep the data somewhere specific, + click <strong>Choose Drive folder</strong> and pick a parent + folder. Otherwise DataPipe creates the folder + in <em>My Drive/DataPipe</em>. </Text> <Text> - <Text as="span" fontWeight="semibold">OSF Project ID</Text> — the - short code from your OSF project URL. For example, if your project - is at <em>osf.io/abcde</em>, the ID is <strong>abcde</strong>. + <Text as="span" fontWeight="semibold">Dataverse</Text> — the{" "} + <strong>collection alias</strong>, which is the short name from + your collection's URL, plus the author name, contact email, + and description that Dataverse requires for every dataset. Subject + is optional. </Text> <Text> - <Text as="span" fontWeight="semibold">Data Component Name</Text> — DataPipe - will create a new component with this name inside your OSF project - to store all data files. + <Text as="span" fontWeight="semibold">Zenodo</Text> — the{" "} + <strong>creator name</strong> and a <strong>description</strong>{" "} + for the deposition. Affiliation is optional. </Text> </Stack> <Text> - Click <strong>Create</strong> and you will be taken to the experiment - dashboard. + Click <strong>Create</strong>. DataPipe makes the folder, dataset, + or deposition for you and opens the experiment dashboard, which + links straight to it. </Text> </StepCard> @@ -206,8 +307,8 @@ export default function GettingStarted() { specify required fields. This helps prevent malicious submissions. </FeatureItem> <FeatureItem name="Session limit"> - — cap the number of data files that can be sent to your OSF - project. You can increase this later. + — cap how many data files DataPipe will accept. You can raise the + limit later. </FeatureItem> <FeatureItem name="Psych-DS metadata"> — automatically produce metadata adhering to{" "} @@ -221,8 +322,9 @@ export default function GettingStarted() { </Stack> <Callout> Only activate the features you need, and only during active data - collection. DataPipe creates an open path to your OSF project — - validation and session limits reduce the risk of unwanted submissions. + collection. DataPipe creates an open path into your storage provider + — validation and session limits reduce the risk of unwanted + submissions. </Callout> </StepCard> @@ -236,8 +338,10 @@ export default function GettingStarted() { . Otherwise, you can use the DataPipe API directly with fetch requests. </Text> <Text> - Your experiment dashboard has ready-to-use code snippets for both - jsPsych and plain JavaScript. Go to{" "} + The code is the same whichever storage provider you chose — your + experiment sends data to DataPipe, and DataPipe handles the rest. + Your experiment dashboard has ready-to-use snippets for both jsPsych + and plain JavaScript. Go to{" "} <Link href="/admin" color="brandOrange.300">My Experiments</Link>, select your experiment, and copy the code from the{" "} <strong>Code Samples</strong> panel. @@ -289,7 +393,7 @@ export default function GettingStarted() { <Stack gap={2} pl={4}> <Text> <Text as="span" fontWeight="semibold">Enable data collection</Text> — for - sending text files (JSON, CSV) to OSF. + sending text files (JSON, CSV). </Text> <Text> <Text as="span" fontWeight="semibold">Enable base64 data collection</Text> — for @@ -301,9 +405,26 @@ export default function GettingStarted() { </Text> </Stack> <Text> - Run through your experiment once to verify data files appear in - your OSF data component. You should see them immediately after - completing the experiment. + Run through your experiment once to check that the data arrives. The + experiment dashboard links straight to your Drive folder, Dataverse + dataset, or Zenodo deposition — your file should appear there + shortly after you finish. + </Text> + </StepCard> + + <StepCard number={8} title="When data collection ends"> + <Text> + When your study is finished, <strong>finalize</strong> the + experiment from its dashboard. DataPipe merges every remaining data + file into a single archive on your storage provider and stops + accepting new submissions, which makes the dataset easier to share + and cite. Finalizing cannot be undone, so do it only when you are + certain no more data is coming. + </Text> + <Text color="gray.400" fontSize="sm"> + On Zenodo, finalizing prepares the deposition but does not publish + it. Publishing the record, and with it issuing the DOI, stays your + decision and happens on Zenodo itself. </Text> </StepCard> </Stack> From e98be9093d38185b6bc748c47988db778ab80037 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 12:37:29 -0400 Subject: [PATCH 101/181] =?UTF-8?q?feat(ui):=20shared=20primitives=20?= =?UTF-8?q?=E2=80=94=20SettingsSection,=20StatusIndicator,=20FormErrorAler?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First residents of components/ui/, extracted from the account-page critique (.impeccable snapshot 2026-08-22). Each replaces a divergent or failing pattern rather than adding a new one: - SettingsSection: real h2 headings + optional description + danger variant, replacing the five 12px uppercase gray.500 eyebrow labels (3.43:1, below the AA floor). Owns its own rhythm so sections stop re-inventing spacing. - StatusIndicator: one status rendering with a MANDATORY visible text label, replacing three competing patterns (icon+text, icon-behind-tooltip, badge). ok-status is brandTeal, not Chakra green — one green per DESIGN.md §1. - FormErrorAlert: the single form-error surface, lifted verbatim from the one error path that already did it right (LinkedAccounts). All color choices carry computed WCAG ratios in comments; semantic tokens where they exist, literal steps annotated for the light/dark migration. 20 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- __tests__/form-error-alert.test.jsx | 54 +++++++++++++ __tests__/settings-section.test.jsx | 102 +++++++++++++++++++++++++ __tests__/status-indicator.test.jsx | 72 ++++++++++++++++++ components/ui/FormErrorAlert.js | 48 ++++++++++++ components/ui/SettingsSection.js | 114 ++++++++++++++++++++++++++++ components/ui/StatusIndicator.js | 104 +++++++++++++++++++++++++ 6 files changed, 494 insertions(+) create mode 100644 __tests__/form-error-alert.test.jsx create mode 100644 __tests__/settings-section.test.jsx create mode 100644 __tests__/status-indicator.test.jsx create mode 100644 components/ui/FormErrorAlert.js create mode 100644 components/ui/SettingsSection.js create mode 100644 components/ui/StatusIndicator.js diff --git a/__tests__/form-error-alert.test.jsx b/__tests__/form-error-alert.test.jsx new file mode 100644 index 0000000..fb56fb0 --- /dev/null +++ b/__tests__/form-error-alert.test.jsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +import FormErrorAlert from "../components/ui/FormErrorAlert"; + +function renderWithChakra(ui) { + return render(<ChakraProvider value={system}>{ui}</ChakraProvider>); +} + +describe("FormErrorAlert", () => { + it("renders nothing for null children", () => { + const { container } = renderWithChakra( + <FormErrorAlert>{null}</FormErrorAlert> + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for undefined children", () => { + const { container } = renderWithChakra(<FormErrorAlert />); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing for an empty string ("")', () => { + const { container } = renderWithChakra(<FormErrorAlert>{""}</FormErrorAlert>); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the message when present", () => { + renderWithChakra( + <FormErrorAlert>Could not connect. Please try again.</FormErrorAlert> + ); + + expect( + screen.getByText("Could not connect. Please try again.") + ).toBeInTheDocument(); + }); + + it("renders inside an error-status alert", () => { + const { container } = renderWithChakra( + <FormErrorAlert>Something went wrong.</FormErrorAlert> + ); + + // Alert.Root does not forward a plain `role="alert"` in Chakra v3 by + // default in every version, so assert on the text plus a rendered + // element rather than coupling to an internal DOM attribute. + expect(container.firstChild).not.toBeNull(); + expect(screen.getByText("Something went wrong.")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/settings-section.test.jsx b/__tests__/settings-section.test.jsx new file mode 100644 index 0000000..734492c --- /dev/null +++ b/__tests__/settings-section.test.jsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +import SettingsSection from "../components/ui/SettingsSection"; + +function renderWithChakra(ui) { + return render(<ChakraProvider value={system}>{ui}</ChakraProvider>); +} + +describe("SettingsSection", () => { + it("renders the title as a heading (h2)", () => { + renderWithChakra(<SettingsSection title="Storage providers" />); + + const heading = screen.getByRole("heading", { + level: 2, + name: "Storage providers", + }); + expect(heading).toBeInTheDocument(); + }); + + it("renders the description when given", () => { + renderWithChakra( + <SettingsSection + title="Storage providers" + description="Where your experiment data lands. At least one is required." + /> + ); + + expect( + screen.getByText( + "Where your experiment data lands. At least one is required." + ) + ).toBeInTheDocument(); + }); + + it("renders no description text when not given", () => { + renderWithChakra(<SettingsSection title="Storage providers" />); + + // Nothing beyond the heading should render -- no stray empty paragraph. + expect(screen.queryByText(/./, { selector: "p" })).not.toBeInTheDocument(); + }); + + it("renders children inside the section", () => { + renderWithChakra( + <SettingsSection title="Storage providers"> + <div>provider list</div> + </SettingsSection> + ); + + expect(screen.getByText("provider list")).toBeInTheDocument(); + }); + + it("default variant does not add a bordered container", () => { + const { container } = renderWithChakra( + <SettingsSection title="Sign-in methods"> + <div>content</div> + </SettingsSection> + ); + + // The default variant's wrapping Box carries no borderWidth -- only + // the danger variant (tested below) gets the "different rules apply + // here" bordered treatment. + const wrapper = container.firstChild; + const style = window.getComputedStyle(wrapper); + expect(["", "0px"]).toContain(style.borderWidth); + }); + + it("danger variant wraps the section content in a bordered container", () => { + renderWithChakra( + <SettingsSection title="Danger zone" variant="danger"> + <div>Delete account</div> + </SettingsSection> + ); + + const heading = screen.getByRole("heading", { + level: 2, + name: "Danger zone", + }); + const content = screen.getByText("Delete account"); + + // The bordered container is an ancestor of both the heading and the + // content -- find the nearest common ancestor with a borderWidth style. + let node = content; + let borderedAncestor = null; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + if ( + style.borderWidth && + style.borderWidth !== "0px" && + node.contains(heading) + ) { + borderedAncestor = node; + break; + } + node = node.parentElement; + } + + expect(borderedAncestor).not.toBeNull(); + }); +}); diff --git a/__tests__/status-indicator.test.jsx b/__tests__/status-indicator.test.jsx new file mode 100644 index 0000000..f4ef015 --- /dev/null +++ b/__tests__/status-indicator.test.jsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "../lib/theme"; +import "@testing-library/jest-dom"; + +import StatusIndicator from "../components/ui/StatusIndicator"; + +function renderWithChakra(ui) { + return render(<ChakraProvider value={system}>{ui}</ChakraProvider>); +} + +describe("StatusIndicator", () => { + it.each([ + ["ok", "Connected"], + ["warning", "Re-authentication required"], + ["error", "Not connected"], + ["neutral", "Not applicable"], + ])("renders the visible label text for status=%s", (status, label) => { + renderWithChakra(<StatusIndicator status={status} label={label} />); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it("marks the icon as decorative (aria-hidden)", () => { + const { container } = renderWithChakra( + <StatusIndicator status="ok" label="Connected" /> + ); + + const icon = container.querySelector("svg"); + expect(icon).toHaveAttribute("aria-hidden", "true"); + }); + + it("does not apply role=status (static display, not a live region)", () => { + renderWithChakra(<StatusIndicator status="ok" label="Connected" />); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("warns when rendered without a label", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + renderWithChakra(<StatusIndicator status="ok" />); + + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("`label` is required") + ); + + consoleError.mockRestore(); + }); + + it("warns when the label is an empty string", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + renderWithChakra(<StatusIndicator status="warning" label="" />); + + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("`label` is required") + ); + + consoleError.mockRestore(); + }); + + it("does not warn when a label is provided", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + renderWithChakra(<StatusIndicator status="ok" label="Connected" />); + + expect(consoleError).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +}); diff --git a/components/ui/FormErrorAlert.js b/components/ui/FormErrorAlert.js new file mode 100644 index 0000000..98bd318 --- /dev/null +++ b/components/ui/FormErrorAlert.js @@ -0,0 +1,48 @@ +import { Alert, Text } from "@chakra-ui/react"; + +/** + * FormErrorAlert + * + * The one error surface for the app. Extracted verbatim from the pattern + * `components/account/LinkedAccounts.js` already uses correctly: + * + * <Alert.Root status="error" borderRadius="md"> + * <Alert.Indicator /> + * <Text fontSize="sm">{error}</Text> + * </Alert.Root> + * + * `.impeccable/critique/2026-08-22T13-53-28Z__pages-admin-account-js.md` + * (P0 "Two of the three network paths fail silently") documents that this + * exact pattern exists in exactly one of the account page's several error + * paths: `LinkedAccounts` renders it, `DeleteAccount` renders a near-copy + * inline, `ProviderConnections`'s OAuth paths drop the error to + * `console.error` with nothing rendered, and `ChangePassword` closes its + * own dialog before showing a bare "Failed" with no message. PRODUCT.md's + * Design Principle 5 ("practice what you preach... honest errors, no + * silent failures") and the critique's fix both call for ONE + * error-surfacing pattern reused everywhere instead of three ad hoc ones. + * This component is that pattern, pulled out so every call site gets it + * for free instead of re-typing (and, per the critique, sometimes + * skipping) it. + * + * Renders NOTHING when `children` is falsy or an empty string, so a call + * site can write `<FormErrorAlert>{error}</FormErrorAlert>` unconditionally + * without an `{error && ...}` guard of its own. + * + * `Alert.Root status="error"` supplies Chakra's own error coloring and + * icon; no literal color values are chosen here, so there is nothing in + * this file for the light/dark mode migration to revisit. + * + * @param {React.ReactNode} children - The error message. Nothing renders + * if this is falsy (null, undefined, ""). + */ +export default function FormErrorAlert({ children }) { + if (!children) return null; + + return ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{children}</Text> + </Alert.Root> + ); +} diff --git a/components/ui/SettingsSection.js b/components/ui/SettingsSection.js new file mode 100644 index 0000000..89a5f39 --- /dev/null +++ b/components/ui/SettingsSection.js @@ -0,0 +1,114 @@ +import { Box, Heading, Text } from "@chakra-ui/react"; + +/** + * SettingsSection + * + * Replaces the `SectionLabel` eyebrow pattern that used to sit above every + * block on `pages/admin/account.js` (five of five sections: Sign-in Methods, + * Storage Providers, OSF (Legacy), Account, Danger Zone). That pattern — + * `fontSize="xs"` + `fontWeight="semibold"` + `textTransform="uppercase"` + + * `letterSpacing="wide"` + `color="gray.500"` — is flagged in + * `.impeccable/critique/2026-08-22T13-53-28Z__pages-admin-account-js.md` as + * both a named anti-pattern (an eyebrow above every section is scaffolding + * by reflex) and a WCAG failure: gray.500 (#71717a) measures 3.43:1 against + * the app's #1C1F22 surface, below the 4.5:1 AA floor for body text, and the + * uppercase/tracked treatment makes the least legible combination available + * even worse. Every section on that page also read as identical visual + * weight, so "Danger Zone" was distinguished from "Sign-in Methods" by + * nothing but the hue of a caption most people could not read. + * + * This component renders a REAL heading (Chakra `Heading`, `as="h2"`) in + * sentence case, no uppercase transform, no letter-spacing trick, plus an + * optional one-line description answering "what is this and why would I + * care" (Design Principle 1 in PRODUCT.md: assume no recall). It owns its + * own heading -> description -> content spacing so call sites don't have to + * re-invent rhythm per section, and a `variant="danger"` wraps the section + * in a bordered container so a genuinely different section ("different + * rules apply here") reads as different, per the critique's fix for its + * P1 "every section has identical visual weight" finding. + * + * Contrast (relative luminance, measured against the app body #1C1F22 -- + * see lib/theme.js for the same method): + * - Heading, default: color="fg" resolves to gray.50 (#fafafa) -> 15.86:1. + * Semantic token, not a literal step, so it tracks the light/dark theme + * migration automatically. + * - Description: color="fg.muted" resolves to gray.400 (#a1a1aa) -> + * 6.46:1. Also semantic. NEVER use `fg.subtle` here -- that semantic + * token resolves to the literal gray.500 step (#71717a), which is the + * same 3.43:1 failure this component exists to retire. + * - Danger heading tint: literal `red.400` (#f87171) -> 5.99:1, clears + * the 4.5:1 body-text floor. + * - Danger border: literal `red.500` (#ef4444) -> 4.40:1 against + * #1C1F22, clears the 3:1 floor WCAG 1.4.11 sets for non-text UI + * boundaries. + * NOTE (dark-surface assumption): `red.400`/`red.500` above are literal + * Chakra palette steps, not house semantic tokens -- there is no + * semantic "danger"/"error" text or border token in lib/theme.js yet + * (only the brand palettes and the re-pointed `gray` get that + * treatment). Revisit these two literal references when the light/dark + * mode migration lands; they were chosen and measured for the current + * permanently-dark surface only. + * + * @param {string} title - Required. Rendered as an <h2>. Pass sentence + * case ("Storage providers", not "STORAGE PROVIDERS" or "Storage + * Providers"). + * @param {string} [description] - Optional single line under the heading, + * in plain language, explaining what the section is and why it matters. + * @param {React.ReactNode} children - Section content. + * @param {"default"|"danger"} [variant="default"] - "danger" wraps the + * section in a bordered container to signal it plays by different rules + * (e.g. irreversible actions). The heading stays legible either way. + */ +export default function SettingsSection({ + title, + description, + children, + variant = "default", +}) { + if (process.env.NODE_ENV !== "production" && !title) { + console.error( + "SettingsSection: `title` is required. A section with no heading " + + "is exactly the illegible-eyebrow problem this component replaces." + ); + } + + const isDanger = variant === "danger"; + + const content = ( + <> + <Heading + as="h2" + size="md" + fontWeight="semibold" + color={isDanger ? "red.400" : "fg"} + mb={description ? 1 : 4} + > + {title} + </Heading> + + {description && ( + <Text fontSize="sm" color="fg.muted" mb={4}> + {description} + </Text> + )} + + {children} + </> + ); + + if (!isDanger) { + return <Box w="100%">{content}</Box>; + } + + return ( + <Box + w="100%" + borderWidth="1px" + borderColor="red.500" + borderRadius="md" + p={4} + > + {content} + </Box> + ); +} diff --git a/components/ui/StatusIndicator.js b/components/ui/StatusIndicator.js new file mode 100644 index 0000000..b701917 --- /dev/null +++ b/components/ui/StatusIndicator.js @@ -0,0 +1,104 @@ +import { HStack, Text } from "@chakra-ui/react"; +import { CircleCheck, TriangleAlert, CircleX, Minus } from "lucide-react"; + +/** + * StatusIndicator + * + * One way to render status, replacing three divergent patterns that grew up + * independently on `pages/admin/account.js`: + * - `ProviderConnections.js`: icon + visible text ("Connected") -- the one + * that already got this right. + * - `OAuthTokenStatus.js`: icon-only, with the status word ("Connected" / + * "Re-authentication Required") living inside a `Tooltip` that only + * opens on hover -- inaccessible on touch and to keyboard users, and a + * color/icon-alone signal until the tooltip is triggered. + * - `LinkedAccounts.js`: a plain gray `Badge` ("Enabled") with no icon and + * no relation to the other two. + * `.impeccable/critique/2026-08-22T13-53-28Z__pages-admin-account-js.md` + * calls this out directly under "Status renders three ways" (P2) and + * Recognition Rather Than Recall (score 2): "OSF connection status is + * icon-only with the text behind hover." PRODUCT.md's Accessibility section + * is explicit that status must never be conveyed by color or icon alone. + * + * This component makes the label mandatory and ALWAYS visible as text next + * to the icon -- never behind a tooltip, never color-only. In development, + * a missing label is a console error rather than a silent render, so the + * anti-pattern this replaces can't quietly come back through a call site + * that forgets the prop. + * + * The icon is `aria-hidden` and decorative; the visible label text is what + * carries the meaning to assistive tech, so no `role="status"` is applied + * here -- this renders a static, already-known state (e.g. "Connected" on + * a settled page), not a live region announcing a change as it happens. A + * call site that needs an announcement should wrap this in its own live + * region at the point the status actually changes. + * + * Contrast (measured against the app body #1C1F22, same method as + * lib/theme.js): + * - ok / CircleCheck: literal `brandTeal.500` (#13b24b) -> 5.91:1. NOT + * Chakra's `green.500`: DESIGN.md §1 commits to one green ("ok" IS the + * brand teal), and giving this primitive a second green at birth would + * re-create the two-greens drift it exists to end. + * - warning / TriangleAlert: literal `orange.500` (#f97316) -> 5.91:1. + * - error / CircleX: literal `red.400` (#f87171) -> 5.99:1. + * - neutral / Minus: literal `gray.400` (#a1a1aa) -> 6.46:1. + * All four clear the 3:1 floor WCAG 1.4.11 sets for non-text UI (icons + * count as non-text), with headroom to spare. + * NOTE (dark-surface assumption): all four are literal Chakra palette + * steps passed as raw CSS color strings to lucide-react's `color` prop + * (lucide icons are not Chakra-token-aware), because lib/theme.js has no + * semantic success/warning/error/neutral color tokens yet -- only the + * brand palettes and the re-pointed `gray` get semantic treatment. These + * were chosen and measured for the current permanently-dark surface; + * revisit when the light/dark mode migration adds semantic status + * tokens. + * - Label: `color="fg"` (semantic, gray.50 / #fafafa) -> 15.86:1, well + * above the 4.5:1 body-text floor. Semantic, so it tracks the + * light/dark migration automatically. + * + * @param {"ok"|"warning"|"error"|"neutral"} status + * @param {string} label - REQUIRED. Always rendered as visible text beside + * the icon -- this is the one place status is stated in words, not + * inferred from color or shape. + * @param {number} [size=16] - Icon size in px (~16-18 recommended). + */ + +const STATUS_ICONS = { + ok: CircleCheck, + warning: TriangleAlert, + error: CircleX, + neutral: Minus, +}; + +// Raw CSS color strings (Chakra's generated custom properties), not Chakra +// style props -- lucide-react's `color` prop is not token-aware, so this is +// the same `var(--chakra-colors-...)` pattern already used for icon color +// elsewhere in the app (see components/account/ProviderConnections.js). +const STATUS_COLORS = { + ok: "var(--chakra-colors-brand-teal-500)", + warning: "var(--chakra-colors-orange-500)", + error: "var(--chakra-colors-red-400)", + neutral: "var(--chakra-colors-gray-400)", +}; + +export default function StatusIndicator({ status, label, size = 16 }) { + if (process.env.NODE_ENV !== "production" && !label) { + console.error( + "StatusIndicator: `label` is required. Status must never be " + + "conveyed by color or icon alone -- see PRODUCT.md Accessibility " + + "& Inclusion." + ); + } + + const Icon = STATUS_ICONS[status] || STATUS_ICONS.neutral; + const color = STATUS_COLORS[status] || STATUS_COLORS.neutral; + + return ( + <HStack gap={1.5} display="inline-flex" alignItems="center"> + <Icon aria-hidden="true" size={size} color={color} /> + <Text fontSize="sm" color="fg"> + {label} + </Text> + </HStack> + ); +} From 4fdf735aadb6100a629a7330470d18f0ace7776f Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 12:37:44 -0400 Subject: [PATCH 102/181] =?UTF-8?q?fix(account):=20close=20both=20P0s=20?= =?UTF-8?q?=E2=80=94=20guard=20provider=20disconnect,=20surface=20every=20?= =?UTF-8?q?error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disconnect was one unguarded click that could silently stop an in-flight study; it now confirms through the new ConfirmDialog primitive, naming the real consequence with a fresh per-open count of the experiments wired to that provider (owner query filtered by storageProvider; includes paused experiments deliberately — disconnecting breaks those too on re-enable, and the copy says 'set up to send' to match). Disconnect drops red for neutral outline: red is reserved for irreversible destruction so it still means something in the Danger Zone. Every error path on the page now renders something a researcher can act on: - ProviderConnections: connect/disconnect failures render alerts instead of console.error-only; missing uid guard added. - ChangePassword: dialog stays open on failure with the mapped message (auth/requires-recent-login finally handled instead of discarded); validation gated on touched; state reset on reopen; success auto-clears. - SelectAuth: token-save failures surface in the dialog. - DeleteAccount: adopts ConfirmDialog (destructive); Cancel is neutral, not a green solid outshouting the red Delete; setDeleting now set before the request, closing a redirect race. - LinkedAccounts: federated-only accounts with a known email can now add a password (linkWithCredential), so losing the one linked provider stops being a lockout; hidden entirely for ORCID accounts with no email. lib/auth-errors gains changePassword/setPassword modes with action-specific requires-recent-login copy. 29 new tests; one flaky-under-load test given a 15s budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- __tests__/change-password.test.jsx | 118 +++++++++++++++ __tests__/confirm-dialog.test.jsx | 108 ++++++++++++++ __tests__/provider-connections.test.jsx | 74 +++++++++- components/account/ChangePassword.js | 100 +++++++++---- components/account/DeleteAccount.js | 96 ++++--------- components/account/LinkedAccounts.js | 167 +++++++++++++++++++++- components/account/ProviderConnections.js | 126 ++++++++++++++-- components/account/SelectAuth.js | 38 ++++- components/ui/ConfirmDialog.js | 110 ++++++++++++++ lib/auth-errors.js | 24 +++- 10 files changed, 847 insertions(+), 114 deletions(-) create mode 100644 __tests__/change-password.test.jsx create mode 100644 __tests__/confirm-dialog.test.jsx create mode 100644 components/ui/ConfirmDialog.js diff --git a/__tests__/change-password.test.jsx b/__tests__/change-password.test.jsx new file mode 100644 index 0000000..3fbf5f0 --- /dev/null +++ b/__tests__/change-password.test.jsx @@ -0,0 +1,118 @@ +import { render, screen, within, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import "@testing-library/jest-dom"; +import { system } from "../lib/theme"; + +const mockUpdatePassword = jest.fn(); +jest.mock("firebase/auth", () => ({ + updatePassword: (...args) => mockUpdatePassword(...args), +})); + +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: { uid: "user-1" } }, +})); + +import ChangePassword from "../components/account/ChangePassword"; + +function renderComponent() { + return render( + <ChakraProvider value={system}> + <ChangePassword /> + </ChakraProvider> + ); +} + +// Opens the dialog and returns it scoped with `within`. The trigger button +// and the dialog's own submit button share the exact same accessible name +// ("Change Password"), and Chakra hides the background from assistive tech +// asynchronously once the dialog settles -- relying on that timing to +// disambiguate the two is racy, so every query below is scoped to the +// dialog element itself instead. +async function openDialog() { + fireEvent.click(screen.getByRole("button", { name: /^Change Password$/i })); + const dialog = await screen.findByRole("dialog"); + return within(dialog); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// Chakra's Dialog (Ark UI/zag-js underneath) opens and closes through its own +// state machine, which updates asynchronously relative to the click that +// triggers it -- so every interaction with it below is awaited (findBy*) +// rather than asserted on synchronously, same as finalize-control.test.jsx. +describe("ChangePassword", () => { + it("does not show validation errors before the fields are touched", async () => { + renderComponent(); + const dialog = await openDialog(); + + // The critique's exact failure mode: "Password must be at least 12 + // characters" rendering the instant the dialog opens, before a keystroke. + expect(dialog.getByLabelText(/New Password/i)).toBeInTheDocument(); + expect( + dialog.queryByText(/Password must be at least 12 characters/i) + ).not.toBeInTheDocument(); + expect(dialog.queryByText(/Passwords do not match/i)).not.toBeInTheDocument(); + }); + + it("shows the length error only after the new-password field is blurred", async () => { + renderComponent(); + const dialog = await openDialog(); + + const newPassword = dialog.getByLabelText(/New Password/i); + fireEvent.change(newPassword, { target: { value: "short" } }); + expect( + dialog.queryByText(/Password must be at least 12 characters/i) + ).not.toBeInTheDocument(); + + fireEvent.blur(newPassword); + expect( + dialog.getByText(/Password must be at least 12 characters/i) + ).toBeInTheDocument(); + }); + + it("keeps the dialog open and renders the mapped error when updatePassword fails", async () => { + mockUpdatePassword.mockRejectedValue({ code: "auth/requires-recent-login" }); + renderComponent(); + const dialog = await openDialog(); + + fireEvent.change(dialog.getByLabelText(/New Password/i), { + target: { value: "a-long-enough-password" }, + }); + fireEvent.change(dialog.getByLabelText(/Confirm Password/i), { + target: { value: "a-long-enough-password" }, + }); + fireEvent.click(dialog.getByRole("button", { name: /^Change Password$/i })); + + expect( + await dialog.findByText( + /For security, sign out and sign back in, then change your password\./i + ) + ).toBeInTheDocument(); + // Still open: the field is still on screen. + expect(dialog.getByLabelText(/New Password/i)).toBeInTheDocument(); + }); + + it("closes and shows a transient success indicator when updatePassword succeeds", async () => { + mockUpdatePassword.mockResolvedValue(); + renderComponent(); + const dialog = await openDialog(); + + fireEvent.change(dialog.getByLabelText(/New Password/i), { + target: { value: "a-long-enough-password" }, + }); + fireEvent.change(dialog.getByLabelText(/Confirm Password/i), { + target: { value: "a-long-enough-password" }, + }); + fireEvent.click(dialog.getByRole("button", { name: /^Change Password$/i })); + + await waitFor(() => expect(screen.getByText("Success")).toBeInTheDocument()); + // The dialog's own close is a separate async transition from the submit + // -- wait for the field to actually leave the DOM rather than asserting + // synchronously against the exit animation's in-between state. + await waitFor(() => + expect(screen.queryByLabelText(/New Password/i)).not.toBeInTheDocument() + ); + }); +}); diff --git a/__tests__/confirm-dialog.test.jsx b/__tests__/confirm-dialog.test.jsx new file mode 100644 index 0000000..0f5d9b5 --- /dev/null +++ b/__tests__/confirm-dialog.test.jsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import "@testing-library/jest-dom"; +import { system } from "../lib/theme"; +import ConfirmDialog from "../components/ui/ConfirmDialog"; + +// A thin controlled wrapper, the same shape every real caller uses +// (Dialog.Root's open/onOpenChange contract), so the dialog can be opened, +// closed, and reopened the way DeleteAccount/ProviderConnections drive it. +function Harness({ onConfirm, destructive }) { + const [open, setOpen] = useState(true); + return ( + <> + <button onClick={() => setOpen(true)}>Reopen</button> + <ConfirmDialog + open={open} + onOpenChange={(e) => setOpen(e.open)} + title="Disconnect Google Drive?" + confirmLabel="Disconnect" + destructive={destructive} + onConfirm={onConfirm} + > + <p>3 experiments are currently sending data to Google Drive.</p> + </ConfirmDialog> + </> + ); +} + +function renderDialog(props) { + return render( + <ChakraProvider value={system}> + <Harness {...props} /> + </ChakraProvider> + ); +} + +describe("ConfirmDialog", () => { + it("renders the title, body, and both actions", () => { + renderDialog({ onConfirm: jest.fn() }); + + expect(screen.getByText("Disconnect Google Drive?")).toBeInTheDocument(); + expect( + screen.getByText(/3 experiments are currently sending data/i) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Disconnect" }) + ).toBeInTheDocument(); + }); + + it("Cancel is always the neutral outline button, never brandTeal solid", () => { + renderDialog({ onConfirm: jest.fn() }); + const cancel = screen.getByRole("button", { name: "Cancel" }); + // Chakra v3 recipes resolve variant/colorPalette into data attributes + // rather than literal class names, so assert on those rather than on + // computed colors (jsdom does not run the CSS engine). + expect(cancel).not.toHaveAttribute("data-colorPalette", "brandTeal"); + }); + + it("calls onConfirm and closes on success", async () => { + const onConfirm = jest.fn(() => Promise.resolve()); + renderDialog({ onConfirm }); + + fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); + + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.queryByText("Disconnect Google Drive?")).not.toBeInTheDocument() + ); + }); + + it("keeps the dialog open and shows error.message when onConfirm throws", async () => { + const onConfirm = jest.fn(() => + Promise.reject(new Error("Could not reach DataPipe. Check your connection and try again.")) + ); + renderDialog({ onConfirm }); + + fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); + + expect( + await screen.findByText(/Could not reach DataPipe/i) + ).toBeInTheDocument(); + // Still open: the title and Cancel button are still on screen. + expect(screen.getByText("Disconnect Google Drive?")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }); + + it("clears a previous error when reopened", async () => { + const onConfirm = jest.fn(() => Promise.reject(new Error("Something went wrong."))); + renderDialog({ onConfirm }); + + fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); + await screen.findByText("Something went wrong."); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByText("Disconnect Google Drive?")).not.toBeInTheDocument() + ); + + fireEvent.click(screen.getByRole("button", { name: "Reopen" })); + // Chakra's Dialog (Ark UI/zag-js underneath) opens through its own state + // machine, asynchronously relative to the click that triggers it, so + // this is awaited like any other async UI update. + expect(await screen.findByText("Disconnect Google Drive?")).toBeInTheDocument(); + expect(screen.queryByText("Something went wrong.")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/provider-connections.test.jsx b/__tests__/provider-connections.test.jsx index 9011937..bc755bd 100644 --- a/__tests__/provider-connections.test.jsx +++ b/__tests__/provider-connections.test.jsx @@ -16,9 +16,16 @@ jest.mock("../lib/context", () => ({ }), })); +const mockGetDocs = jest.fn(() => + Promise.resolve({ docs: [] }) +); jest.mock("firebase/firestore", () => ({ doc: jest.fn(() => ({})), setDoc: jest.fn(() => Promise.resolve()), + collection: jest.fn(() => ({})), + query: jest.fn(() => ({})), + where: jest.fn(() => ({})), + getDocs: (...args) => mockGetDocs(...args), })); jest.mock("react-firebase-hooks/firestore", () => ({ @@ -40,6 +47,8 @@ beforeEach(() => { jest.clearAllMocks(); mockGetIdToken.mockClear(); mockGetIdToken.mockImplementation(() => Promise.resolve("id-token-123")); + mockGetDocs.mockClear(); + mockGetDocs.mockResolvedValue({ docs: [] }); global.fetch = jest.fn(); localStorage.clear(); @@ -184,12 +193,15 @@ describe("ProviderConnections", () => { expect(screen.getByLabelText(/Dataverse server URL/i)).toBeInTheDocument(); }); - it("8. connected: shows Connected status + Disconnect; click posts to disconnectprovider", async () => { + it("8. connected: shows Connected status; Disconnect opens a confirmation dialog, and confirming posts to disconnectprovider", async () => { useDocumentData.mockReturnValue([ { connectedAccounts: { gdrive: true } }, false, undefined, ]); + mockGetDocs.mockResolvedValue({ + docs: [{ data: () => ({ storageProvider: "gdrive" }) }], + }); global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }), @@ -198,7 +210,18 @@ describe("ProviderConnections", () => { renderComponent(); expect(screen.getByText(/Connected/i)).toBeInTheDocument(); + + // Clicking Disconnect only opens the confirmation dialog -- no request + // yet, and the row button is neutral now (red is reserved for + // irreversible actions), not the confirm button inside the dialog. fireEvent.click(screen.getByRole("button", { name: /^Disconnect Google Drive$/i })); + expect(global.fetch).not.toHaveBeenCalled(); + + expect( + await screen.findByText(/1 experiment is set up to send data to Google Drive/i) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^Disconnect$/i })); await waitFor(() => expect(global.fetch).toHaveBeenCalled()); const [url, options] = global.fetch.mock.calls[0]; @@ -209,4 +232,53 @@ describe("ProviderConnections", () => { idToken: "id-token-123", }); }); + + it("disconnect dialog: zero experiments shows the reassuring zero-count copy", async () => { + useDocumentData.mockReturnValue([ + { connectedAccounts: { gdrive: true } }, + false, + undefined, + ]); + mockGetDocs.mockResolvedValue({ docs: [] }); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /^Disconnect Google Drive$/i })); + + expect( + await screen.findByText(/No experiments are currently using this connection/i) + ).toBeInTheDocument(); + }); + + it( + "disconnect dialog: a failed disconnect stays open and shows the error", + async () => { + useDocumentData.mockReturnValue([ + { connectedAccounts: { gdrive: true } }, + false, + undefined, + ]); + mockGetDocs.mockResolvedValue({ docs: [] }); + global.fetch.mockResolvedValue({ ok: false }); + + renderComponent(); + fireEvent.click(screen.getByRole("button", { name: /^Disconnect Google Drive$/i })); + await screen.findByText(/No experiments are currently using this connection/i); + + fireEvent.click(screen.getByRole("button", { name: /^Disconnect$/i })); + + expect( + await screen.findByText(/Could not reach DataPipe/i) + ).toBeInTheDocument(); + // Dialog is still open -- Cancel is still on screen. + expect(screen.getByRole("button", { name: /^Cancel$/i })).toBeInTheDocument(); + }, + // This test's full round trip -- open, resolve the count query, submit, + // reject the fetch, re-render the error inside the dialog -- occasionally + // outran Jest's default 5000ms per-test budget under a heavily parallel + // full-suite run (many worker processes contending for CPU), even though + // it passes quickly in isolation. The component isn't slow; the CI + // scheduler sometimes is, so this test gets more room rather than being + // left flaky. + 15000 + ); }); diff --git a/components/account/ChangePassword.js b/components/account/ChangePassword.js index 5065ea8..680649c 100644 --- a/components/account/ChangePassword.js +++ b/components/account/ChangePassword.js @@ -1,5 +1,4 @@ -import { useState, useContext } from "react"; -import { UserContext } from "../../lib/context"; +import { useEffect, useState } from "react"; import { HStack, @@ -9,18 +8,51 @@ import { Dialog, Field, Input, + Alert, + CloseButton, } from "@chakra-ui/react"; import { auth } from "../../lib/firebase"; import { updatePassword } from "firebase/auth"; +import { messageForAuthError } from "../../lib/auth-errors"; export default function ChangePassword() { - const { user } = useContext(UserContext); const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [submitStatus, setSubmitStatus] = useState(null); // "success" | "failure" | null + // Gates the two validation messages on the field having been visited, not + // just on its value -- otherwise "Password must be at least 12 characters" + // renders in red the instant the dialog opens, before a single keystroke. + const [touchedPassword, setTouchedPassword] = useState(false); + const [touchedConfirm, setTouchedConfirm] = useState(false); + const [submitStatus, setSubmitStatus] = useState(null); // "success" | null + const [error, setError] = useState(""); + + // Reopening is a fresh attempt, not a continuation of whatever was left on + // screen last time: an old error, an old "Success" tick, or a stale + // touched-field flag must not greet the researcher before they have typed + // anything this time. + useEffect(() => { + if (open) { + setPassword(""); + setConfirmPassword(""); + setTouchedPassword(false); + setTouchedConfirm(false); + setSubmitStatus(null); + setError(""); + } + }, [open]); + + // "Success" next to the button is only useful for the few seconds after a + // change -- left on screen forever it reads as a permanent status rather + // than a one-time confirmation. Reopening the dialog also clears it (above), + // so this timeout only matters for someone who leaves the page open. + useEffect(() => { + if (submitStatus !== "success") return; + const timer = setTimeout(() => setSubmitStatus(null), 4000); + return () => clearTimeout(timer); + }, [submitStatus]); // Derived during render, not mirrored into state by an effect. Both are pure // functions of the two fields above, so storing them separately only created @@ -31,6 +63,25 @@ export default function ChangePassword() { const passwordMatch = password === confirmPassword; const passwordLengthSatisfied = password.length >= 12; + const handleChangePassword = async () => { + setIsSubmitting(true); + setError(""); + try { + await updatePassword(auth.currentUser, password); + setSubmitStatus("success"); + setOpen(false); + } catch (err) { + // Dialog stays open on failure -- closing it here would discard the + // one thing that lets the researcher fix the problem: what they + // already typed. auth/requires-recent-login is the likeliest failure + // for a twice-a-year visitor, and a bare "Failed" gave them nothing to + // act on. + setError(messageForAuthError(err?.code, null, "changePassword")); + } finally { + setIsSubmitting(false); + } + }; + return ( <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> <Text fontSize={"lg"}>Password</Text> @@ -38,9 +89,6 @@ export default function ChangePassword() { {submitStatus === "success" && ( <Text fontSize="sm" color="green.400">Success</Text> )} - {submitStatus === "failure" && ( - <Text fontSize="sm" color="red.400">Failed</Text> - )} <Button loading={isSubmitting} onClick={() => setOpen(true)} colorPalette="brandTeal"> Change Password </Button> @@ -49,33 +97,47 @@ export default function ChangePassword() { <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content bg="greyBackground" color="white"> + <Dialog.CloseTrigger asChild> + <CloseButton size="sm" aria-label="Close" /> + </Dialog.CloseTrigger> <Dialog.Header>Change Password</Dialog.Header> - <Dialog.CloseTrigger /> <Dialog.Body> <VStack gap={4}> <Field.Root id="new-password" - invalid={!passwordLengthSatisfied} + invalid={touchedPassword && !passwordLengthSatisfied} > <Field.Label>New Password</Field.Label> <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} + onBlur={() => setTouchedPassword(true)} /> <Field.ErrorText> Password must be at least 12 characters </Field.ErrorText> </Field.Root> - <Field.Root id="confirm-password" invalid={!passwordMatch}> + <Field.Root + id="confirm-password" + invalid={touchedConfirm && !passwordMatch} + > <Field.Label>Confirm Password</Field.Label> <Input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} + onBlur={() => setTouchedConfirm(true)} /> <Field.ErrorText>Passwords do not match</Field.ErrorText> </Field.Root> + + {error && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{error}</Text> + </Alert.Root> + )} </VStack> </Dialog.Body> <Dialog.Footer> @@ -83,7 +145,7 @@ export default function ChangePassword() { variant={"solid"} colorPalette={"brandTeal"} size={"md"} - onClick={() => handleChangePassword(password, setIsSubmitting, setOpen, setSubmitStatus)} + onClick={handleChangePassword} loading={isSubmitting} disabled={!passwordMatch || !passwordLengthSatisfied} > @@ -96,19 +158,3 @@ export default function ChangePassword() { </HStack> ); } - -async function handleChangePassword(newPassword, setIsSubmitting, setOpen, setSubmitStatus) { - setIsSubmitting(true); - const user = auth.currentUser; - - try { - await updatePassword(user, newPassword); - setIsSubmitting(false); - setOpen(false); - setSubmitStatus("success"); - } catch (error) { - setIsSubmitting(false); - setOpen(false); - setSubmitStatus("failure"); - } -} diff --git a/components/account/DeleteAccount.js b/components/account/DeleteAccount.js index 2f7b379..88d15b8 100644 --- a/components/account/DeleteAccount.js +++ b/components/account/DeleteAccount.js @@ -1,17 +1,11 @@ import { useState } from "react"; -import { - HStack, - VStack, - Button, - Text, - Alert, - Dialog, -} from "@chakra-ui/react"; +import { HStack, VStack, Button, Text } from "@chakra-ui/react"; import { auth } from "../../lib/firebase"; import { useRouter } from "next/router"; +import ConfirmDialog from "../ui/ConfirmDialog"; // Deletion runs server-side (functions/src/delete-account.ts) rather than // through deleteUser() here. The client SDK can only delete the auth record, @@ -20,13 +14,16 @@ import { useRouter } from "next/router"; // off the uid, and destroying the account first means a failed cleanup strands // them with no owner and no way to sign back in and retry. export default function DeleteAccount({ setDeleting }) { - const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); - const [deleteError, setDeleteError] = useState(null); const router = useRouter(); const deleteAccount = async function () { - setIsSubmitting(true); + // `setDeleting(true)` has to land before the request can possibly fail + // or succeed. AccountPage passes `deleting` straight into AuthCheck's + // fallbackRoute, so a sign-out that lands mid-request -- ours below on + // success, or an unrelated one -- resolves to the "your account is gone" + // page instead of dumping the researcher back at the sign-in form. + setDeleting(true); try { // Not forced: a refreshed token carries the same auth_time, so it would // not get past the endpoint's recent-login check anyway. @@ -51,71 +48,40 @@ export default function DeleteAccount({ setDeleting }) { await auth.signOut().catch(() => {}); router.push("/admin/deleted-account"); } catch (error) { + // Not deleted after all -- undo the redirect-on-signout wiring above so + // an unrelated sign-out (or none at all) leaves the researcher on this + // page, where ConfirmDialog now shows `error` and keeps the dialog open. setDeleting(false); - setIsSubmitting(false); - setDeleteError(error.message); + throw error; } }; return ( <VStack w="100%" align="stretch" gap={3}> - {deleteError && ( - <Alert.Root status="error" borderRadius="md"> - <Alert.Indicator /> - <Text fontSize="sm">{deleteError}</Text> - </Alert.Root> - )} - <HStack justifyContent="space-between" w="100%" flexWrap="wrap" gap={3}> <Text fontSize={"lg"}>Delete DataPipe Account</Text> - <Button - loading={isSubmitting} - onClick={() => setOpen(true)} - colorPalette="red" - > + <Button onClick={() => setOpen(true)} colorPalette="red"> Delete Account </Button> - <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> - <Dialog.Backdrop /> - <Dialog.Positioner> - <Dialog.Content bg="greyBackground" color="white"> - <Dialog.Header fontSize="lg" fontWeight="bold"> - Delete Account - </Dialog.Header> - - <Dialog.Body> - <Text mb={4}> - Are you sure? This action is final. We cannot recover any - experiments that are associated with this account after - deletion. - </Text> - <Text> - Deleting your DataPipe account will not affect any data - already written to your storage provider. - </Text> - </Dialog.Body> - - <Dialog.Footer> - <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> - Cancel - </Button> - <Button - colorPalette="red" - onClick={() => { - setDeleting(true); - setOpen(false); - setDeleteError(null); - deleteAccount(); - }} - ml={3} - > - Delete - </Button> - </Dialog.Footer> - </Dialog.Content> - </Dialog.Positioner> - </Dialog.Root> + <ConfirmDialog + open={open} + onOpenChange={(e) => setOpen(e.open)} + title="Delete Account" + confirmLabel="Delete" + destructive + onConfirm={deleteAccount} + > + <Text mb={4}> + Are you sure? This action is final. We cannot recover any + experiments that are associated with this account after + deletion. + </Text> + <Text> + Deleting your DataPipe account will not affect any data + already written to your storage provider. + </Text> + </ConfirmDialog> </HStack> </VStack> ); diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index 523c27e..68d7854 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -1,6 +1,22 @@ -import { useContext, useState } from "react"; -import { HStack, VStack, Text, Button, Alert, Badge } from "@chakra-ui/react"; -import { linkWithPopup, unlink } from "firebase/auth"; +import { useContext, useEffect, useState } from "react"; +import { + HStack, + VStack, + Text, + Button, + Alert, + Badge, + Dialog, + Field, + Input, + CloseButton, +} from "@chakra-ui/react"; +import { + EmailAuthProvider, + linkWithCredential, + linkWithPopup, + unlink, +} from "firebase/auth"; import { CircleCheck } from "lucide-react"; import { UserContext } from "../../lib/context"; import { auth } from "../../lib/firebase"; @@ -148,7 +164,7 @@ export default function LinkedAccounts() { ); })} - {hasPassword && ( + {hasPassword ? ( <HStack justifyContent="space-between" w="100%"> <HStack> <Text fontSize="lg">Email and password</Text> @@ -156,7 +172,150 @@ export default function LinkedAccounts() { </HStack> <Badge colorPalette="gray">Enabled</Badge> </HStack> + ) : ( + // ORCID lets researchers keep their email private (see the + // providesEmail comment in lib/auth-providers.js), and a password + // credential has to be built from a real email address. An + // ORCID-only account with no disclosed email has nothing to attach + // a password to, so render nothing rather than a button that can + // only fail. + user.email && ( + <AddPasswordRow user={user} setAfterAction={setAfterAction} /> + ) )} </VStack> ); } + +// Lets a researcher whose only sign-in methods are federated add an +// email/password fallback, using the email address Firebase already has on +// file for them (there is nowhere else on this page to type a different one, +// and EmailAuthProvider.credential needs a real address to attach the +// password to). +function AddPasswordRow({ user, setAfterAction }) { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + // Same touched-gating as ChangePassword: no red validation text before the + // researcher has typed anything. + const [touchedPassword, setTouchedPassword] = useState(false); + const [touchedConfirm, setTouchedConfirm] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (open) { + setPassword(""); + setConfirmPassword(""); + setTouchedPassword(false); + setTouchedConfirm(false); + setError(""); + } + }, [open]); + + const passwordMatch = password === confirmPassword; + // Mirrors ChangePassword's 12-character rule -- same Firebase project, + // same policy. Not shared as a constant: each dialog is small enough that + // the duplication costs less than a new module for one number. + const passwordLengthSatisfied = password.length >= 12; + + const handleAddPassword = async () => { + setIsSubmitting(true); + setError(""); + try { + const credential = EmailAuthProvider.credential(user.email, password); + const result = await linkWithCredential(auth.currentUser, credential); + // Same tagging discipline as handleLink above: linking mutates + // providerData in place without re-emitting an auth state, so the + // returned user is recorded here, tagged with its uid, and takes + // precedence over the derived read. + setAfterAction({ + uid: result.user.uid, + ids: linkedProviderIds(result.user), + }); + setOpen(false); + } catch (err) { + setError(messageForAuthError(err?.code, "password", "setPassword")); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <HStack justifyContent="space-between" w="100%"> + <Text fontSize="lg">Email and password</Text> + <Button colorPalette="brandTeal" size="sm" onClick={() => setOpen(true)}> + Add password + </Button> + + <Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content bg="greyBackground" color="white"> + <Dialog.CloseTrigger asChild> + <CloseButton size="sm" aria-label="Close" /> + </Dialog.CloseTrigger> + <Dialog.Header>Add a Password</Dialog.Header> + <Dialog.Body> + <VStack gap={4} align="stretch"> + <Text fontSize="sm" color="gray.400"> + This adds a password to sign in as {user.email}, alongside + the methods you already use. + </Text> + <Field.Root + invalid={touchedPassword && !passwordLengthSatisfied} + > + <Field.Label>New Password</Field.Label> + <Input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + onBlur={() => setTouchedPassword(true)} + /> + <Field.ErrorText> + Password must be at least 12 characters + </Field.ErrorText> + </Field.Root> + <Field.Root invalid={touchedConfirm && !passwordMatch}> + <Field.Label>Confirm Password</Field.Label> + <Input + type="password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + onBlur={() => setTouchedConfirm(true)} + /> + <Field.ErrorText>Passwords do not match</Field.ErrorText> + </Field.Root> + + {error && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{error}</Text> + </Alert.Root> + )} + </VStack> + </Dialog.Body> + <Dialog.Footer> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button + colorPalette="brandTeal" + onClick={handleAddPassword} + loading={isSubmitting} + disabled={!passwordMatch || !passwordLengthSatisfied} + ml={3} + > + Add Password + </Button> + </Dialog.Footer> + </Dialog.Content> + </Dialog.Positioner> + </Dialog.Root> + </HStack> + ); +} diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 2de561d..5ac2b7d 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -1,20 +1,35 @@ import { useContext, useState } from "react"; -import { VStack, HStack, Text, Button, Input, Field } from "@chakra-ui/react"; -import { doc } from "firebase/firestore"; +import { VStack, HStack, Text, Button, Input, Field, Alert } from "@chakra-ui/react"; +import { collection, doc, getDocs, query, where } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; import { useDocumentData } from "react-firebase-hooks/firestore"; import { UserContext } from "../../lib/context"; import { STORAGE_PROVIDERS } from "../../lib/provider-config"; +import ConfirmDialog from "../ui/ConfirmDialog"; import { CircleCheck } from "lucide-react"; +// Generic fetch-failure copy for handleConnect/handleDisconnect. Unlike +// messageForError below -- which decodes Dataverse's specific rejection +// reasons -- these two hit internal DataPipe endpoints with no researcher- +// actionable detail to translate, so one message covers "the request never +// landed." +const NETWORK_ERROR_MESSAGE = + "Could not reach DataPipe. Check your connection and try again."; + export default function ProviderConnections() { const { user } = useContext(UserContext); - const [data] = useDocumentData(doc(db, "users", user.uid)); + const [data] = useDocumentData(user?.uid ? doc(db, "users", user.uid) : null); const [connectingId, setConnectingId] = useState(null); - const [disconnectingId, setDisconnectingId] = useState(null); + + // Component-level failure surface for handleConnect, which has no dialog + // of its own to show an error in (renders the same way LinkedAccounts.js + // reports its own link/unlink failures). handleDisconnect does NOT use + // this -- it always runs inside the disconnect ConfirmDialog below, which + // already surfaces a thrown error in context; see the comment there. + const [error, setError] = useState(""); // Static-token providers have no redirect flow: clicking Connect opens an // inline form instead of navigating away. Only one can be open at a time. @@ -23,6 +38,13 @@ export default function ProviderConnections() { const [apiToken, setApiToken] = useState(""); const [formError, setFormError] = useState(null); + // Disconnect is guarded by a confirmation dialog naming the actual + // consequence (see countExperiments below), so `confirmProviderId` is which + // provider's dialog is open rather than an immediate action. + const [confirmProviderId, setConfirmProviderId] = useState(null); + const [experimentCount, setExperimentCount] = useState(null); // null = loading + const [countFailed, setCountFailed] = useState(false); + const openTokenForm = (providerId) => { setTokenFormId(providerId); setServerUrl(""); @@ -95,7 +117,7 @@ export default function ProviderConnections() { closeTokenForm(); } catch (err) { console.error("Failed to connect provider:", err); - setFormError("Could not reach DataPipe. Check your connection and try again."); + setFormError(NETWORK_ERROR_MESSAGE); } finally { setConnectingId(null); } @@ -103,6 +125,7 @@ export default function ProviderConnections() { const handleConnect = async (providerId) => { setConnectingId(providerId); + setError(""); try { const stateRes = await fetch("/api/generateoauthstate", { method: "POST", @@ -118,13 +141,65 @@ export default function ProviderConnections() { window.location.assign(authorizeUrl); } catch (err) { console.error("Failed to initiate provider connect:", err); + setError(NETWORK_ERROR_MESSAGE); } finally { setConnectingId(null); } }; + // Counts experiments this researcher owns that are wired to `providerId`, + // so the disconnect dialog can name the actual consequence instead of a + // vague warning. One Firestore query (by owner uid, which is already + // covered by an index every other experiments query relies on) filtered by + // provider in JS -- no composite index, no denormalized counter to keep in + // sync. Fetched fresh each time the dialog opens, not cached and not + // fetched on page load, because the number is only ever needed right + // before this one decision. + const countExperiments = async (providerId) => { + try { + const q = query(collection(db, "experiments"), where("owner", "==", user.uid)); + const snapshot = await getDocs(q); + const count = snapshot.docs.filter( + (d) => d.data().storageProvider === providerId + ).length; + setExperimentCount(count); + } catch (err) { + console.error("Failed to count experiments for provider:", err); + setCountFailed(true); + } + }; + + const openDisconnectDialog = (providerId) => { + setConfirmProviderId(providerId); + setExperimentCount(null); + setCountFailed(false); + countExperiments(providerId); + }; + + // Body copy for the disconnect dialog. Consequence before mechanism: say + // what happens to the researcher's experiments before anything about the + // connection itself. Falls back to the generic warning if the count + // couldn't be fetched -- silence would be worse than an imprecise number. + const disconnectDialogBody = (provider) => { + if (countFailed) { + return `Any experiment currently sending data to ${provider.name} will stop receiving new data. Data already written stays there.`; + } + if (experimentCount === null) { + return "Checking which experiments use this connection..."; + } + if (experimentCount === 0) { + return "No experiments are currently using this connection. You can reconnect at any time."; + } + // "Set up to send", not "currently sending": the count deliberately + // includes experiments whose collection is paused, because disconnecting + // breaks those too the moment they are re-enabled. The copy must not + // claim less than the count covers. + const noun = experimentCount === 1 ? "experiment is" : "experiments are"; + return `${experimentCount} ${noun} set up to send data to ${provider.name}. Disconnecting stops them from receiving new data. Data already written to ${provider.name} stays there.`; + }; + const handleDisconnect = async (providerId) => { - setDisconnectingId(providerId); + setError(""); try { const idToken = await auth.currentUser.getIdToken(); const response = await fetch("/api/disconnectprovider", { @@ -137,17 +212,28 @@ export default function ProviderConnections() { }), }); if (!response.ok) { - throw new Error("Failed to disconnect provider"); + throw new Error(NETWORK_ERROR_MESSAGE); } } catch (err) { console.error("Failed to disconnect provider:", err); - } finally { - setDisconnectingId(null); + // Rethrow rather than also setting the page-level `error` state below: + // this always runs inside ConfirmDialog's onConfirm, which already + // keeps itself open and renders error.message right next to the + // Disconnect button that failed. Setting both would print the same + // sentence twice -- once in the dialog, once on the page behind it. + throw err; } }; return ( <VStack gap={3} w="100%" align="stretch"> + {error && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{error}</Text> + </Alert.Root> + )} + {Object.values(STORAGE_PROVIDERS).map((provider) => { const connected = provider.isConnected(data); const isStaticToken = provider.authMethod === "static-token"; @@ -173,13 +259,15 @@ export default function ProviderConnections() { )} </HStack> {connected ? ( + // Neutral, not red: disconnecting is reversible (reconnect any + // time), unlike the Danger Zone's account deletion. Red is + // reserved for actions that cannot be undone, so it keeps + // meaning where it actually matters. <Button - colorPalette="red" variant="outline" size="md" aria-label={`Disconnect ${provider.name}`} - loading={disconnectingId === provider.id} - onClick={() => handleDisconnect(provider.id)} + onClick={() => openDisconnectDialog(provider.id)} > Disconnect </Button> @@ -262,6 +350,20 @@ export default function ProviderConnections() { </VStack> ); })} + + {confirmProviderId && ( + <ConfirmDialog + open={!!confirmProviderId} + onOpenChange={(e) => { + if (!e.open) setConfirmProviderId(null); + }} + title={`Disconnect ${STORAGE_PROVIDERS[confirmProviderId].name}?`} + confirmLabel="Disconnect" + onConfirm={() => handleDisconnect(confirmProviderId)} + > + <Text>{disconnectDialogBody(STORAGE_PROVIDERS[confirmProviderId])}</Text> + </ConfirmDialog> + )} </VStack> ); } diff --git a/components/account/SelectAuth.js b/components/account/SelectAuth.js index 31747f4..42f49db 100644 --- a/components/account/SelectAuth.js +++ b/components/account/SelectAuth.js @@ -9,6 +9,8 @@ import { Input, Spinner, Center, + Alert, + CloseButton, } from "@chakra-ui/react" import { useContext, useState, useRef } from "react"; import { UserContext } from "../../lib/context"; @@ -28,8 +30,14 @@ export default function SelectAuth() { const [isTokenOpen, setIsTokenOpen] = useState(false); const [isSubmittingToken, setIsSubmittingToken] = useState(false); + const [tokenError, setTokenError] = useState(null); const tokenRef = useRef(null); + const openTokenDialog = () => { + setTokenError(null); + setIsTokenOpen(true); + }; + const handleSwitchToPersonalToken = () => { setDoc(doc(db, "users", user.uid), { usingPersonalToken: true, @@ -45,6 +53,7 @@ export default function SelectAuth() { const handleSaveToken = async () => { const token = tokenRef.current?.value; setIsSubmittingToken(true); + setTokenError(null); try { const idToken = await auth.currentUser.getIdToken(); const response = await fetch("/api/saveosftoken", { @@ -56,11 +65,23 @@ export default function SelectAuth() { body: JSON.stringify({ token }), }); if (!response.ok) { - throw new Error("Failed to save token"); + throw new Error( + response.status === 401 || response.status === 403 + ? "You are not signed in to the right account. Try reloading the page and signing in again." + : "Could not save your OSF token. Please try again." + ); } - setIsSubmittingToken(false); setIsTokenOpen(false); } catch (error) { + // Dialog stays open (it already did -- this just makes the + // failure visible instead of leaving the researcher watching the + // spinner stop with nothing to show for it). + setTokenError( + error instanceof TypeError + ? "Could not reach DataPipe. Check your connection and try again." + : error.message + ); + } finally { setIsSubmittingToken(false); } } @@ -109,7 +130,7 @@ export default function SelectAuth() { </HStack> <Button colorPalette="brandTeal" - onClick={() => setIsTokenOpen(true)} + onClick={openTokenDialog} loading={isSubmittingToken} > Set OSF Token @@ -131,8 +152,10 @@ export default function SelectAuth() { <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content bg="greyBackground" color="white"> + <Dialog.CloseTrigger asChild> + <CloseButton size="sm" aria-label="Close" /> + </Dialog.CloseTrigger> <Dialog.Header>Set OSF Personal Access Token</Dialog.Header> - <Dialog.CloseTrigger /> <Dialog.Body> <VStack gap={4} w="100%"> <Text> @@ -160,6 +183,13 @@ export default function SelectAuth() { </Field.Root> </VStack> )} + + {tokenError && ( + <Alert.Root status="error" borderRadius="md"> + <Alert.Indicator /> + <Text fontSize="sm">{tokenError}</Text> + </Alert.Root> + )} </VStack> </Dialog.Body> <Dialog.Footer> diff --git a/components/ui/ConfirmDialog.js b/components/ui/ConfirmDialog.js new file mode 100644 index 0000000..e94c55e --- /dev/null +++ b/components/ui/ConfirmDialog.js @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, CloseButton, Dialog, Text } from "@chakra-ui/react"; + +/** + * Confirmation dialog for consequential actions (disconnecting a provider, + * deleting an account, ...). Controlled, the same shape as every other + * Dialog.Root on this page: the caller owns `open` and reacts to + * `onOpenChange`, e.g. `onOpenChange={(e) => setOpen(e.open)}`. + * + * Props: + * - open, onOpenChange: controlled visibility, Chakra Dialog.Root shape. + * - title: dialog heading. + * - children: dialog body -- the consequence copy the caller composes. + * - confirmLabel: text on the confirm button. + * - destructive: red solid confirm for actions that cannot be undone; + * brandTeal solid (the default) for actions that are consequential but + * reversible. Keep red scarce -- it only means something in the Danger + * Zone if routine actions do not also wear it. + * - onConfirm: may be async. While it is pending the confirm button shows + * a loading state and Cancel is disabled (no closing out from under an + * in-flight request). If it throws, the dialog stays OPEN and + * `error.message` renders inside it -- the researcher's context (what + * they were about to do, what they typed) survives the failure instead + * of being thrown away with a closed dialog and a mystery toast. On + * success the dialog closes itself. + * + * Cancel is ALWAYS the neutral button (variant="outline", no colorPalette) -- + * never brandTeal solid. A solid green Cancel next to a solid red Confirm + * reads as the button to press, which is backwards: walking away is supposed + * to be the free, obvious choice, not competing for attention with the + * action that has a consequence. + */ +export default function ConfirmDialog({ + open, + onOpenChange, + title, + children, + confirmLabel = "Confirm", + destructive = false, + onConfirm, +}) { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + // Reopening is a fresh attempt, not a continuation of whatever failed last + // time -- an error left over from a previous open must not greet the + // researcher before they have done anything this time. + useEffect(() => { + if (open) setError(null); + }, [open]); + + const handleConfirm = async () => { + setIsPending(true); + setError(null); + try { + await onConfirm(); + onOpenChange({ open: false }); + } catch (err) { + setError(err?.message || "Something went wrong. Please try again."); + } finally { + setIsPending(false); + } + }; + + return ( + <Dialog.Root open={open} onOpenChange={onOpenChange}> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content bg="greyBackground" color="white"> + <Dialog.CloseTrigger asChild> + <CloseButton size="sm" aria-label="Close" disabled={isPending} /> + </Dialog.CloseTrigger> + + <Dialog.Header fontSize="lg" fontWeight="bold"> + {title} + </Dialog.Header> + + <Dialog.Body> + {children} + {error && ( + <Alert.Root status="error" borderRadius="md" mt={4}> + <Alert.Indicator /> + <Text fontSize="sm">{error}</Text> + </Alert.Root> + )} + </Dialog.Body> + + <Dialog.Footer> + <Button + variant="outline" + onClick={() => onOpenChange({ open: false })} + disabled={isPending} + > + Cancel + </Button> + <Button + colorPalette={destructive ? "red" : "brandTeal"} + variant="solid" + loading={isPending} + onClick={handleConfirm} + ml={3} + > + {confirmLabel} + </Button> + </Dialog.Footer> + </Dialog.Content> + </Dialog.Positioner> + </Dialog.Root> + ); +} diff --git a/lib/auth-errors.js b/lib/auth-errors.js index 1a63c7e..a6688ce 100644 --- a/lib/auth-errors.js +++ b/lib/auth-errors.js @@ -29,6 +29,20 @@ const FALLBACK = { signIn: (name) => `Could not complete ${name} sign-in. Please try again.`, link: (name) => `Could not link your ${name} account. Please try again.`, unlink: (name) => `Could not unlink your ${name} account. Please try again.`, + changePassword: () => "Could not change your password. Please try again.", + setPassword: () => "Could not add a password to your account. Please try again.", +}; + +// auth/requires-recent-login means "Firebase wants a fresh session before +// this specific sensitive change," and the fix a researcher needs to hear is +// the name of the change they were making, not a generic phrase about +// sign-in methods. Keyed by mode so DeleteAccount, ChangePassword, and the +// new set-password flow each say the thing the researcher was actually +// trying to do. +const REQUIRES_RECENT_LOGIN = { + changePassword: "For security, sign out and sign back in, then change your password.", + setPassword: "For security, sign out and sign back in, then add a password.", + default: "For security, please sign out and sign back in before changing your sign-in methods.", }; export function messageForAuthError( @@ -49,6 +63,14 @@ export function messageForAuthError( // `owner: uid` and moving them between accounts is a maintainer // operation -- so the copy has to say that rather than imply a retry // will help. + if (mode === "setPassword") { + // The researcher isn't choosing an email here -- it's already fixed + // to their account's own address, so there is no "use a different + // email" escape hatch to offer. This only fires if a different + // Firebase user was somehow already registered with a password under + // the same address. + return "This email address is already registered to a different DataPipe account. Contact us through the Contact page if this looks wrong."; + } return mode === "link" ? `Your ${providerName} account's email address already belongs to a different DataPipe account, so it can't be linked to this one. Sign out and sign in to that account instead, or link a ${providerName} account that uses a different email address. If you need two accounts combined, get in touch through the Contact page.` : `An account already exists with this email address. Sign in using the method you set up originally, then add ${providerName} from your account settings.`; @@ -73,7 +95,7 @@ export function messageForAuthError( return "Could not reach the authentication service. Check your connection and try again."; case "auth/requires-recent-login": - return "For security, please sign out and sign back in before changing your sign-in methods."; + return REQUIRES_RECENT_LOGIN[mode] || REQUIRES_RECENT_LOGIN.default; default: return (FALLBACK[mode] || FALLBACK.signIn)(providerName); From f6f7fae3e4feea01b1ae7c819aae4c204288dd3f Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 12:37:54 -0400 Subject: [PATCH 103/181] =?UTF-8?q?docs:=20DESIGN.md=20=E2=80=94=20dual-mo?= =?UTF-8?q?de=20design=20contract;=20version=20the=20|>=20logo=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md is forward-looking: the target for the opt-in light/dark migration, not a capture of the forced-dark present. Every color decision carries a computed WCAG ratio in both modes. Notable findings baked in: the current primary button (brandTeal.600 solid + white) measures 4.04:1 — a live AA failure in both modes — and gray.500 (3.43:1) is retired as a text color. Includes mode strategy (dark default until page conversion completes, toggle ships last), a ranked Phase 2 conversion inventory, typography and spacing rules, color semantics (one primary, red = irreversible only, blue retired), component contracts, and codebase- specific bans. docs/brand/logo/ versions the accepted logo handoff (direction 3b): the |> mark as production SVGs with optically-corrected favicon reductions, plus the geometry/color source of truth. Ratified follow-ups tracked as tasks: the app adopts the logo's green (#2E7D32 ramp) as its primary, and the navbar becomes mark + Space Grotesk wordmark. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- DESIGN.md | 354 ++++++++++++++++++++++++++ docs/brand/logo/README.md | 113 ++++++++ docs/brand/logo/favicon-16.svg | 5 + docs/brand/logo/favicon-32.svg | 6 + docs/brand/logo/icon-tile-dark.svg | 6 + docs/brand/logo/icon-tile-light.svg | 6 + docs/brand/logo/icon-tile-outline.svg | 6 + docs/brand/logo/mark-dark.svg | 5 + docs/brand/logo/mark-light.svg | 5 + docs/brand/logo/mark-mono.svg | 5 + 10 files changed, 511 insertions(+) create mode 100644 DESIGN.md create mode 100644 docs/brand/logo/README.md create mode 100644 docs/brand/logo/favicon-16.svg create mode 100644 docs/brand/logo/favicon-32.svg create mode 100644 docs/brand/logo/icon-tile-dark.svg create mode 100644 docs/brand/logo/icon-tile-light.svg create mode 100644 docs/brand/logo/icon-tile-outline.svg create mode 100644 docs/brand/logo/mark-dark.svg create mode 100644 docs/brand/logo/mark-light.svg create mode 100644 docs/brand/logo/mark-mono.svg diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..5aa1de9 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,354 @@ +# DESIGN.md — DataPipe design contract + +Forward-looking. Not a description of what the app looks like today; the target every +design task converges on. Where this document and the code disagree, the code is wrong. +Read `PRODUCT.md` first — this exists to serve it. + +**Brand constants, non-negotiable:** `brandTeal #13b24b`, `brandOrange #f78f1e`, +`brandRed #ee4523`, the dark surface `#1C1F22`, the plain jsPsych-adjacent aesthetic. +Register is *product*: earned familiarity, the tool disappears into the task. + +**The headline change:** DataPipe moves from a forced-dark theme to an opt-in +light/dark mode. Every color below is specified for both modes with a computed WCAG 2.1 +ratio. Body text ≥4.5:1, large text and non-text UI boundaries ≥3:1 — **in both +modes**, against the *worst* surface the token is allowed to sit on. + +--- + +## 1. Theme architecture + +Neutral ramp stays Chakra v3's default `gray` (zinc: `200 #e4e4e7`, `300 #d4d4d8`, +`400 #a1a1aa`, `500 #71717a`, `600 #52525b`, `700 #3f3f46`, `800 #27272a`). The dark +column is the measured work already in `lib/theme.js`, corrected where it failed. + +**Light surface direction:** a cool, near-neutral off-white — *not* the cream / +warm-sand body that reads as 2026 AI-default. Page `#F5F7F8` carries a 3-point +channel spread toward the same cool slate hue as `#1C1F22`; panels are true white. +Light-mode ink is `#1C1F22` itself: the dark surface color becomes the light text +color, so the brand's one distinctive neutral is present in both modes. + +### Surfaces + +| Token | Light | Dark | Use | +|---|---|---|---| +| `bg` | `#F5F7F8` | `#1C1F22` | Page body | +| `bg.subtle` | `#EBEFF1` | `#16191B` | Recessed: code blocks, table headers | +| `bg.panel` | `#FFFFFF` | `#1C1F22` | Cards, dialogs, menus. Dark stays flat — delineated by `border`, not elevation | +| `bg.muted` | `#E1E6E9` | `#2A2F34` | Hover / selected fills | + +Page↔panel separation is 1.07:1 in both modes. That is intentional and it means +**panels must carry a `border`** — never rely on the fill alone to define an edge. + +### Foreground (all three levels clear 4.5:1 on every surface above) + +| Token | Light | Dark | Worst-case ratio (light / dark) | +|---|---|---|---| +| `fg` | `#1C1F22` | `gray.50 #fafafa` | 13.16 / 12.94 (on `bg.muted`) | +| `fg.muted` | `gray.700 #3f3f46` | `gray.300 #d4d4d8` | 8.30 / 9.14 (on `bg.muted`) | +| `fg.subtle` | `gray.600 #52525b` | `gray.400 #a1a1aa` | 6.15 / 5.27 (on `bg.muted`) | + +`gray.500` is **retired as a text color.** It measures 3.43:1 on `#1C1F22` and is the +source of the account page's `SectionLabel` and "use personal access token" failures. +It survives as a border value only. + +### Borders + +| Token | Light | Dark | Ratio vs `bg` | Use | +|---|---|---|---|---| +| `border` | `gray.500 #71717a` | `gray.500 #71717a` | 4.50 / 3.43 | Inputs, outline buttons, panel edges, table rules — anything WCAG 1.4.11 covers | +| `border.subtle` | `gray.300 #d4d4d8` | `#3F4449` | 1.38 / 1.68 | Decorative hairlines *inside* an already-grouped region. Never the only grouping device | + +One value serves both modes. `whiteAlpha.200` (1.92:1) is **banned**. + +### Palettes + +Each `colorPalette` supplies `fg` (text on `bg`/`bg.panel`), `subtle`+`muted` (tinted +fills), `solid`+`contrast` (filled controls), `border`, `focusRing`. + +**brandTeal** — the primary action color. + +| Slot | Light | ratio | Dark | ratio | +|---|---|---|---|---| +| `fg` | `700 #0B7230` | 5.24 (on `bg.subtle`) | `300 #58D183` | 6.99 (on `bg.muted`) | +| `solid` | `700 #0B7230` | fill 5.64 vs page | `500 #13b24b` | fill 5.91 vs page | +| `contrast` | `white` | **6.06** on solid | `#1C1F22` | **5.91** on solid | +| `subtle` (bg) | `50 #E8F9EE` | text `800` → 8.57 | `900 #043216` | text `300` → 7.37 | +| `border` | `600 #0E923D` | 3.76 | `400 #2CC35E` | 7.15 | +| `focusRing` | `600 #0E923D` | 3.21 (worst, on `bg.muted`) | `400 #2CC35E` | 5.84 | + +> `#13b24b` on white is **2.80:1**. It can never be light-mode text, and never a +> light-mode solid fill under white text. Today's `solid: brandTeal.600` + `white` +> contrast is **4.04:1 — a live AA failure in both modes.** Fix: light flips to `700`; +> dark flips the *text* to `#1C1F22` on the bright `500` fill. + +**brandOrange** — warning / attention only. + +| Slot | Light | ratio | Dark | ratio | +|---|---|---|---|---| +| `fg` | `800 #7C4606` | 6.63 | `300 #FFB74D` | 7.80 | +| `subtle` (bg) | `50 #FFF3E0` | text `800` → 7.00 | `900 #3E2303` | text `gray.200` → 11.44 | +| `border` | `700 #A85F08` | 4.54 | `400 #FFA726` | 8.52 | + +**`brandOrange` has no `solid`.** Every orange dark enough to hold white text +(`700` = 4.88) has stopped being the brand orange. Orange is a status hue, never a +button fill. + +**brandRed** — irreversible destruction only. + +| Slot | Light | ratio | Dark | ratio | +|---|---|---|---|---| +| `fg` | `700 #A82E16` | 5.92 | `300 #F17761` | 4.86 (on `bg.muted`) | +| `solid` | `700 #A82E16` | fill 6.37 | `600 #D13A1B` | fill 3.42 | +| `contrast` | `white` | 6.85 | `white` | 4.85 | +| `subtle` (bg) | `50 #FDE8E4` | text `800` → 8.30 | `900 #4A1509` | text `gray.200` → 11.80 | +| `border` | `600 #D13A1B` | 4.51 | `400 #EF5A3E` | 4.89 | + +**gray** — neutral controls (the default `colorPalette`). + +| Slot | Light | Dark | +|---|---|---| +| `fg` | `800 #27272a` (13.86) | `200 #e4e4e7` (13.05) | +| `solid` / `contrast` | `800` / `gray.50` → 14.27 | `200` / `gray.900` → 13.96 | +| `subtle` / `muted` / `emphasized` | `100` / `200` / `300` | `800` / `700` / `600` | +| `border` | `500` (4.50) | `500` (3.43) | + +**Status** aliases onto the brand hues — one green, not two: +`ok = brandTeal`, `warning = brandOrange`, `error = brandRed`, `neutral = fg.muted` +(neutral; **no blue**). `brandLime` is legacy and should be deleted once +`JsPsychIcon` is confirmed to be its only consumer. + +--- + +## 2. Mode strategy + +**Preference is three-way: `system` / `light` / `dark`.** Stored by `next-themes` in +`localStorage` under `datapipe-color-mode`, applied as a class on `<html>` +(`attribute="class"`), read by Chakra v3's `_light` / `_dark` token conditions. +`next-themes`' inline script must run before paint so there is no flash. Device-local: +no server round-trip, no Firestore field. + +```jsx +// pages/_app.js +<ThemeProvider attribute="class" defaultTheme="dark" + enableSystem={false} storageKey="datapipe-color-mode" + disableTransitionOnChange> + <ChakraProvider value={system}>…</ChakraProvider> +</ThemeProvider> +``` + +**Default: `dark`, with `enableSystem={false}`, until the conversion completes.** +Justification: roughly 40 `color="white"`, 16 `bg="greyBackground"`, 17 `whiteAlpha.*` +and 4 `bg="black"` are spread across 17 files, plus `globalCss` and `globals.css` +force the body dark unconditionally. Honoring the OS preference before those are +converted means a light-preferring researcher gets white-on-white navigation on the +page they were *blocked into* — a worse outcome than a dark theme they didn't choose. +Flip to `defaultTheme="system"` + `enableSystem` as the final step of Phase 3. + +**Toggle placement:** a three-item radio group ("System / Light / Dark") inside the +existing navbar **Account menu**, above Settings. Not a floating sun/moon icon — this +is a twice-a-year tool and an unlabeled icon violates "assume no recall". Signed-out +visitors get the same control from the mobile/overflow menu. + +### Migration phases + +**Phase 1 — token foundation.** Diverge every `_light`/`_dark` pair in `lib/theme.js` +(today all 30+ semantic tokens set both sides identically). Delete `globalCss.body`, +`globalCss.label`, `globalCss.input`. Move `html, body` color/background out of +`globals.css` into the theme. Every new component from this point consumes semantic +tokens only. + +**Phase 2 — page-by-page conversion.** Ranked by damage: + +| # | File | What breaks | +|---|---|---| +| 1 | `pages/index.js` | ~53 hits: a ~40-literal syntax-highlight array, `bg="gray.950/900/800"` terminal chrome, `bg="black"` section | +| 2 | `components/Navbar.js` | 17× `color="white"`, 9× `bg="greyBackground"`, 2× `borderColor="white"`, 4× `whiteAlpha.300` | +| 3 | `pages/getting-started.js` | `bg="black"`, `color="white"`, 7× `gray.400` | +| 4 | `pages/admin/index.js` | 2× `bg="black"`, dialog `bg="greyBackground" color="white"` | +| 5 | `components/dashboard/CodeHints.js` | 4× `greyBackground`/`white`, 7× `gray.400` | +| 6 | `pages/oauth2/{connect,callback}.js` | `color="white"`, `bg="red.800"`, 3× `colorPalette="blue"` | +| 7 | `components/Footer.js` | `bg="greyBackground"`, 4× `gray.300`, `borderColor="white"` | +| 8 | `pages/admin/[experiment_id].js` | 4× `whiteAlpha.200` separators, `blue.500` link | +| 9 | `dashboard/{Title,ExperimentInfo}.js`, `admin/account.js`, `account/*` dialogs | `color="white"`, `whiteAlpha.*` separators | +| 10 | `{CodeBlock,CopyButton,SignInForm}.js`, `{signup,reset-password,api-docs}.js` | `color="white"`, `bg="gray.800"` | + +Already clean, leave alone: `contact.js`, `redirect.js`, `admin/deleted-account.js`, +`dashboard/ErrorPanel.js`, `AuthCheck.js`, `Loader.js`, `TestEnvironmentWarning.js`, +`auth/AuthProviderButtons.js`, `account/OsfRelinkButton.js`. Third-party brand SVGs +keep their literal hexes — but `AuthProviderIcons.js:35` `fill="#FFF"` disappears on a +light background and needs a `currentColor` fix. `styles/Home.module.css` is imported +nowhere; delete it rather than migrate it. + +**Phase 3 — ship the toggle.** Only after Phase 2 clears. Light mode must never +render a half-converted page. Then flip the default to `system`. + +--- + +## 3. Typography + +One family. Body, headings, labels, buttons and data all run on the existing system +stack (`-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, …`). **Rubik stays +logo-only** — the wordmark in `Navbar.js` and the `index.js` hero. It is not +introduced anywhere else; a display face in UI labels is a product-register ban, and +a second webfont costs a load for no legibility gain. + +Fixed rem scale, tight ratio, four roles: + +| Role | Size / weight | Color | Notes | +|---|---|---|---| +| Page title | `2xl` (24px) / 700 | `fg` | One per page. Sentence case | +| Section heading | `lg` (18px) / 600 | `fg` | A real `<Heading as="h2">`, sentence case | +| Body | `md` (16px) / 400 | `fg` | Default. Prose measure 65–75ch | +| Supporting | `sm` (14px) / 400 | `fg.muted` | Descriptions, hints, help lines | +| Fine print | `xs` (12px) / 400 | `fg.muted` | Timestamps, IDs. **Never** `fg.subtle` at this size | + +**Sentence case everywhere**, including buttons. `textTransform="uppercase"` + +`letterSpacing="wide"` micro-labels are banned — see §8. Today's codebase is +`fontSize="sm"` 74×, `xs` 19×: a page whose default text is 14px is a page with no +body size. Let 16px be the default and reserve `sm` for genuinely secondary text. + +--- + +## 4. Spacing & layout rhythm + +- **Settings / single-subject content column: `maxW="560px"`.** Confirmed keep — a + correct, confident measure for a scanned page. Dashboard and marketing pages use + `maxW="1100px"`; consolidate the stray `540px` / `960px` / `440px` onto these two. +- Spacing scale: Chakra's 4px base. Use `2 / 3 / 4 / 6 / 8 / 12 / 16` and nothing + else. Within a row: `gap={3}`. Within a section: `gap={4}`. Between routine + sections: `mt={10}`. Before a consequential section (Danger Zone, anything + destructive): `mt={16}`. +- **Spacing carries grouping.** More air means "different subject, higher stakes." + A page whose sections are all `my={6}` apart is telling the reader nothing. + +**Separator policy — committed:** *spacing-only grouping between sections.* Remove +every `<Separator>` from `pages/admin/account.js` (4×) and +`pages/admin/[experiment_id].js` (4×). A hairline at 1.27:1 is not a weak separator, +it is an absent one, and raising it to a visible 3:1 rule between five sections makes +a settings page look like a spreadsheet. Separators survive only *inside* dense +repeating structures — table row rules, menu group dividers — where they use `border` +or `border.subtle` and are not the primary grouping device. Grouping that spacing +cannot carry alone gets a bordered container (see `SettingsSection` danger variant). + +--- + +## 5. Color semantics + +- **`brandTeal` is the primary action color, app-wide. One primary per screen.** + Every other action on that screen is `outline` or `ghost` on `gray`. +- **`blue` is retired as an action color.** Five `colorPalette="blue"` and five raw + `blue.500` links remain (`ProviderConnections.js:188,247`, `SelectAuth.js:120`, + `OAuthTokenStatus.js:92`, both `oauth2/*` pages, `QueuePanel.js` status map). + Links become `brandTeal.fg`; secondary buttons become neutral outline. +- **`brandRed` is exclusively for irreversible destruction** — account deletion, + experiment deletion. Routine, reversible actions (disconnect a provider, unlink a + sign-in method) are **neutral outline**. Red that means "routine" cannot also mean + "final". +- **Status trio** `ok` / `warning` / `error` (+ `neutral`), values in §1. **Status + is never color-alone or icon-alone: a visible text label is mandatory**, always + rendered, never behind a tooltip or `title`. Non-text status marks still clear 3:1. +- **Focus ring:** `2px solid {colorPalette}.focusRing` with a `2px` offset, on *every* + interactive element including icon-only and link-styled controls. Default palette + ring is `brandTeal.focusRing` (3.21:1 worst case light, 5.84:1 dark). + `focusRing="none"` — currently on four `Navbar` links — is banned. +- **Semantic z-index scale.** `globals.css:54` has the app's only z-index, an + arbitrary `1000`. Replace with theme tokens and use nothing else: + + | Token | Value | Use | + |---|---|---| + | `docked` | 10 | Sticky table headers | + | `dropdown` | 1000 | Menus, popovers, selects | + | `sticky` | 1100 | Sticky page chrome | + | `banner` | 1200 | `.sticky-alert` / `TestEnvironmentWarning` | + | `modal.backdrop` | 1300 | Dialog backdrop | + | `modal` | 1400 | Dialog content | + | `toast` | 1500 | Transient notifications | + | `tooltip` | 1600 | Tooltips (decoration only — never meaning) | + +--- + +## 6. Component inventory + +Shared primitives live in `components/ui/`. Every interactive one ships all seven +states — default, hover, focus, active, disabled, loading, error — or it does not +ship. + +**Being built now:** + +- **`SettingsSection`** — a real `<h2>` (`lg`/600/`fg`), optional one-line description + in `sm`/`fg.muted` that says what the section is and what depends on it, and the + section body. `variant="danger"` wraps the body in a `1px border.brandRed` container + with `p={5}` and `rounded="md"`. Replaces `SectionLabel` entirely. +- **`StatusIndicator`** — `status` (`ok`/`warning`/`error`/`neutral`) plus a + **mandatory visible `label`**. Icon + text, always both, always rendered. No tooltip + variant exists, so `OAuthTokenStatus`'s hover-only state cannot be reproduced. + Replaces all three of the account page's competing status renderings. +- **`FormErrorAlert`** — the single form-error surface. Takes a human message (mapped + through `lib/auth-errors.js`, never a raw Firebase code), renders on + `brandRed.subtle` with `brandRed.fg` text and `role="alert"`. One pattern for the + three error paths that currently disagree. +- **`ConfirmDialog`** — async `onConfirm` with a loading state on the confirm button; + failures are caught and surfaced **inside the dialog via `FormErrorAlert`**, and the + dialog stays open. **Cancel is always neutral** (`variant="outline"`, + `colorPalette="gray"`) and is the default-focused control; the confirm button + carries the destructive palette. The green solid "Cancel" in `DeleteAccount.js:100` + is the exact shape this bans. + +**Anticipated for the wider pass:** + +- **`PageHeader`** — page title, optional one-sentence purpose line, optional back + link. Every page gets one; `/admin/account` currently has a bare `Heading` and no + route back to the dashboard. +- **`EmptyState`** — a heading, one sentence of what goes here and why, and the single + primary action. Text only: no illustration, no mascot, no emoji. +- **`GuidanceLine`** — the standard help line under a section heading: `sm`/`fg.muted`, + consequence before mechanism, with an inline link to `/getting-started` or `/faq` + where one exists. Shown at zero-state as well as one-state. + +--- + +## 7. Motion + +Minimal and purposeful. Motion conveys state change, feedback, loading, or reveal — +nothing else. No scroll-jacking, no parallax, no bounce or spring easing, no +orchestrated page-load sequences. + +- Duration `150–200ms`; easing `ease-out` only (`cubic-bezier(0, 0, 0.2, 1)`). +- Color-mode switches do **not** animate (`disableTransitionOnChange`). +- **Every animation needs a `prefers-reduced-motion` story.** The `.loader` spinner + (`globals.css:26–42`) has none. Its fallback: under + `@media (prefers-reduced-motion: reduce)` drop the `spin` animation, leaving a static + ring plus a visible `aria-live` "Loading…" label — the label is required regardless of + motion preference. Its `white` / `darkblue` borders become `border` / `brandTeal.solid`. +- Skeletons over centered spinners for content loading in place; spinners are for + actions, not regions. + +--- + +## 8. Anti-patterns — codebase-specific bans + +1. **Uppercase tracked eyebrow micro-labels.** `pages/admin/account.js:21–34` + `SectionLabel` renders five of them at `xs` + `uppercase` + `letterSpacing="wide"` + + `gray.500` (3.43:1) — the least legible configuration available, and an eyebrow on + every section is scaffolding by reflex. Real headings, sentence case. +2. **Icon-only or color-only status.** `OAuthTokenStatus` hides its state behind a + hover tooltip: unreachable on touch, unreliable by keyboard, unannounced. +3. **Meaning in `title=` or a tooltip on a disabled control.** + `LinkedAccounts.js:129` explains why unlinking is blocked in a `title` attribute on + a disabled button. The explanation goes in visible text next to the control. +4. **Green / primary-solid cancel buttons.** `DeleteAccount.js:100`. Cancel is neutral; + the loud button is never the safe one. +5. **Raw hex or raw palette steps in components.** `color="white"`, + `bg="greyBackground"`, `bg="black"`, `whiteAlpha.*`, `gray.400`, `blue.500` — tokens + only. Third-party brand SVGs are the sole exception. +6. **Arbitrary z-index values.** `globals.css:54` `z-index: 1000`. Use the §5 scale. +7. **Silent catch blocks that swallow user-facing failures.** `handleConnect` and + `handleDisconnect` in `ProviderConnections.js` end in `console.error` with nothing + rendered; `ChangePassword` discards `auth/requires-recent-login` and shows the word + "Failed". PRODUCT.md principle 5 is "no silent failures" — every failed action + surfaces a mapped, human message through `FormErrorAlert`. +8. **`focusRing="none"`.** Four instances in `Navbar.js`. Keyboard operability is not + optional. +9. **Validation errors before first input.** Gate on touched/dirty, not on value. +10. **Modal as first thought.** Exhaust inline and progressive disclosure first; + dialogs are for confirming consequences, not for holding forms that fit on a page. diff --git a/docs/brand/logo/README.md b/docs/brand/logo/README.md new file mode 100644 index 0000000..3122dd8 --- /dev/null +++ b/docs/brand/logo/README.md @@ -0,0 +1,113 @@ +# Handoff: DataPipe logo (pipe.jspsych.org) + +## Overview +Identity for **DataPipe**, the data-collection service at pipe.jspsych.org. The mark is the R/base pipe +operator `|>` rendered as pure geometry: one vertical bar plus an open chevron, with an echoed second +chevron in a lighter green. It exists as a horizontal lockup (mark + wordmark), a square icon tile, and +favicon-optimised reductions. + +The selected direction is **3b (Echo)**. Earlier explorations (turns 1–3) remain in the design file for +context but are **not** part of this handoff — implement 3b only. + +## About the Design Files +The files in this bundle are **design references created in HTML** — a prototype showing intended look and +behaviour, not production code to copy directly. The task is to **recreate the mark in the target +environment** (mkdocs theme, React site, README, etc.) using its established patterns. The SVGs in +`assets/` ARE production-ready and can ship as-is; the `.dc.html` file is a presentation board only. + +## Fidelity +**High-fidelity.** Colours, geometry and proportions are final. All SVG path data below is the source of +truth — do not re-draw by eye. + +## The mark + +### Geometry (canonical 104 × 104 grid) +Every element is a stroke or rect at a **single shared weight of 10 units**. Never mix weights. + +| Element | Path / rect | Notes | +| --- | --- | --- | +| Bar (`\`|\``) | `<rect x="10" y="12" width="10" height="80" rx="1">` | Spans y 12→92 | +| Chevron 1 (`>`) | `M32 26 L62 52 L32 78` | Spans y 26→78 | +| Chevron 2 (echo) | `M62 26 L92 52 L62 78` | Starts exactly where chevron 1 apex lands | + +Stroke attributes on both chevrons: `stroke-width="10"`, `stroke-linecap="square"`, +`stroke-linejoin="round"`, `fill="none"`. + +**Two rules that must survive any resize:** +1. **The chevron is never filled.** A solid triangle reads as a play button. It is two strokes, always. +2. **The bar overshoots the chevrons** top and bottom (y 12→92 vs 26→78, ≈ 14 units each end). This + matches how `|` sits taller than `>` in a monospace font. Do not align them. + +### Colour +| Role | Light bg | Dark bg | +| --- | --- | --- | +| Bar + chevron 1 | `#2E7D32` | `#F2F5F1` | +| Chevron 2 (echo) | `#8BC34A` | `#8BC34A` | +| Inside a green tile | `#FFFFFF` bar/chevron 1, `#A5D66A` echo | — | + +The echo green `#8BC34A` is deliberately the **same value in both modes** — it is the only tone that holds +contrast against both `#FFFFFF` and `#101A14`. Do not darken it for light mode. + +## Lockup +Horizontal only. Mark on the left, two-line text block on the right, `gap: 24px`, vertically centred. + +- Line 1 — wordmark: `DataPipe`, Space Grotesk 600, `font-size: 34px`, `letter-spacing: -0.03em`, + `line-height: 1`, colour `#16211B` (light) / `#F2F5F1` (dark). +- Line 2 — URL: `pipe.jspsych.org`, IBM Plex Mono 400, `font-size: 12px`, `letter-spacing: 0.04em`, + colour `#6B7A70` (light) / `#8FA294` (dark). +- Gap between the two lines: `3px`. +- Mark height = 104px at this wordmark size; scale the pair together. + +Clear space: **one bar-width (10 units at 104 grid ≈ 10% of mark height)** on all sides. + +## Icon tile (96 × 96 grid) +`rx="14"` (`rx="12"` at 32px, `rx="8"` at 16px). Bar `x=14 y=16 w=11 h=64`; chevron 1 +`M36 24 L60 48 L36 72`; chevron 2 `M60 24 L84 48 L60 72`; stroke-width 11. + +Variants shipped: `icon-tile-light` (green fill), `icon-tile-dark` (`#1C2A22` fill), +`icon-tile-outline` (`#3A4C41` 5px frame, no fill). + +## Reductions — IMPORTANT +The mark is **not** uniformly scaled for favicons. Optical corrections: + +- **32px** (`favicon-32.svg`): stroke 12, chevrons widened to `M37 23 L61 48 L37 73` and + `M61 23 L85 48 L61 73`. The notch must stay ≥ 2× stroke width or it fills in. +- **16px** (`favicon-16.svg`): **chevron 1 is dropped entirely.** Bar `x=18 y=14 w=14 h=68` plus a single + green chevron `M44 20 L74 48 L44 76` at stroke 14. Two chevrons mush at this size. + +Ship the 16px asset for `≤ 20px` and the 32px asset for `21–48px`; above that, use the full tile. + +## Optional animation (site header only) +An earlier direction (3c) pulses an orange `#FB8C00` dot clearing the chevron apex, 2.2s ease-in-out, +opacity 0.25 → 1 → 0.25. **Not part of 3b** — implement only if asked. If any animation is added, respect +`prefers-reduced-motion: reduce` and fall back to the static mark. + +## Design tokens +``` +--dp-green: #2E7D32 /* primary — matches jsPsych docs Material green */ +--dp-green-mid: #43A047 /* used in earlier explorations, not in 3b */ +--dp-green-light: #8BC34A /* the echo chevron; identical in light + dark */ +--dp-green-tile: #A5D66A /* echo when knocked out of a solid green tile */ +--dp-ink: #16211B /* wordmark on light */ +--dp-ink-deep: #101A14 /* dark-mode page background */ +--dp-tile-dark: #1C2A22 /* dark-mode icon tile fill */ +--dp-paper: #F2F5F1 /* mark + wordmark on dark */ +--dp-muted: #6B7A70 /* URL line on light */ +--dp-muted-dark: #8FA294 /* URL line on dark */ +--dp-rule-dark: #3A4C41 /* outline-tile stroke */ +--dp-accent: #FB8C00 /* animation dot only */ + +stroke-weight: 10 / 104 grid (11 / 96 tile, 12 @32px, 14 @16px) +radius: 14 / 96 tile (12 @32px, 8 @16px) +``` + +Typography: **Space Grotesk** 600 (wordmark), **IBM Plex Mono** 400/500 (URL, code voice). Both Google Fonts. + +## Assets +`assets/` — eight standalone SVGs, no external deps, no embedded fonts (the wordmark is live text, not +outlines, so set it in HTML/CSS rather than baking it into the SVG). `mark-mono.svg` uses +`currentColor` for single-colour contexts (paper figures, laser-cut stickers, favicons in mask mode). + +## Files +- `assets/*.svg` — production assets, ship these. +- `DataPipe Logo.dc.html` — the full exploration board (turns 1–3). Reference only; open in a browser. diff --git a/docs/brand/logo/favicon-16.svg b/docs/brand/logo/favicon-16.svg new file mode 100644 index 0000000..99d5402 --- /dev/null +++ b/docs/brand/logo/favicon-16.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"> + <rect width="96" height="96" rx="8" fill="#2E7D32"></rect> + <rect x="18" y="14" width="14" height="68" fill="#FFFFFF"></rect> + <path d="M44 20 L74 48 L44 76" fill="none" stroke="#A5D66A" stroke-width="14" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/favicon-32.svg b/docs/brand/logo/favicon-32.svg new file mode 100644 index 0000000..024e134 --- /dev/null +++ b/docs/brand/logo/favicon-32.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"> + <rect width="96" height="96" rx="12" fill="#2E7D32"></rect> + <rect x="14" y="16" width="12" height="64" fill="#FFFFFF"></rect> + <path d="M37 23 L61 48 L37 73" fill="none" stroke="#FFFFFF" stroke-width="12" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M61 23 L85 48 L61 73" fill="none" stroke="#A5D66A" stroke-width="12" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/icon-tile-dark.svg b/docs/brand/logo/icon-tile-dark.svg new file mode 100644 index 0000000..8a609d5 --- /dev/null +++ b/docs/brand/logo/icon-tile-dark.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"> + <rect width="96" height="96" rx="14" fill="#1C2A22"></rect> + <rect x="14" y="16" width="11" height="64" fill="#F2F5F1"></rect> + <path d="M36 24 L60 48 L36 72" fill="none" stroke="#F2F5F1" stroke-width="11" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M60 24 L84 48 L60 72" fill="none" stroke="#8BC34A" stroke-width="11" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/icon-tile-light.svg b/docs/brand/logo/icon-tile-light.svg new file mode 100644 index 0000000..0fc17a8 --- /dev/null +++ b/docs/brand/logo/icon-tile-light.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"> + <rect width="96" height="96" rx="14" fill="#2E7D32"></rect> + <rect x="14" y="16" width="11" height="64" fill="#FFFFFF"></rect> + <path d="M36 24 L60 48 L36 72" fill="none" stroke="#FFFFFF" stroke-width="11" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M60 24 L84 48 L60 72" fill="none" stroke="#A5D66A" stroke-width="11" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/icon-tile-outline.svg b/docs/brand/logo/icon-tile-outline.svg new file mode 100644 index 0000000..26bea4a --- /dev/null +++ b/docs/brand/logo/icon-tile-outline.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"> + <rect x="2.5" y="2.5" width="91" height="91" rx="13" fill="none" stroke="#3A4C41" stroke-width="5"></rect> + <rect x="14" y="16" width="11" height="64" fill="currentColor"></rect> + <path d="M36 24 L60 48 L36 72" fill="none" stroke="currentColor" stroke-width="11" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M60 24 L84 48 L60 72" fill="none" stroke="#8BC34A" stroke-width="11" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/mark-dark.svg b/docs/brand/logo/mark-dark.svg new file mode 100644 index 0000000..56cca7a --- /dev/null +++ b/docs/brand/logo/mark-dark.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104" fill="none"> + <rect x="10" y="12" width="10" height="80" rx="1" fill="#F2F5F1"></rect> + <path d="M32 26 L62 52 L32 78" fill="none" stroke="#F2F5F1" stroke-width="10" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M62 26 L92 52 L62 78" fill="none" stroke="#8BC34A" stroke-width="10" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/mark-light.svg b/docs/brand/logo/mark-light.svg new file mode 100644 index 0000000..7b73489 --- /dev/null +++ b/docs/brand/logo/mark-light.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104" fill="none"> + <rect x="10" y="12" width="10" height="80" rx="1" fill="#2E7D32"></rect> + <path d="M32 26 L62 52 L32 78" fill="none" stroke="#2E7D32" stroke-width="10" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M62 26 L92 52 L62 78" fill="none" stroke="#8BC34A" stroke-width="10" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file diff --git a/docs/brand/logo/mark-mono.svg b/docs/brand/logo/mark-mono.svg new file mode 100644 index 0000000..68ea7c6 --- /dev/null +++ b/docs/brand/logo/mark-mono.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104" fill="none"> + <rect x="10" y="12" width="10" height="80" rx="1" fill="currentColor"></rect> + <path d="M32 26 L62 52 L32 78" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="square" stroke-linejoin="round"></path> + <path d="M62 26 L92 52 L62 78" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="square" stroke-linejoin="round"></path> +</svg> \ No newline at end of file From 9c0d78d2acbb50ea9991a11f540c48e9b74271c1 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 13:26:45 -0400 Subject: [PATCH 104/181] =?UTF-8?q?feat(theme):=20adopt=20the=20logo's=20g?= =?UTF-8?q?reen=20=E2=80=94=20brandGreen=20replaces=20brandTeal=20as=20pri?= =?UTF-8?q?mary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new |> mark's green #2E7D32 (Material Green 800) measured 1.83:1 against the old primary brandTeal #13b24b: close enough to read as a mistake, far enough to clash. Owner call: one green, and it's the logo's. brandGreen ships the Material Green ramp verbatim with semantic slots computed against the surface the app renders on (#1C1F22): fg 300 8.23:1, solid 500 with greyBackground contrast text (5.96:1 both axes — computed against dark-fill-plus-white 3.23/5.13 and better on both), border 400. The adoption also retires two standing AA failures for free: teal solid + white was 4.04:1, and #13b24b could never be light-mode text (2.80:1 on white) where #2E7D32 clears 5.13:1. brandTeal survives only as a deprecated alias mirroring brandGreen slot for slot — identical pixels — so the not-yet-renamed references (Navbar, index hero, in-progress docs pages) cannot break mid-transition. 31 refs across 15 files renamed; DESIGN.md §1 rebuilt for both modes with the subtle-variant caveat documented (green 300-on-900 is 3.91:1; text on brandGreen.subtle is explicitly 50 instead). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- DESIGN.md | 95 +++++++++--- __tests__/confirm-dialog.test.jsx | 4 +- components/AuthCheck.js | 2 +- components/SignInForm.js | 2 +- components/account/ChangePassword.js | 4 +- components/account/LinkedAccounts.js | 6 +- components/account/SelectAuth.js | 6 +- components/dashboard/ExperimentValidation.js | 4 +- components/dashboard/FinalizeControl.js | 2 +- components/ui/ConfirmDialog.js | 6 +- components/ui/StatusIndicator.js | 10 +- lib/theme.js | 146 ++++++++++++++++--- pages/admin/[experiment_id].js | 2 +- pages/admin/account.js | 2 +- pages/admin/index.js | 8 +- pages/admin/new.js | 8 +- pages/reset-password.js | 4 +- pages/signup.js | 2 +- 18 files changed, 237 insertions(+), 76 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5aa1de9..a9ae27e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,9 +4,17 @@ Forward-looking. Not a description of what the app looks like today; the target design task converges on. Where this document and the code disagree, the code is wrong. Read `PRODUCT.md` first — this exists to serve it. -**Brand constants, non-negotiable:** `brandTeal #13b24b`, `brandOrange #f78f1e`, -`brandRed #ee4523`, the dark surface `#1C1F22`, the plain jsPsych-adjacent aesthetic. -Register is *product*: earned familiarity, the tool disappears into the task. +**Brand constants, non-negotiable:** `brandGreen #2E7D32` (the logo's green — see +*Logo* below), `brandOrange #f78f1e`, `brandRed #ee4523`, the dark surface `#1C1F22`, +the plain jsPsych-adjacent aesthetic. Register is *product*: earned familiarity, the +tool disappears into the task. + +**`brandTeal #13b24b` is retired.** It measured 1.83:1 against the logo green — close +enough to read as a mistake rather than a pairing — and 2.80:1 on white, which barred +it from light mode entirely. `#2E7D32` is 5.13:1 on white and 4.77:1 on the `#F5F7F8` +page, so adopting the logo's green is also the fix for the teal's AA failures. A +transitional `brandTeal` alias in `lib/theme.js` resolves to the brandGreen values so +un-renamed references keep rendering; it is deleted once the rename completes. **The headline change:** DataPipe moves from a forced-dark theme to an opt-in light/dark mode. Every color below is specified for both modes with a computed WCAG 2.1 @@ -65,21 +73,46 @@ One value serves both modes. `whiteAlpha.200` (1.92:1) is **banned**. Each `colorPalette` supplies `fg` (text on `bg`/`bg.panel`), `subtle`+`muted` (tinted fills), `solid`+`contrast` (filled controls), `border`, `focusRing`. -**brandTeal** — the primary action color. +**brandGreen** — the primary action color. The ramp is **Material Green verbatim** +(`50 #E8F5E9`, `100 #C8E6C9`, `200 #A5D6A7`, `300 #81C784`, `400 #66BB6A`, +`500 #4CAF50`, `600 #43A047`, `700 #388E3C`, `800 #2E7D32`, `900 #1B5E20`), because +the logo green `#2E7D32` *is* Material Green 800 and the logo's own token sheet names +`#43A047` (600) as its mid step. The brand color sits on the ramp rather than near it, +and every step is a hand-tuned tone instead of an interpolation off one hex. | Slot | Light | ratio | Dark | ratio | |---|---|---|---|---| -| `fg` | `700 #0B7230` | 5.24 (on `bg.subtle`) | `300 #58D183` | 6.99 (on `bg.muted`) | -| `solid` | `700 #0B7230` | fill 5.64 vs page | `500 #13b24b` | fill 5.91 vs page | -| `contrast` | `white` | **6.06** on solid | `#1C1F22` | **5.91** on solid | -| `subtle` (bg) | `50 #E8F9EE` | text `800` → 8.57 | `900 #043216` | text `300` → 7.37 | -| `border` | `600 #0E923D` | 3.76 | `400 #2CC35E` | 7.15 | -| `focusRing` | `600 #0E923D` | 3.21 (worst, on `bg.muted`) | `400 #2CC35E` | 5.84 | - -> `#13b24b` on white is **2.80:1**. It can never be light-mode text, and never a -> light-mode solid fill under white text. Today's `solid: brandTeal.600` + `white` -> contrast is **4.04:1 — a live AA failure in both modes.** Fix: light flips to `700`; -> dark flips the *text* to `#1C1F22` on the bright `500` fill. +| `fg` | `800 #2E7D32` | 4.77 (on `bg`; 5.13 on `bg.panel`) | `300 #81C784` | 6.71 (on `bg.muted`) | +| `solid` | `800 #2E7D32` | fill 4.77 vs page | `500 #4CAF50` | fill 5.96 vs page | +| `contrast` | `white` | **5.13** on solid | `#1C1F22` | **5.96** on solid | +| `subtle` (bg) | `50 #E8F5E9` | text `900` → 7.00 | `900 #1B5E20` | text `50` → 7.00 | +| `border` | `700 #388E3C` | 3.83 | `400 #66BB6A` | 7.00 | +| `focusRing` | `700 #388E3C` | 3.27 (worst, on `bg.muted`) | `400 #66BB6A` | 5.71 (worst, on `bg.muted`) | + +Palette `fg` is text on `bg` / `bg.panel` only. On the light tinted neutrals it drops +to 4.43 (`bg.subtle`) and 4.08 (`bg.muted`), so a green label inside a recessed or +hover-filled region uses `fg` (the neutral), not `brandGreen.fg`. + +> **Superseded — the teal fix below is now moot; the green replaces it.** `#13b24b` on +> white was **2.80:1**: it could never be light-mode text, and never a light-mode solid +> fill under white text, and `solid: brandTeal.600` + `white` was **4.04:1 — a live AA +> failure in both modes.** The teal fix was to flip light to `700` and flip dark's +> *text* to `#1C1F22` on the bright `500` fill. Adopting the logo green retires the +> problem at its source instead: `#2E7D32` clears 4.5:1 on both light surfaces, so +> light-mode green text and a white-on-green solid are both legal for the first time. +> The dark-side flip survives on its merits — computed both ways on `#1C1F22`, a dark +> fill under white text (`800` + `white`) gives fill 3.23 / text 5.13, while the bright +> fill under dark text (`500` + `#1C1F22`) gives fill 5.96 / text 5.96, better on both +> axes. On a dark page a dark green button is a hole; the bright chip reads as a control. + +> **Caveat on `subtle`.** Chakra's `subtle` and `surface` variants paint +> `colorPalette.fg` on `colorPalette.subtle`, and in dark mode that pairing is +> `300` on `900` = **3.91:1**, under the body floor. Material Green 900 is a mid-dark +> green, not the near-black the hand-tuned teal 900 was, and the ramp has nothing +> darker. Text on `brandGreen.subtle` is therefore named explicitly (`50`, above), and +> `variant="subtle"` / `variant="surface"` on `brandGreen` is **not approved for body +> text** until a semantic pairing token exists. No call site uses either variant on +> this palette today. **brandOrange** — warning / attention only. @@ -113,10 +146,26 @@ button fill. | `border` | `500` (4.50) | `500` (3.43) | **Status** aliases onto the brand hues — one green, not two: -`ok = brandTeal`, `warning = brandOrange`, `error = brandRed`, `neutral = fg.muted` +`ok = brandGreen`, `warning = brandOrange`, `error = brandRed`, `neutral = fg.muted` (neutral; **no blue**). `brandLime` is legacy and should be deleted once `JsPsychIcon` is confirmed to be its only consumer. +### Logo + +The mark (`docs/brand/logo/README.md`) is the source of the brand green and is +authoritative over the ramp, not the other way round. + +| Role | Value | Where | +|---|---|---| +| Bar + chevron 1, light bg | `#2E7D32` | = `brandGreen.800`. The anchor | +| Bar + chevron 1, dark bg | `#F2F5F1` | 15.06 on `#1C1F22`. Not `fg` — the mark keeps its own paper white | +| Chevron 2 (echo) | `#8BC34A` | **Mark only.** Identical in both modes by design | + +**The echo green `#8BC34A` is never a UI color.** It is 2.10:1 on white and off the +Material Green ramp entirely — it exists because it is the one tone that holds against +both `#FFFFFF` and the logo's `#101A14`, inside a mark where it carries no meaning on +its own. It is never text, never a fill, never a border, never a status hue. + --- ## 2. Mode strategy @@ -234,12 +283,12 @@ cannot carry alone gets a bordered container (see `SettingsSection` danger varia ## 5. Color semantics -- **`brandTeal` is the primary action color, app-wide. One primary per screen.** +- **`brandGreen` is the primary action color, app-wide. One primary per screen.** Every other action on that screen is `outline` or `ghost` on `gray`. - **`blue` is retired as an action color.** Five `colorPalette="blue"` and five raw `blue.500` links remain (`ProviderConnections.js:188,247`, `SelectAuth.js:120`, `OAuthTokenStatus.js:92`, both `oauth2/*` pages, `QueuePanel.js` status map). - Links become `brandTeal.fg`; secondary buttons become neutral outline. + Links become `brandGreen.fg`; secondary buttons become neutral outline. - **`brandRed` is exclusively for irreversible destruction** — account deletion, experiment deletion. Routine, reversible actions (disconnect a provider, unlink a sign-in method) are **neutral outline**. Red that means "routine" cannot also mean @@ -249,7 +298,7 @@ cannot carry alone gets a bordered container (see `SettingsSection` danger varia rendered, never behind a tooltip or `title`. Non-text status marks still clear 3:1. - **Focus ring:** `2px solid {colorPalette}.focusRing` with a `2px` offset, on *every* interactive element including icon-only and link-styled controls. Default palette - ring is `brandTeal.focusRing` (3.21:1 worst case light, 5.84:1 dark). + ring is `brandGreen.focusRing` (3.27:1 worst case light, 5.71:1 dark). `focusRing="none"` — currently on four `Navbar` links — is banned. - **Semantic z-index scale.** `globals.css:54` has the app's only z-index, an arbitrary `1000`. Replace with theme tokens and use nothing else: @@ -290,9 +339,9 @@ ship. - **`ConfirmDialog`** — async `onConfirm` with a loading state on the confirm button; failures are caught and surfaced **inside the dialog via `FormErrorAlert`**, and the dialog stays open. **Cancel is always neutral** (`variant="outline"`, - `colorPalette="gray"`) and is the default-focused control; the confirm button - carries the destructive palette. The green solid "Cancel" in `DeleteAccount.js:100` - is the exact shape this bans. + `colorPalette="gray"`) and is the default-focused control; the confirm button carries + `brandRed` when `destructive`, `brandGreen` — the primary — otherwise. The green solid + "Cancel" in `DeleteAccount.js:100` is the exact shape this bans. **Anticipated for the wider pass:** @@ -319,7 +368,7 @@ orchestrated page-load sequences. (`globals.css:26–42`) has none. Its fallback: under `@media (prefers-reduced-motion: reduce)` drop the `spin` animation, leaving a static ring plus a visible `aria-live` "Loading…" label — the label is required regardless of - motion preference. Its `white` / `darkblue` borders become `border` / `brandTeal.solid`. + motion preference. Its `white` / `darkblue` borders become `border` / `brandGreen.solid`. - Skeletons over centered spinners for content loading in place; spinners are for actions, not regions. diff --git a/__tests__/confirm-dialog.test.jsx b/__tests__/confirm-dialog.test.jsx index 0f5d9b5..44dacfe 100644 --- a/__tests__/confirm-dialog.test.jsx +++ b/__tests__/confirm-dialog.test.jsx @@ -49,13 +49,13 @@ describe("ConfirmDialog", () => { ).toBeInTheDocument(); }); - it("Cancel is always the neutral outline button, never brandTeal solid", () => { + it("Cancel is always the neutral outline button, never brandGreen solid", () => { renderDialog({ onConfirm: jest.fn() }); const cancel = screen.getByRole("button", { name: "Cancel" }); // Chakra v3 recipes resolve variant/colorPalette into data attributes // rather than literal class names, so assert on those rather than on // computed colors (jsdom does not run the CSS engine). - expect(cancel).not.toHaveAttribute("data-colorPalette", "brandTeal"); + expect(cancel).not.toHaveAttribute("data-colorPalette", "brandGreen"); }); it("calls onConfirm and closes on success", async () => { diff --git a/components/AuthCheck.js b/components/AuthCheck.js index 0d5eaa3..63590e8 100644 --- a/components/AuthCheck.js +++ b/components/AuthCheck.js @@ -15,7 +15,7 @@ export default function AuthCheck({ children, fallback, fallbackRoute }) { }, [user, router, fallbackRoute]); if (loading || (user && !user.uid)) { - return <Center py={8}><Spinner size="lg" color="brandTeal.500" /></Center>; + return <Center py={8}><Spinner size="lg" color="brandGreen.500" /></Center>; } return (user && user.uid) diff --git a/components/SignInForm.js b/components/SignInForm.js index 08c3285..c8e2ee4 100644 --- a/components/SignInForm.js +++ b/components/SignInForm.js @@ -87,7 +87,7 @@ export default function SignInForm({ routeAfterSignIn }) { </Field.Root> <Button - colorPalette="brandTeal" + colorPalette="brandGreen" loading={isSubmitting} onClick={onSubmit} w="full" diff --git a/components/account/ChangePassword.js b/components/account/ChangePassword.js index 680649c..e759858 100644 --- a/components/account/ChangePassword.js +++ b/components/account/ChangePassword.js @@ -89,7 +89,7 @@ export default function ChangePassword() { {submitStatus === "success" && ( <Text fontSize="sm" color="green.400">Success</Text> )} - <Button loading={isSubmitting} onClick={() => setOpen(true)} colorPalette="brandTeal"> + <Button loading={isSubmitting} onClick={() => setOpen(true)} colorPalette="brandGreen"> Change Password </Button> </HStack> @@ -143,7 +143,7 @@ export default function ChangePassword() { <Dialog.Footer> <Button variant={"solid"} - colorPalette={"brandTeal"} + colorPalette={"brandGreen"} size={"md"} onClick={handleChangePassword} loading={isSubmitting} diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index 68d7854..986b71d 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -152,7 +152,7 @@ export default function LinkedAccounts() { </Button> ) : ( <Button - colorPalette="brandTeal" + colorPalette="brandGreen" size="sm" loading={pendingId === entry.id} onClick={() => handleLink(entry)} @@ -244,7 +244,7 @@ function AddPasswordRow({ user, setAfterAction }) { return ( <HStack justifyContent="space-between" w="100%"> <Text fontSize="lg">Email and password</Text> - <Button colorPalette="brandTeal" size="sm" onClick={() => setOpen(true)}> + <Button colorPalette="brandGreen" size="sm" onClick={() => setOpen(true)}> Add password </Button> @@ -304,7 +304,7 @@ function AddPasswordRow({ user, setAfterAction }) { Cancel </Button> <Button - colorPalette="brandTeal" + colorPalette="brandGreen" onClick={handleAddPassword} loading={isSubmitting} disabled={!passwordMatch || !passwordLengthSatisfied} diff --git a/components/account/SelectAuth.js b/components/account/SelectAuth.js index 42f49db..5cb5e50 100644 --- a/components/account/SelectAuth.js +++ b/components/account/SelectAuth.js @@ -86,7 +86,7 @@ export default function SelectAuth() { } } - if (loading) return <Center py={8}><Spinner size="lg" color="brandTeal.500" /></Center>; + if (loading) return <Center py={8}><Spinner size="lg" color="brandGreen.500" /></Center>; if (error) return <div>Error: {error.message}</div>; const usingPersonalToken = data?.usingPersonalToken; @@ -129,7 +129,7 @@ export default function SelectAuth() { {!hasValidPersonalToken && <TriangleAlert color="var(--chakra-colors-orange-500)" size={18} />} </HStack> <Button - colorPalette="brandTeal" + colorPalette="brandGreen" onClick={openTokenDialog} loading={isSubmittingToken} > @@ -195,7 +195,7 @@ export default function SelectAuth() { <Dialog.Footer> <Button variant="solid" - colorPalette="brandTeal" + colorPalette="brandGreen" size="md" onClick={handleSaveToken} loading={isSubmittingToken} diff --git a/components/dashboard/ExperimentValidation.js b/components/dashboard/ExperimentValidation.js index 7a5e478..e92978e 100644 --- a/components/dashboard/ExperimentValidation.js +++ b/components/dashboard/ExperimentValidation.js @@ -79,14 +79,14 @@ export default function ExperimentValidation({ data }) { }} > <Stack gap={5} direction="row"> - <Checkbox.Root value="json" colorPalette="brandTeal"> + <Checkbox.Root value="json" colorPalette="brandGreen"> <Checkbox.HiddenInput /> <Checkbox.Control> <Checkbox.Indicator /> </Checkbox.Control> <Checkbox.Label>Allow JSON</Checkbox.Label> </Checkbox.Root> - <Checkbox.Root value="csv" colorPalette="brandTeal"> + <Checkbox.Root value="csv" colorPalette="brandGreen"> <Checkbox.HiddenInput /> <Checkbox.Control> <Checkbox.Indicator /> diff --git a/components/dashboard/FinalizeControl.js b/components/dashboard/FinalizeControl.js index 999e02c..9228726 100644 --- a/components/dashboard/FinalizeControl.js +++ b/components/dashboard/FinalizeControl.js @@ -195,7 +195,7 @@ export default function FinalizeControl({ data, experimentId }) { </Dialog.Body> <Dialog.Footer> - <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> + <Button onClick={() => setOpen(false)} colorPalette="brandGreen"> Cancel </Button> <Button diff --git a/components/ui/ConfirmDialog.js b/components/ui/ConfirmDialog.js index e94c55e..b592550 100644 --- a/components/ui/ConfirmDialog.js +++ b/components/ui/ConfirmDialog.js @@ -13,7 +13,7 @@ import { Alert, Button, CloseButton, Dialog, Text } from "@chakra-ui/react"; * - children: dialog body -- the consequence copy the caller composes. * - confirmLabel: text on the confirm button. * - destructive: red solid confirm for actions that cannot be undone; - * brandTeal solid (the default) for actions that are consequential but + * brandGreen solid (the default) for actions that are consequential but * reversible. Keep red scarce -- it only means something in the Danger * Zone if routine actions do not also wear it. * - onConfirm: may be async. While it is pending the confirm button shows @@ -25,7 +25,7 @@ import { Alert, Button, CloseButton, Dialog, Text } from "@chakra-ui/react"; * success the dialog closes itself. * * Cancel is ALWAYS the neutral button (variant="outline", no colorPalette) -- - * never brandTeal solid. A solid green Cancel next to a solid red Confirm + * never brandGreen solid. A solid green Cancel next to a solid red Confirm * reads as the button to press, which is backwards: walking away is supposed * to be the free, obvious choice, not competing for attention with the * action that has a consequence. @@ -94,7 +94,7 @@ export default function ConfirmDialog({ Cancel </Button> <Button - colorPalette={destructive ? "red" : "brandTeal"} + colorPalette={destructive ? "red" : "brandGreen"} variant="solid" loading={isPending} onClick={handleConfirm} diff --git a/components/ui/StatusIndicator.js b/components/ui/StatusIndicator.js index b701917..be83683 100644 --- a/components/ui/StatusIndicator.js +++ b/components/ui/StatusIndicator.js @@ -35,10 +35,12 @@ import { CircleCheck, TriangleAlert, CircleX, Minus } from "lucide-react"; * * Contrast (measured against the app body #1C1F22, same method as * lib/theme.js): - * - ok / CircleCheck: literal `brandTeal.500` (#13b24b) -> 5.91:1. NOT + * - ok / CircleCheck: literal `brandGreen.500` (#4CAF50) -> 5.96:1. NOT * Chakra's `green.500`: DESIGN.md §1 commits to one green ("ok" IS the - * brand teal), and giving this primitive a second green at birth would - * re-create the two-greens drift it exists to end. + * brand green), and giving this primitive a second green at birth would + * re-create the two-greens drift it exists to end. This was the retired + * `brandTeal.500` (#13b24b, 5.91:1) until the logo green #2E7D32 became + * the primary; the ramp is Material Green, so `ok` moved with it. * - warning / TriangleAlert: literal `orange.500` (#f97316) -> 5.91:1. * - error / CircleX: literal `red.400` (#f87171) -> 5.99:1. * - neutral / Minus: literal `gray.400` (#a1a1aa) -> 6.46:1. @@ -75,7 +77,7 @@ const STATUS_ICONS = { // the same `var(--chakra-colors-...)` pattern already used for icon color // elsewhere in the app (see components/account/ProviderConnections.js). const STATUS_COLORS = { - ok: "var(--chakra-colors-brand-teal-500)", + ok: "var(--chakra-colors-brand-green-500)", warning: "var(--chakra-colors-orange-500)", error: "var(--chakra-colors-red-400)", neutral: "var(--chakra-colors-gray-400)", diff --git a/lib/theme.js b/lib/theme.js index dedc612..8464832 100644 --- a/lib/theme.js +++ b/lib/theme.js @@ -26,17 +26,53 @@ const config = defineConfig({ 800: { value: "#7C4606" }, 900: { value: "#3E2303" }, }, + // The brand green, anchored to the logo (docs/brand/logo/README.md). + // The mark's light-background green is #2E7D32 and its token sheet + // names #43A047 as the mid step, which identifies the ramp as + // Material Green: #2E7D32 is Material Green 800, #43A047 is 600. So + // the whole 50-900 ramp below is Material Green verbatim, which means + // every step is a real, hand-tuned tone rather than an interpolation + // off a single brand hex, and the logo sits on the ramp at 800 + // instead of merely near it. + brandGreen: { + 50: { value: "#E8F5E9" }, + 100: { value: "#C8E6C9" }, + 200: { value: "#A5D6A7" }, + 300: { value: "#81C784" }, + 400: { value: "#66BB6A" }, + 500: { value: "#4CAF50" }, + 600: { value: "#43A047" }, + 700: { value: "#388E3C" }, + 800: { value: "#2E7D32" }, + 900: { value: "#1B5E20" }, + }, + // DEPRECATED ALIAS -- brandTeal is retired as a color. + // + // The old primary #13b24b measured 1.83:1 against the logo green + // #2E7D32 (close enough to read as a mistake rather than a pairing) + // and 2.80:1 on white (it could never be light-mode text). The green + // fixes both: #2E7D32 is 5.13:1 on white and 4.77:1 on the planned + // #F5F7F8 light page. + // + // Every step here now resolves to the brandGreen step of the same + // index, so `brandTeal.400` and `brandGreen.400` paint the identical + // pixel. This exists purely so the rename can land file by file + // without a single broken reference in between -- components/Navbar.js + // and three in-progress pages (index, getting-started, api-docs) are + // owned by other work right now and still say brandTeal. Delete this + // block, and the semantic brandTeal block below, once those are + // renamed; nothing should be added to it. brandTeal: { - 50: { value: "#E8F9EE" }, - 100: { value: "#C6F0D5" }, - 200: { value: "#8FE0AC" }, - 300: { value: "#58D183" }, - 400: { value: "#2CC35E" }, - 500: { value: "#13b24b" }, - 600: { value: "#0E923D" }, - 700: { value: "#0B7230" }, - 800: { value: "#085223" }, - 900: { value: "#043216" }, + 50: { value: "{colors.brandGreen.50}" }, + 100: { value: "{colors.brandGreen.100}" }, + 200: { value: "{colors.brandGreen.200}" }, + 300: { value: "{colors.brandGreen.300}" }, + 400: { value: "{colors.brandGreen.400}" }, + 500: { value: "{colors.brandGreen.500}" }, + 600: { value: "{colors.brandGreen.600}" }, + 700: { value: "{colors.brandGreen.700}" }, + 800: { value: "{colors.brandGreen.800}" }, + 900: { value: "{colors.brandGreen.900}" }, }, brandLime: { 50: { value: "#E0F2E8" }, @@ -131,15 +167,89 @@ const config = defineConfig({ focusRing: { value: { _light: "{colors.brandOrange.500}", _dark: "{colors.brandOrange.500}" } }, border: { value: { _light: "{colors.brandOrange.500}", _dark: "{colors.brandOrange.400}" } }, }, + // brandGreen -- the primary action color, replacing brandTeal. + // + // Same rule as the gray palette above: the app renders on a + // permanently dark surface (#1C1F22) while Chakra's mode is light, so + // every _light value here carries the DARK-surface reading and both + // sides are set identically. A palette whose _light column were tuned + // for a white page would be measurably wrong on the page we actually + // ship. When the light/dark migration lands, the _light column + // diverges to the values in DESIGN.md section 1; until then, one + // reading, correctly measured, in both slots. + // + // All ratios below are WCAG 2.1 against the body #1C1F22 unless the + // surface is named. bg.muted is gray.700 #3f3f46 today. + // + // fg 300 #81C784 8.23:1 on the body, 6.71:1 on the migration's + // #2A2F34 bg.muted, 5.19:1 on today's gray.700 + // bg.muted. Clears the 4.5:1 body-text floor on + // every surface a palette fg is allowed to sit + // on. (400 #66BB6A would also clear at 7.00, + // but 300 keeps a step of headroom for the + // hover/active darkening Chakra applies.) + // solid 500 #4CAF50 fill 5.96:1 vs the body. + // contrast #1C1F22 5.96:1 against that fill -- the body color + // used as button TEXT. + // + // The solid/contrast pair was computed both ways, because the + // obvious choice is wrong here. Dark fill + white text (800 + // #2E7D32 + white) gives fill 3.23:1 and text 5.13:1. Bright fill + // + dark text (500 #4CAF50 + #1C1F22) gives fill 5.96:1 and text + // 5.96:1 -- better on both axes at once. On a dark page a dark + // green button is a hole; the bright chip reads as a control. This + // is the same flip DESIGN.md prescribes for dark-mode teal, and it + // is what retires the old solid: brandTeal.600 + white, which was + // 4.04:1 -- a live AA failure. + // + // border 400 #66BB6A 7.00:1 vs the body, 5.71:1 on #2A2F34, + // 4.42:1 on today's gray.700. WCAG 1.4.11 wants + // 3.0 for a non-text boundary; this clears it + // on every surface. + // focusRing 400 same value, same 4.42:1 worst case. A focus + // ring is a non-text boundary too, and it must + // stay visible where a control sits on a hover + // fill, not just on the page. + // subtle 900 #1B5E20 tinted fill, 2.10:1 vs the body -- visible as + // a region without competing with content. + // muted 800 / emphasized 700 hover and active steps above it. + // + // CAVEAT, and it is the one soft spot on this ramp: Chakra's + // `subtle` and `surface` variants paint colorPalette.fg on + // colorPalette.subtle, and 300 on 900 is 3.91:1 -- under the + // 4.5:1 body floor. Material Green 900 is a mid-dark green, not + // the near-black that the old hand-tuned teal 900 (#043216) was, + // and the ramp has nothing darker. So text placed on + // brandGreen.subtle must be named explicitly -- 50 #E8F5E9 gives + // 7.00:1 -- and `variant="subtle"`/`"surface"` is not approved for + // brandGreen body text until a semantic pairing exists. No call + // site uses either variant on this palette today; every brandGreen + // consumer is a solid button, a checkbox, or a spinner. + brandGreen: { + contrast: { value: { _light: "{colors.greyBackground}", _dark: "{colors.greyBackground}" } }, + fg: { value: { _light: "{colors.brandGreen.300}", _dark: "{colors.brandGreen.300}" } }, + subtle: { value: { _light: "{colors.brandGreen.900}", _dark: "{colors.brandGreen.900}" } }, + muted: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.800}" } }, + emphasized: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.700}" } }, + solid: { value: { _light: "{colors.brandGreen.500}", _dark: "{colors.brandGreen.500}" } }, + focusRing: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, + border: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, + }, + // DEPRECATED ALIAS -- see the brandTeal ramp above. Every slot mirrors + // brandGreen exactly, so `colorPalette="brandTeal"` renders as + // `colorPalette="brandGreen"` down to the pixel. Transitional only: + // it exists so components/Navbar.js and the three in-progress pages + // can keep saying brandTeal while they are owned elsewhere, and it + // dies with them once every reference is renamed. brandTeal: { - contrast: { value: { _light: "white", _dark: "white" } }, - fg: { value: { _light: "{colors.brandTeal.500}", _dark: "{colors.brandTeal.300}" } }, - subtle: { value: { _light: "{colors.brandTeal.100}", _dark: "{colors.brandTeal.900}" } }, - muted: { value: { _light: "{colors.brandTeal.200}", _dark: "{colors.brandTeal.800}" } }, - emphasized: { value: { _light: "{colors.brandTeal.300}", _dark: "{colors.brandTeal.700}" } }, - solid: { value: { _light: "{colors.brandTeal.600}", _dark: "{colors.brandTeal.600}" } }, - focusRing: { value: { _light: "{colors.brandTeal.500}", _dark: "{colors.brandTeal.500}" } }, - border: { value: { _light: "{colors.brandTeal.500}", _dark: "{colors.brandTeal.400}" } }, + contrast: { value: { _light: "{colors.greyBackground}", _dark: "{colors.greyBackground}" } }, + fg: { value: { _light: "{colors.brandGreen.300}", _dark: "{colors.brandGreen.300}" } }, + subtle: { value: { _light: "{colors.brandGreen.900}", _dark: "{colors.brandGreen.900}" } }, + muted: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.800}" } }, + emphasized: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.700}" } }, + solid: { value: { _light: "{colors.brandGreen.500}", _dark: "{colors.brandGreen.500}" } }, + focusRing: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, + border: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, }, brandLime: { contrast: { value: { _light: "white", _dark: "white" } }, diff --git a/pages/admin/[experiment_id].js b/pages/admin/[experiment_id].js index f2a8a73..587907b 100644 --- a/pages/admin/[experiment_id].js +++ b/pages/admin/[experiment_id].js @@ -87,7 +87,7 @@ function ExperimentPageDashboard({ experiment_id }) { return ( <> - {loading && <Spinner color="brandTeal.500" size={"xl"} />} + {loading && <Spinner color="brandGreen.500" size={"xl"} />} {error && <Text>This experiment does not exist.</Text>} {data && ( <VStack alignSelf="flex-start" align="flex-start" w="100%" maxW={1200} px={4}> diff --git a/pages/admin/account.js b/pages/admin/account.js index 6fc115a..5f432ef 100644 --- a/pages/admin/account.js +++ b/pages/admin/account.js @@ -45,7 +45,7 @@ export default function AccountPage({}) { if (loading) { return ( <Center py={16}> - <Spinner size="lg" color="brandTeal.500" /> + <Spinner size="lg" color="brandGreen.500" /> </Center> ); } diff --git a/pages/admin/index.js b/pages/admin/index.js index 35e53af..8800a19 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -51,7 +51,7 @@ function ExperimentList() { if (loading) { return ( <Center w="100%" py={8}> - <Spinner color="brandTeal.500" size={"xl"} /> + <Spinner color="brandGreen.500" size={"xl"} /> </Center> ); } @@ -82,7 +82,7 @@ function ExperimentList() { <Link href="/admin/new"> <Button - colorPalette="brandTeal" + colorPalette="brandGreen" size="lg" width="full" > @@ -117,7 +117,7 @@ function ExperimentList() { <Link href="/admin/new"> <Button variant={"solid"} - colorPalette={"brandTeal"} + colorPalette={"brandGreen"} size={"md"} mr={4} > @@ -254,7 +254,7 @@ function DeleteAlertDialog({ exp }) { </Dialog.Body> <Dialog.Footer> - <Button onClick={() => setOpen(false)} colorPalette="brandTeal"> + <Button onClick={() => setOpen(false)} colorPalette="brandGreen"> Cancel </Button> <Button diff --git a/pages/admin/new.js b/pages/admin/new.js index 83f6ff9..d7a8b43 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -236,7 +236,7 @@ function NewExperimentForm() { return ( <> - {loading && <Spinner color="brandTeal.500" size={"xl"} />} + {loading && <Spinner color="brandGreen.500" size={"xl"} />} {!loading && ( <Stack gap={6} w="100%" maxW="540px" px={4}> <Heading>Create a New Experiment</Heading> @@ -267,7 +267,7 @@ function NewExperimentForm() { {STORAGE_PROVIDERS[provider]?.name} account to get started. </Text> <Link href="/admin/account"> - <Button variant={"solid"} colorPalette={"brandTeal"} size={"lg"}> + <Button variant={"solid"} colorPalette={"brandGreen"} size={"lg"}> Connect {STORAGE_PROVIDERS[provider]?.name} Account </Button> </Link> @@ -339,7 +339,7 @@ function NewExperimentForm() { <HStack gap={3}> <Button variant="outline" - colorPalette="brandTeal" + colorPalette="brandGreen" size="md" loading={folderPickerLoading} onClick={handleChooseFolder} @@ -370,7 +370,7 @@ function NewExperimentForm() { <Button onClick={handleProviderSubmit} loading={providerSubmitting} - colorPalette={"brandTeal"} + colorPalette={"brandGreen"} > Create </Button> diff --git a/pages/reset-password.js b/pages/reset-password.js index cbecfa3..9416d89 100644 --- a/pages/reset-password.js +++ b/pages/reset-password.js @@ -89,7 +89,7 @@ export default function ResetPassword() { password. </Text> <Button - colorPalette={"brandTeal"} + colorPalette={"brandGreen"} loading={isSubmitting} onClick={resetPassword} > @@ -114,7 +114,7 @@ export default function ResetPassword() { <Field.ErrorText>{error}</Field.ErrorText> </Field.Root> <Button - colorPalette={"brandTeal"} + colorPalette={"brandGreen"} loading={isSubmitting} onClick={setNewPassword} > diff --git a/pages/signup.js b/pages/signup.js index 6aa713d..e671c7d 100644 --- a/pages/signup.js +++ b/pages/signup.js @@ -128,7 +128,7 @@ export default function SignUpPage() { </Field.Root> <Button - colorPalette="brandTeal" + colorPalette="brandGreen" loading={isSubmitting} onClick={onSubmit} w="full" From 3d33f69a0499ff879df040ac4fb8a62b20fcf6e8 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw <josh.deleeuw@gmail.com> Date: Sat, 22 Aug 2026 13:27:04 -0400 Subject: [PATCH 105/181] =?UTF-8?q?feat(brand):=20implement=20the=20|>=20m?= =?UTF-8?q?ark=20=E2=80=94=20LogoMark,=20navbar=20lockup,=20favicon=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogoMark renders the handoff's canonical 104-grid geometry as inline SVG (docs/brand/logo/README.md is the source of truth; nothing redrawn). Its two invariants are documented in place: the chevrons are never filled (a solid triangle reads as a play button) and the bar overshoots them top and bottom. The echo chevron #8BC34A is deliberately identical in both color modes per the handoff. Decorative to assistive tech; the wordmark beside it carries the name. Navbar drops logo.png + Rubik for mark + 'DataPipe' in Space Grotesk 600 (-0.03em), the ratified lockup minus the URL line, which belongs on the homepage hero only. Rubik now survives solely in the index hero pending its lockup swap. Favicons regenerated from the handoff's optically-corrected reductions: 16px is bar + single chevron (two chevrons mush at that size), 32px uses the widened notch, tile assets feed the touch/android icons, and an SVG favicon leads for modern browsers with PNG/ICO fallbacks. Manifest picks up the dark tile #1C2A22. index.test's next/font mock now exports every font a rendered component imports — the missing Space_Grotesk crashed the suite at module load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- __tests__/index.test.jsx | 6 ++- components/LogoMark.js | 66 ++++++++++++++++++++++++++++++ components/Navbar.js | 38 +++++++++-------- pages/_app.js | 10 +++++ public/android-chrome-192x192.png | Bin 17015 -> 3623 bytes public/android-chrome-512x512.png | Bin 57610 -> 10583 bytes public/apple-touch-icon.png | Bin 15362 -> 3228 bytes public/favicon-16.svg | 5 +++ public/favicon-16x16.png | Bin 668 -> 430 bytes public/favicon-32.svg | 6 +++ public/favicon-32x32.png | Bin 1490 -> 1255 bytes public/favicon.ico | Bin 15406 -> 4414 bytes public/favicon.svg | 6 +++ public/icon-tile-dark.svg | 6 +++ public/icon-tile-light.svg | 6 +++ public/icon-tile-outline.svg | 6 +++ public/site.webmanifest | 2 +- 17 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 components/LogoMark.js create mode 100644 public/favicon-16.svg create mode 100644 public/favicon-32.svg create mode 100644 public/favicon.svg create mode 100644 public/icon-tile-dark.svg create mode 100644 public/icon-tile-light.svg create mode 100644 public/icon-tile-outline.svg diff --git a/__tests__/index.test.jsx b/__tests__/index.test.jsx index 3c0cbb0..67fd717 100644 --- a/__tests__/index.test.jsx +++ b/__tests__/index.test.jsx @@ -9,9 +9,13 @@ jest.mock("../lib/firebase", () => ({ db: {}, })); -// Mock next/font/google since it's not available in test env +// Mock next/font/google since it's not available in test env. Every font +// any rendered component imports must appear here -- a missing export +// crashes the suite at module load (Navbar's Space_Grotesk did exactly +// that when the logo lockup replaced Rubik there). jest.mock("next/font/google", () => ({ Rubik: () => ({ className: "mock-rubik" }), + Space_Grotesk: () => ({ className: "mock-space-grotesk" }), })); // Mock context to provide a default user value diff --git a/components/LogoMark.js b/components/LogoMark.js new file mode 100644 index 0000000..d58c96b --- /dev/null +++ b/components/LogoMark.js @@ -0,0 +1,66 @@ +/** + * DataPipe logo mark -- the R/base pipe operator `|>` rendered as pure + * geometry: one vertical bar plus an open chevron, echoed by a second, + * lighter chevron. Geometry is canonical (104x104 grid) per + * docs/brand/logo/README.md ("direction 3b / Echo") -- the path data below + * is the source of truth, do not redraw by eye or otherwise adjust it. + * + * Two invariants that must survive any resize or recolor: + * 1. The chevrons are NEVER filled. Each is drawn as two strokes + * (fill="none") -- a solid triangle reads as a play button. + * 2. The bar overshoots the chevrons top and bottom (y 12->92 vs the + * chevrons' y 26->78, ~14 units each end). This matches how `|` sits + * taller than `>` in a monospace font -- never align the bar's edges to + * the chevrons'. + * + * @param {number} [size=104] - Rendered height in px. Width scales 1:1 since + * the mark is drawn on a square grid. + * @param {string} [color="currentColor"] - Color for the bar and the first + * chevron. Defaults to currentColor so the mark inherits the surrounding + * text color (the mark-mono.svg pattern) -- pass an explicit value where + * inheritance is fragile (e.g. across a font/className boundary). + * @param {string} [echoColor="#8BC34A"] - Color for the second, echoed + * chevron. This green is deliberately the SAME value in light and dark + * modes per the brand handoff -- it's the only tone that holds contrast + * against both #FFFFFF and dark surfaces (#101A14). Do not theme this + * value or otherwise vary it by color mode. + */ +export default function LogoMark({ + size = 104, + color = "currentColor", + echoColor = "#8BC34A", + ...props +}) { + // Decorative (aria-hidden, no role): the visible wordmark beside the mark + // carries the name, so the SVG is hidden from assistive tech rather than + // announced twice. + return ( + <svg + width={size} + height={size} + viewBox="0 0 104 104" + fill="none" + xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + {...props} + > + <rect x="10" y="12" width="10" height="80" rx="1" fill={color} /> + <path + d="M32 26 L62 52 L32 78" + fill="none" + stroke={color} + strokeWidth="10" + strokeLinecap="square" + strokeLinejoin="round" + /> + <path + d="M62 26 L92 52 L62 78" + fill="none" + stroke={echoColor} + strokeWidth="10" + strokeLinecap="square" + strokeLinejoin="round" + /> + </svg> + ); +} diff --git a/components/Navbar.js b/components/Navbar.js index ff728f7..1e76e2a 100644 --- a/components/Navbar.js +++ b/components/Navbar.js @@ -8,17 +8,17 @@ import { Flex, HStack, Link, - Image, IconButton, } from "@chakra-ui/react"; import { Plus, Menu as MenuIcon } from "lucide-react"; import { Menu } from "@chakra-ui/react"; import { auth } from "../lib/firebase"; +import LogoMark from "./LogoMark"; -import { Rubik } from "next/font/google"; +import { Space_Grotesk } from "next/font/google"; -const rubik = Rubik({ subsets: ["latin"] }); +const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], weight: ["600"] }); // `user` comes from an auth listener, so it is null in the server-rendered // HTML and may be populated by the time the client hydrates -- rendering it @@ -57,21 +57,23 @@ export default function Navbar() { > <HStack gap={4} alignItems={"center"} pe={"2"}> <NextLink href="/"> - <Box - display={"flex"} - alignItems={"center"} - fontSize={"2xl"} - className={rubik.className} - pr={10} - > - <Box p={2}> - <Image - src="/logo.png" - alt="DataPipe Logo" - boxSize="64px" - /> - </Box> - <Text>DataPipe</Text> + <Box display={"flex"} alignItems={"center"} gap={2.5} pr={10}> + {/* Explicit dark-surface colorway (README.md's "Dark bg" + column): the app renders on a permanently dark surface + (see lib/theme.js), so this is not a light/dark toggle -- + hardcode it rather than lean on currentColor inheritance + across the font className boundary below. */} + <LogoMark size={40} color="#F2F5F1" /> + <Text + className={spaceGrotesk.className} + fontWeight="600" + fontSize="22px" + lineHeight="1" + letterSpacing="-0.03em" + color="#F2F5F1" + > + DataPipe + </Text> </Box> </NextLink> <HStack diff --git a/pages/_app.js b/pages/_app.js index b943ab2..843fa98 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -45,6 +45,16 @@ function MyApp({ Component, pageProps }) { <Head> <title>DataPipe + {/* Vector favicon first so modern browsers get the new mark at any + resolution; PNG/ICO fallbacks follow for browsers that don't + support type="image/svg+xml" icons. */} + + + + + + + {getLayout()} diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 635a2460e2f29cd54a5e24eced432e2fa74f954e..441d7c157f1e83695f9bd7a87af456796d6b8d06 100644 GIT binary patch literal 3623 zcmYk8dpwivAIGm9%y~=)OBkjnIW6S`q)vD z3v^@(KVm58+hz2!D0D!1+1ZeQK=>|tU6u)vqsK^=ZsB>0?C8tye=4>87@Cc|zG)e` zW5X)Gcu#Si(;cy%Zk$F;PR`pL9mx)-`$x|eou5Db%%UCpQuggs@kp~Zf;CnAuiP`= zu9>M)S<+7V$7*3Z+jHvPiVxu4D-US}aLhLz-TVj}3R(NL#kqpZAktBplDI!FZY6~zgIGG8ZVpOCG3jErqVtS_*eJtkn zhldvyz7K!bGvwIU(~r77tx(U?-6Wr!iiGnNsO7sCB>YgX!K7 zOZbt%W~cd%y0Q_uU&?vcfnjyNKu2|&4BWp&YNyAi*rrpyv3}zC`wDM`pgqZ#&z<;v zKCuk^_;V`~zeH>9O8+~&++ffotE>B}gr~#hp3rm&fi?VU{$fhI{Dzh44bIl^RoU!2m(T(rbawM#a6wTzxOeF3&q>5b=c) z1$lwA_qdvtVrPip5~hd0y}h2)Yb>H*=dEPKcVkqyW^w;|8rj0Dke(4q)h{DnjPZ?Fvzp$o+I#@WN_tDOqAK2)!`$Y^ zwx_3}6wuvIpRe192F-9YV@j17TkXg1%T8Tmo2m|2iqV-FOl|gV)qyv17iv8_P{LD7 zxb#l-(uFaxbmgf_l4&>L_Hh}`lClhbjQx)1AnWUxNcwM&PYBeK+P6zllUhOhg>y|D zE_3}4$HDwpfsG6PJ>^xRAoS<8SB-VJCzYK5Y~Eu1bzScCP2>Y&)N!*q?eW|9XLRaI zzbmjuI!=YvD%#0+w{9lpI=TK{9cdcV0-lxERJ0{_=Q_z(SsnK^fD+uCFm5(o^w1yL zZuhf`inU`OC;I8fiW8{IKIo>ZG_`g`%5MAvETTOvfERE_*0OH$gG2zDiL)-8KHDD1pK4XofNHulWiY1r>i}>-G^GFK1sk=mY=@Gkf zK1ae;O=BH2Y&6h|eGMX}%o@jE?8dCLWeztkt2pejIX!wJ(Ee`GR|LSuY->@D!N5Qg zljfBIjRnXPBs$sC4FG&_F&0nI1yqcY^JNTn7a%CV>8z^k-_7~C*~qTXYOpA1UiDBq zs&3-}*(J=mT=GVMR>8RA)h|A?n41 zgqkNXhEigcVTg7N-bsF13`sVKVOyiu~i1Yf_kwD9**&v_gFuSpZnDqOiAKDuk-dI9{1AF?Yj3S?)w z8=tiDL!J$M5Vw?(Hb9qBA%;esKVdz$eKd;2P8sU|nd7?$BPs1Zq^Z;e;N+ogpGAh) z-#bkj3b4HluO|anq*SP(d1wQB|KI=}r828*kCP8W6%k?@P5AQa@@rvcP51{c3eF}e zFywg^Ry&*`(SNQ&TG_6tkh9OqN1^4US;0Jp^skH+Dg$%=@kVJ2kY|+RWRj8!V6JCfQ+2uwf0^(BcXjf0 z^;1_Rowfn%e{L#K0B_CjQmUtLQv4ZFYV({bqkpUPa~R)zk)E7X+JB?gaqiCoxXA5l z$lZPH#*VY0h;nG-BBYoGij=U1IGiV_p5Ru# zGv)(Ca1s<-6UH#;rUn%$3ns|A!uufN%Y&hcZnJ81kFsP4REAIrfTT2eA%%vhkk(0e z`S;K`LONr=vrrXU z&5#H41XS1^=*A-ebMks|T09g~q=-sH@n9?q9C0?@b)_|u?zNx^JyNu49xmv=1S;u@ zEp;A?UvY}7P?Pex3>qx*rZ)DQeY?yvh&F10`jvA1CrMH`?ryc8+W4hSkeA&dvJf(t8|nG}^0`!5uaBd7q6Q7k3j9!p_T|G&So%W_%cI7hFI zo?Axu+yn>LZsr`|Q(-W$MV@F0qFCSw7lnR+Ph)nyQ?^heKtJ0pIAA0*Q?F9?;|W)w zDM6PsX766c8ItEb7lA^7`rR=p?vbNO%xS0uX@E`j{4)6AYx0_A4b+6KuXfq$>~1-tww-`2zd zR^T%0)0|h>li6TsSt98e(|+N9`-7c8ur-*X@u$Nn>fy(c@}$3-b%;{)&^|(0Ezokp zl|7;|V5R(A<%_ED%u+(}88X;ujeIv$RxVy!d;!;Bc%TZwG%^Qs_r_}1D#Th_y#8tX{vW&yOBl!0$# z>}}#*sl)k77irZ+8g0*5i|rLHpP)Iac~3RsdzFmyf0kt`nfNVsOUurg0o`SvAkvtL z>X8Z&Q7=)nW9Y480*%H1R-?l%3AQE`|DBWE?&^^?n4c+ak;aO?!y~MYwf%8)OoX<^ z9@(89k6g8OnW_`+q4!^VCwWGsbB6c*^N+8i3FG8W+b7`1j#*Rnr8sa%Mp{buVsvSg zO54b+Ur)o0@!QQ;!Tj==Qo3LB-uui)xTir;I)#ryB9QGXzfQHG9nXfg%&hRTmqFI_ zD^z;oluLV|dx54$1E=D;>5`cd$A)U3>>pS`Baw0PE{COx20KoYZp_Cpm-khMdPjcC zr+lzh6!G*=Ft~TS)%H8)h;}BE4N4p6y8SmCY$v2uxwg~w;q1nekK*p|_FA8gj6R!W zT({k^1+-m}Gh7-}!>8SdZn0(d2VbWD5d5KmQlEB~ocbGyE`1lmdCD64K2GFhS$ Yz}cSDi!G-SSpa$+vvwd=AMuR+4?m~1%>V!Z literal 17015 zcmYg&Ra70(vTbj4;~w1I0>Rzg-5ml1cbAP5+$|6^xJz({ph1GWyGwAnIq!~l-uviY zpVd7|M%A3N)>joJX%s{PL;wI#WMw4O{+&JldBAZ0-fF9M@c#~wtD3YJP(49(1OQ}! ztc0kBm(iI&Lbics=EXa|XPN(r|Ams+2XHW+m<1<08`(rH%%KRl9Sj{c;ksF-EK7im z4=)9Ob%Zs9{{g=@PbdYXs-Yo83ZAwLrG}|b3OhY=^VD4PUBBSJUoStVF5)+AZfjGQ z7H{L9JNW*ba}hYv92h!Us$p+}R+X=@N%6KwbRfx7wj%>MTAVK}=J3PN$}JGbq+ z?c=zsBOg_kuuT`7tpLT0iA^pBh@eY=ppMWb64WRupdQdDfg}-3nCNbhs+3r9SRM)B z4-k`?u!9`s3ON51bKjLTRNSxqorN$k`$7hmR7fv*ydZKG6w(&B5KPEp-2}+q-Ft~113jI${l5!S$|McwCmkw7 z-Lf6sv5i`P3(Z-Y#;Uusx4$?We4(CqGP;m?Q-B5FS6)x7@JF0%*+s`2zs8X#&A_np zu_8#s0&aubs0+UDEX05SWG~kd!d| z&Qmztchw69XET1C_y8p>uM$fo0cItxqYfPH#s%!i$vAt=zDlFp-v`gh6Cj zeRCL2L_1x^-Uw$V#?D`AtSdLqP)Y4A7MtSm(%VQvmFf8V&!pBQ{1tNtMY0MkCPg}_ zgKnGrI6e~MZA2+I%*|2ARRoyV> z)LdLD$&a6h8wNVW5jp37>y3f00l>t^(rjl}#n&Ju`;fa~}FktVj8s6uX%9nRS zkc*!?JS8&V*zsL%aeIgA$O8S?5Gl5z+lRJ`QckBh&P^oZvyBQxtkXcc0B!76H1d_T zcAr`SIy5C2tk{%E`HC(0$0|rHm&%TI3MuLE?QiqTg;j5jcWBI4G{`^aZaMkJCjQQ* zuzy09`Tj?_<4VczgVpMcAR$Kr!twEJE?69%90nel4~7F5SzIp@EdL|LcuenTgNtf# zKHV{6@h##j5~RA-$4mauKlsoQ;TD5}50qEy>Erv&k&vLCCii+m*6~Ph>HB3NET!X5 zP`*Pb@Z~suYth7Tgk=b|6JQQWP)3+iFO%_El7Q_Cg%Q~;j^|2?%=dcKWYw0iWQ6FjnoYf{5Q{OXlt2jZ#nbzn_kWZ${Pl;@VGxRb9+ZKJ; zV)T=zwG&5tOeDb`h@0P;8i#E9*9d7wV_DxoWTL(CxpVo69vB!Op|||WT+HkYel;r+ zFXer;8wPS;Qa^FP>L{XugV2w_NW{e^n#&DQ^wfMv1=?gs(=nE>PW`_(bjE}tt`sJn z(IAKJR)!RuXIe7jR5-55+~#uP;(R1(U!=^KrR7l!VcL55f6R{68XDmaheJm_;_PVuGF2C$ z4GOiSsLb4~f&axK`!yG%aU~?S4yR(@Gq&9++8G`)l^VFlg#0Q8sfL=?_@FHaTz<`s zg-M8oGawJvgSXLQ7coJNA0R780 zu-!|41m{;45D$q&Z+4P=s2x+J0!3v6skke0Nq<%gF3!WdLp(VnZ|(x3B@7^`bYy$E2ICMu@D>Eq!_^#PR$Zr zY{IQCS_=EskZbGCttY^1XQ=CE#cJyAHC)eXt52;sf2Pa}H247X*}3C^$iv{Oj1-A6 zvztv$8PtVhCIzSp0^&{*Nw$!LVY#5(FGc>HU-Wn5C*9pY%XhbJ3D#AD&^z|~)0441 zMe^2E9f_gzO|deM@fJIDF+9VYrvv<>?n51ejzx6V5MdESxj-k8PR}RYuVWzrf`N@U zX_Nq+&!BFTF8OC5!FUt(Zxa~T7=*f^Naa>lOg7;XQQHHEH1NHVDB~)PJt|1%NE9-R zEzv{Wv1Hjp6M8YEc^xfC!NQb(x?Y|(0^N0aJDqgv)GnjC-)zBG#0l9j=fz`v0iA_pTDC_cx33C26$f_imZK2wQrkFJ)Fy55;dOvt(>45JpU3=JM%6UF^#T2&;XcLQ|FqVM>q9us*R81G^q`gnZoU{Ejb2uQB;lF@(`DbnL*{A1Xe_s_( z_5DzZ(G-i^9Bk2|ft3Em)N=cq84(_GM)=$oo?1RQlu#FuOdRu`wD$0)T9o(?Xpw`} zQv6z6=xr%2uep&L+uCyT+N9}v4~McJn6$}APCBf{`sHoG+tNZ+Yw8`LfK%8Y$Z>;Q*ictI(mWWH@&mvki_^t-e?e7lx^K?PNm^B16}c*1KpmwMX$ zd*2vsw`MX#bKevt8wZD6oH~{7V9(vQOCN$DR0u263H0IMiIr#m-xCrR_$qEjO#RG5bt=cS+-< z*_1GavBN}X`3yBpN&6>-9)PP(9V~QP)EERt>z=|eBPg8Rv33W@KU1Ve|LX=fZdY{o z`GN5;D+4p=d$3A$Tcd7}d0k0cM@BBba4GM|=J-q0RYXFkG$N2W(Q_;-UObcFkH6@T zKU@j>Wko~@&1tkAjQW?=Eg?UMr;ypCv?jtx+6acB0|W=v^KAI>Iu*rwv%nZR+{NW1 z+gU}wYEj>(+8@K3G2EDk^K%RSd#b_?q>f6)pgb0yotm#bvNP^R!r%(NatTuImZeEg z!n@Ve9}35!ny^88KGGc%jM@ENprY8*cHhMH7UIm`7ZeTVET`urtYC{}C_78POski^ z7MU7jIL3x7h{-Q*w@4XuJqEZ=A=ub=D$4H zfXA7#$i)y|*M1!duv?O~GQGbgZ*L~djJ+suJfpd-UPFF{bZ;?ZqxM->gsrQ=cM5^t zzCRRiM$KLztb(}VWQ)XU*y{42BzRZBr-arq+T;XaoPDC2IuqlUrzT^=b7{IJVIad( zE(c?_8(|npv`8(?f&-=^znn>+<236)p*T|RQp>1V;Ks{xj2tj2X&AO!``YzaKXLu` zBiqHP$lcnSTqS;VZw!l?sx>FVL3pXWSVdGBp+(+OG2=7DQuvA~3uh0gEn2A0@H;!L5Rl*S* zn6^L)cZ}>UO_ba-kFaD7&h#o>6yVx^R4|lQFCZXiPd`|Lm%XpZK>eMs%oU|Cuu7EF#<4GOO`lyt$Ik%8ENSfgxWbV%k7!0~M+1u(#iB^+Yu- zzW3Q!c%Bj8-HdL9P+$b{HV{7*!KqLk2Io%^u8{)krY2zuMe2#4aHGw76WS1FMfV=S zE<QC?~PfQJLlj3P3`@QCSJbp(sLD(FbOrLI~ko&EBVLm3kK(w97J5Eo#B=lw>5p z%`d6jN*^S_8ha6s`!1KwgMOos$cX&rd9jPqi22=PV_2oe>}8eBFP1a(>6s%^ zA#&LS(Y@QsnmpmlUVnZ^Y1;o-r--aDYJG}3h}N_(@B$76TIFz;Jw>mfv^H=|ZOsq? z^T8LtNWL4)O(^{o zftu2ygwmvQl~I@v+7JmWN>PQmd2#b6@Ua=ahNPL%#~?*0WgUyam$`~_2D#|PZ6FV+ zPsjaOQLV714D+_KS{kZW&j#`dY^%lP-!Ed%69<`z<}1NQ?EqQOM2}B&EtuQHo!P9L98I7|M8X)A#8*!hqq^#Snj%j^J08 z&ZzNrUmA`LG(X;XjN3BBsS@rGiu?{ylgNHC-svP$ow})vNQr3^JjD?kX;KwXQDd}S zoylC2rvUr1BB3DgF?*%}|7i!1hDY982WyY zW;S_|rs(Hv)lxdoiO0TTGHwSS1|2dWDb4A`1Zxn>GUg-tmC=pWv1wtJyfWM3*z#aM zz!=l?^?X|62MICok@v`p{O?etbz4DGS-(S-y2ra(9K<1yD@$bHmTS_x&7wb(5o$^I zUuahMR~xfPUSy#pq#2G^}=QRNm$D&o3H@EncF0*#!{lPeQ%`(!`ER$JhF zDm?JP+2cI`-{XB-5~ocCAHD?G0G^zaO~$RmEzG}6aSoa_o+>kfDvPW>z0MlMFjm>6L9jV%`y7I@45o%kcREGf8W%Dqu*fzp~!iyX&- z-f5|P%rWrR$qzaL9x{$k>xQ>vgE2FKbR8{Ql&HXv(gWV5{#0E%S98CrFx$Y%38F zS`t8m+|K7LUQbO!0I)xgSkzIBlg>#1uV~hH(%;!P&HIUVx^KVm_!O7kb1Zzt%WiCZ!Cr3@|@`?&*Ra3Rx9~*ZV4N@Td6ujer~$AI6ko;{s>T z=Hf>yCfu})=I!O`CQ9!e3}_L9m)>`Tshoi-nbhCHAYE;O!SseO9nki8esE<%Hh4f+ zYwcgewaW*@cKxPi`NWRd7T-Qu-z}0Wh!gxd02kH>2YIsBx`d+EW}6xmgqSQ-q(ZFR zT7X^;`DwkE8ht^4;4BRXnP^23yQ>CDrACbG?(AwpEVx@ILvquj1~i?p`8%Fcah%1o zp1JJ%`QPu@Z#x^zw+F5s6c$XCC);Rz<0mtY4q-+s&NXjcy7%{~Ih_{`Hp*ZE^72dz zs;V)<&oj)we)G2F33%LVMr$v8y#)2t2fA+vlKJTgj|Wp4 z4|Ur|tX#B!ZVQ$fspp!6~i_PCxAcVNfzvrmykn89>3iI)c&$V)CDkBCY8EZOT ze{7x3Ujk0AOyV83ejlKkK}BZKpXHgUjNOIM$YHW7PI?e9TT3P9dkp}K`@be9_qo5i zx+}|ceErs=S8wfzZK|9_OzP1r^LSursft_MsCr#B>q6@hTWO{nsaEk%9olSpntT3P z*s7GGFh{^p(#)SA$Eq(}zgMxMzL&--mDebHs0ac!8wbvW{Ym%(--e=cx;4g)FNy3e z2^)9#G@MpN!ZOfgxe^vN-%g;zE%U0nMr89f96&xnK6`^>!U5KXCSE{`^@JphPbAn> z=_aYkc^H$b4dyq*#q08~mQHZOeb0nN-7~j*-I;WEnU~iPxG(O7%m&1*gp-MNvzw*5T(1h$uBZqq zY~Vt9d4S>b<%r+!Z&Yo@-ebn)T9%|AU?s#6m+5CfToJf5#I43Ff9YjXafK?6P*24D zkQ(PW$vLK zvjR+nD-zx_obHwC1Ij|?L!>8j z^6i9dhLR_~M|KKPlQ zByOho*oSHdK{^z!3agmM_wi-Lh%n0L0t8vQk^O_F;m;jQ)>@8ddo6@wL$Q zf8jzyDBtk_p{2K0LrZaY;__`G-DTseCZDI;tuE?ABh9+U=K+>31$h)%>k^Wx0`VIX zv*u_T?G%+g{8x;UC4$-M*H;k;2n2v4`8VbNXA*1&ky=R8gQ0s>ujflx^H`nGKoypf$b3JVv@U8%v$x{#gs zF8--cK<0|pFpG=kJ~JOIkEy>J@c=?H;1HsR_i41wAeJ8DEB?&EBWvmNyzAFaUGQG- z{@fQN*$B4*;QG}<*XDvuvlx1CP@qp=uD{fVx!XKbWwfqNyrA^ly}92D6htj({dy<_ z7Vt1h`QRdX5&phXNVTqx&eCiQkQLAG?t4gxnR3H~taI4yP|DXlykv>Vp~Cfq(Y`WM zk(l+Dj6Q8=IFKe1#1EuABws{*hwr(g4T!K84sdum^ZPu=ix|1HEXuAIK7qf9nI*)f zR3@v%n-sR~vW!w-t7YJ^@-D~&@bPn6Y&;?NP%G1kv_W=!>dI#u}(-n*7T_&4fzY~*O)-CKEsqS36M4PeoHs@wyhSj3mhY4h-)t^vf*w7ef!y766|zWKX5zTue1Q1dR|J2Z4qx0N7S$_awOY_lc1wZ1x%TkKddo$b`SLBNu|C+R8NgAf>L zj<&Uold(rAOqiWbpCHRMM_K@buE8}v>iKA^;AiS5XS#$av1~pbd$mEty6;EHBt6rx z$$HD0DM3(>qMs=@0 zPTlLH86w!M9FgpC&Ul?FFx&nIxlQv=*Y!l71zLX)=bJ3T0`nh@TdYS-fNXZ!I7pNH zzi$E-p$6(RmCb*peA)E*2o0*;NoU#2iTHkZRF?W>C<$sCIyt*EI0#_nH1YZ7D0@SM zpA|_hfFV=N4sX*b)bGuH_}HKf3L4Ahi9?8+H)l*h3psnAp;cr)6iCtzUf6i1yX(3w zM^mm+;N8czE(_nZae)T)Ankaue`xo|bJuJtt=Zy`9b6@|P&W2hI-BI*@ZRZ2t`1H> z+XbeKkemd)Na3P|>|?8{kJwqDoV{x19cbKScZIVAr8^mVJP=X{oswH}-FAsbVWVDj5uJ2kn&yzRNU>yu0%j4zjUM^8*&6 z%e~fA*|AV4b;4eZf)EFwnqdgBUfGnJy;x!L9+&22eX)J3e^q%L)RWnO8EMlEwafHZ zkiWa-t{bhNLs7vYr|7i)6=Y(*w)Xzje{AD&t1Oj%EF20&7U#n!FhKL1bL@Y^ulVI# zj114|_s8qc=DCks>5dPpB=7)f&KiJaAbzFuSI`q?m&k-viU(UvT0VloPZJDOl;Ar0 z?}^@aG%ZDxXcaRNBG!tZ#+a`fm~!8T;10F+kGl$mtv7QwlVj{4b7-AmAhu8l>_Kc!)<^-;@vlafP)3i=tvo1|B20-^+l!;b;BKb98Jv zdB8vgBZwhJp8_%E2biO#04Ck~nK7PTp1Q>hu8P&Nj4p3HPnx&>O@!EFd^``D*i=aC zyh=_pzbwCX8_bB)pSt3@K8EoNxTNRpDsKCr_!r(aG)MTvpR6|@-Hy(EYGeyt2zdg| z_RGFXM;iJRF4y{ve?k zR?i!U9VhD=vT7{klD_;4A9O)m#;qx#gigO=1<6$>>*g=vxJUzAQ~$CGXV}K^3;Z)w zIBEn519^BdmLDDid9lA7HTf_2<+x~iv@G=gR4_03uiQ~|b-rRUOb_31Qxmb6WXwlK zbR}(xT}g$Va{EEjtYUMQA&a?*t|Uxdzo)d<7-`N@zWfk%3KUw}#gdfaBkA8NzZ=#F zO+@6V)X!d);)9*OQjiT!nD%6K`8{gdlCd0 zqj0ICAPr}T<#GQdDn}_4-pNvR_n)#jlnwP;6P;F>w2fnXR%%1{&7fswAo^Ne*d1uiXS+#>zbeewtxO%`vuTKAZZx)hN?uGNDWMOb=-(%t&rN!eVnb z)J9TYZza##wf8?wuVqxcj+*>x{)v)%guj|N}*7@wgzKF*)PmjzCm{2^xUYj^^AYGqkSlXR=b!9AGQx`GekQ9nSUoo%-fLUmO||=+i5U3)u|>QY_$7{~~PsnN3N-k*DK!ua>Lz zg}yYcd;Pj$x)PtuxMZYBqTz=aX}%&d)Yul~_)aMDQ{!tk2SJ3 z%fZjhf1`gA8HAp9B*zD*z4D5v<7KcB!7OJ7I|m?FqjS5Da(pgv57%eYhw}f{{I89O zCu?x6A&svdKVpZaBXaVAZiuO25{3m>-G^o0nzr_|zs+*tPg{>e_k0Vt_p>;{$u(Ag zSPlXGgYzZ0uFZkK3I`f;GTMa-#_<}}D#U%C>PrFO^uJRhzDc|L?yH`}=k;wh2$-V` zRcz^P?UV%#08ZOlW}=r%davIb>Dhbcr9KZ(J+bIFvlwu8R~Ws&`fw6MP4sQ8aceVAQrzvZ-hTUTb}6!$m7-YDjDk4!EO&T54zuzL1%1k^)* z^BxG%cfTl=X=s{$GLV@n(f(*~8yj@N&jEZC$R}nsI)H#-bFI;na8Hx#`{=4_T>YV7 zDbUp?j*t4opGlFP+ zwG){CZ%8Kr9@KA?eA2+AT38tb7V1ro2y8DrX>)p6dif1a5Ud|L5a++{_l%C_uM4DM z;}+4~>Z$nA${klEpPB!~b^di!n zB0F;Ytpn-7gk(gJ-xDmk1u=qG5e=xE)bB`I-8FixPFMYu&^_L?O0{ zp-&tSWcGH}T6{o570mxba_|8@D{7OYcc~u9@^4{ZN(cKdOYFj$TlR~!oB;KFa8V3= zCh||=NBC2(J%8&i5X33I7!OIAKI~_RT+iLuf$?C6gCR`rPa_JhTVhYXUYHi0s#X)(s^~ zT<;;BwC>F`_KQr~R444@ z8aU9-WIXSGq2|4CBwt(CN9jJ?#R6ZyL)9zN_mgvT4U_H}Rz(-y9ow9!=9Ftm1-~JF zs=e4-&^Sto5;L$oos7DmXD8DucSX411z6!QO&5t!j+6I>wuoKoyZcm|U(o&w29n0; zUHl$(J%Cf4yJ5xnc&8R-KY@k%Oxu4Y#_LtqR7Ok_ZTQM0_P=;0%0E1lR>oFFB^}?2 z2sDNP^QjYm`I-3jcf~^jG^DzBJ0T0wz0H1-fDEKRKMn`@8N+FJ_$w}mK06RUG39a} zILd6|k^txky_Q3Z`GGgJp5GJ4<&c}ziD3Zx#I+{0Z||?NZr#`Orox5S(SwB$YZ+1g z&rf-7(rAE(JMtf^=_Q0pJ8=QycdP`r`BBCX@7JB{Zaj7hZqwQ6z!RjclLLiI`tmWQ zPpd^|k||3&lK68}_eQfB%1?ny?0VljTt`|)_Sg(4Y_@!VC%(uVA6F7%&)kQaJcNo~ zS;x{kXvc$F`5dD@81Ym7*^t_L&sMEyIPNC%Axc|!;pg~t`}Zo|KdYCtaQ(La*r zN-N6L-HDMvWcFp_`4?}u-lq?b@2(EvqjdDJjpi$Poi*Z_0`P-Ru08q;$owC!H4XN0 zxscVvgq-qWH6PJ$q5h6Wrw6{=#YRvJ|1U#vIwg!&zyerYi!ajCJns3CoGzL)XeoF{ zB2{bQ5Ho06Ju;)c6Ft9O!-)? z*3HZ=mRE|96;W?Zn0U?@vTQhs@2l{02>_Q&xH)bGWM|T=Qu>NE$F)nJ_SLxpxB7Us zAuFq{h(A34+@NgEVQ&|->36>)^JI3hy_7&o6?w)dG5Vk>1$n>47Yf>%B?$fa^P!7e z8#i;{l}2NY6c+L~q2(V%)!fZNB!^52G+5%BB?XX@Ib054jc5~{KvCnZ>V}W$A7+`$ zje&Yz`8wTcx%^LtWE_`NgXf4^4dBw}J zm8t;xZH|cN&hLKS*SU)rYetsr8H!LHxI8(*agz(9Kc3V>zO5Sdh)tSJ3?uoZfWer` zMzOuufv>Rm#y&Alj)?>ze+(MCBQ?d;WCp^vS~xU&$E>DEZ4&vSs*)J#uk6AXusn<% zJqHSHeToQwSolu5ae(#GhVN};I{inX$D3>pWeL8qU=OyUO0fX_P_JGC$?AXe9dc+# zyx;|M+Rs?%f6U$;b+^>UzNfV8`e+TVng~_llrkTp-A%e20#U;Du$&_Y5PkMao$$Q0 ztgdeuL7;gfJ?A~V6 zL^rvU)I%<&U%eTt&j?iaXNR@NKIG@@GgDb(P(8!ub@-j0G0vF`mgHB1RO231e5Zwj z!X4q~5q=8Xy54rZDh=|vwGj5te_!T_nXzQZ6t;rk0RVp~<(L|T%f*8)`^VMSjj1Ux zS0bZo_SqYzXXw+<)tl>oa=<^=>JQ`n`d8E?3e(I?iJ@32m9$_ktM>;YN{tpLA<(hy zN$=>D3b6V6c&(9`Klh39uHkFrHZ9(#nlHqEr#6H~5+8G(QqO~uyFqs95vkzRf$g16 zzzWS}5gJ~pEiG3#+1|6d<7vz>?v@%24Ztii^jrRceizt=e=mojmHB3H)+`YsUfc>| zE9pTQCv|dw(|<(pct8Gp`ZOnx6MyA&FNrl3ZyzDlfP%dVk&yf?3YfTBap8E#k)&DI z&P4ml!-y++6qrBuey;e~{i=#S^d4^)W3#7sFd__lctJ+QEI}hyi**`HIM+z&0}Ur? zZb;cDN+Z)%6a^17IqD*{=NgBvb$ekf3AUDdQTj|IUYl0$6_Roz{ED%ZQKGl_>AwynS5$ZGbo8&~PCMHiWa~mLqsdHZGyyoz7>r+a z-ST)e)HS26dDO^IdAT9Dh@Y*PZ#&wQ=7`Sx&7q4luL$nV2}Gv|Dolu0P^WeF!ns=t zCICRk?|KM|8pqeGDw}CgQF9!!a)ImB5BH(N=Wn_Z2_-Pj5hk-Hr?w6!CX%X#Oi-(X zO+efz^&)giW{&kmfMD{IKRG}oC`!>#Lg@QlKT5FsRi3)+S7Cj1Z^jawxh4Mt+@*Eu z#qQx(5eUwo#8@~vhC8o$CuRe zo91waAHt4es=*Q>T-IcSoN^4?KI4-GXbX?`Zb|?PqP|hb^)f$qOr$KFUke>m`K0ip zo$NL|3{Ta=BC!!p&}~`yt#2o52*e+z%!_RyU}PHp$&ulmslgoWuCtcl*;vniKr`R+ zTbEE1{!^o+$}Fezex<UZzAchvvuocyvAr)yc zHrTMh;pr@L-BmK?vLIQh=4c*j1)pA$Nox?ci7JydoT|QI2;_u@6cKn~pJ1LiUyT;* zHVpS?>0-MYq%iEsSV!FPCq^f#@1efzq+9u?W3z?IECsbo#7zYGmNEG&GAl7MlR)Xp zx{V!A*=`8v6g9?Cx352%YWR>xDvNvy)GuzJwxt>fG@M3_K!fIYZ~ z(sE?a-w{nSy6_+PuenEL#DySWn)rI9UDH-Ury4&94TwDbDETgx2Tv{YS-OG4g1VnU zs%Z8JA}!$%_aS}&BYx1-#z)AsU4&^Ahk0LRy^F(V-cDk&ym{(o87?(Y;Cn=5Sl)kx zVb+jeVK%L!o~CnxEQ5VG7>|a`APZ=MdZugaWN|4G87^0?ALVOrMs}ZRKUV(zAZqY; zbk0*CkcaC%pTu}|;Tym90>N1rRcmkfPN4~Z-QYx0g0MYngt)p9So?dmoCfb(Fi87f z9CwhhETxNDE(8$iw0?Lt_LBT6yYnT|3vd6^*%U1|9e%Ec_p_Mr*XO?gP^vCm+5a)$ z)t0X9SAjFfEH|%9cfW$&Y*(>q=hd*Qp`m0&jFth=;#gqpvaBt7`-P(PeBjS4(T|o; zqwSDSwiH>w`xZrq5!;XnfNk;ps%q-num4B_eUDD$bbq4V^35GdoqFD)iH&cr&cqgS z#o{xw7IOrzG6aZ+M|qhz8P92zbcxVx8k0tDbqO9oT*K4loZX~Isn*(`+W3X}S9!yq zd5r-Gtn0CR#s-xMNhAlgV|^e*Fha;Zo9G;=!fYY@s}4_;3@g-{?d;hx7=8|mXO%lg z((**<2W1g=SM+!q@vy^2RhwSiAlVE=MlyenDsO={f374?bI@S$C###OQ*E9nAste@ z(y)ue5MJ(+g82N(!>*NYH>NKcGy1DU^W{h?>HFxs*~}6t09Y8fEIX6=5CzA%)UfiX zhHQQM^aF(|8#CHXoOhx?lsnfHtX^g%=yJK01+SZ_^dFUI@A69QZL#Ncvj@A+#%iSl zyfAzi6zj6hKeeD}jeIbLC{9fw0f7(M$5}^IOy;ra7koo9b2VL7A(osQ!IoNkj_pQH z7Ye~W0;Ida8sq1=y$A+p7aq5JB*2FG>9gx3)uKUG!9?tG2o6F|RGr%CNujg^5?3aT zIYLG_2qnUds%HK3R@reFvW$i2si@D6hXvP#h~<#c;ySnYndhtMR5EG0F7b4D+-855Yrgl=QTnokCz49^G8Agg{y6!^ zyyNGY2xWvxPjplm4CGJ~u=EF#Yq;UB@+4Sy%IG8R9U9z{a9WN8^(<$r1pfGY-dc4s zMmR{cnJcOqD25~F$F@RChR@HN?lH&!7YIM?#ytgR$M+9N-|qo!1IocZITohP{ja;R zx7$NI&$aT?Wbi>Y)>K8CFuh1Kbfj0cU9@P={xlmQGR!$E2_g&z+Moe(Z*&Ohrs70S zA3VHBmq?}CO-2qbNyhAjX3HV`_h6#%epra4h#q`BK@S;SbMNgw&DZf4qOR?0Y3?F} z?6ae{{Ve6Rg@c1EdGRDyt1#%R;kBegb0E9WH~}+^i7P%Wk%uE;aZ3c8C?ezDpsl*W zdt?sA80MtKVRJ46<#ntOHH&loAxL00^!XL}n39=0mf3LFPmxgY(K+GU!@AH|hx-kR zA-t5p5jZfP1K5=Ow~jziye3KYCgh0)1~J<7A;#y#oABIj$8K4xYh^vg=i?t+n)v6} zS`=y%SIq({E`qoSl!Ur(`or}CXGmYpSOam&ljf!jU70bQ>wmpZ|LLvP%spg~onH6@ zf^59iv28B_HACBs9!vI|E#`CNiUgpAkW?1xnwv4q{mRtCojP4K0akM{=vu={=Z zz!r6txn;QL#}i4iGwEff%%J+?@bEYoKdI#%BH3KkeUvLV4`0y;nDnt^=$qR-aDfl7 zhA-WM?Lr-)JUY_@WCu6l4XuwYH=U4jIH)JtdODj`Kb#quef?&dF*c8{V>VECUMf66 z46VOBd`-$zBEbIQMZ|P`IWmc&qJd>AeQGK_ilt`h==T~PNdHCfTiw=ASXWypzBZtZ z0ETuve#NRjm&}e%e%g!lIL#^fCk}SXn+h0wkK$S`@qLN-msPQ6fMvzSFWP?Gb4qgcf-;*D6lIP{gtpeIUxp+&Nir|dJO-P6LW*?lwjhEw-N@x=Xd z^0i6Tw@HLhkNfdtvG(+S8lv!n1VHW6kvV?4k?CQ7A%xBKS29DRs4JsS`={3xnbH5V zN%L^FOE57y1`V9cg)DscreXStJ|YWz`7TN~mI@B`kz~P}h)e6WTD0mz{Rju4y$C6S zwRTKw{O5{dU%%)x{G*d}9Kh=;acojZsN!?COz;Ht9;XMR;vv*h9^4p{`NFxdU(z6y_LBJ?bL-(+MXOZVHo@!s{*XZBH4VEjB0mb7{->mdf+t-|V~3buI( zKI3q2o$@l>H)5vbFxH!f7OZ&cY#JJe#YjX`n$`bqGyRTF*q=_wBLXG(=5BJ5c16iB z!A+glcDoYlz>#ASJvVkvqX!hcIDA~=&4Po>^?_+!R$R4o@(GPOifa1iv zzs2^6!XMZpnipAg*8!*Xv~5y;{?s6U*q{e48E+pD%lDJ{B51KS#8g!R#9HBRV}BZk z`?&&Wmqz}&Brkr(a!`gpjo=PAwQY~m?Ch=KCKNW9St^4qXrGu3ZGrB&B17C&B0_!V z3i3AtYC@uMd_Jx3B<3QGz%(08)9TLFqGwQ0-goWWB zKMHSp@DN15#Yg|w-ZC$K_6#&9#MOe)+!~QCx%dkOHkc909O_7Q798h?7LfXVqK{AD}^;2Itu$v`@VuF!9rsuT&*PL z_X+EV>tf}#W@ig!`gdhz8Gx?-CRXI{kbba`T#xjUn;aDpac{-ySO84d0&KL@Pt15c zbS(GM-2g*vL9C3eN5`Rp9sEBor&CB%Y~aTfKrl6`C|xlHD{ymN$pG+j^m+X&HN39k zc(@!vcF0cVmxm5`<1eVD-~+lz!(e?))oC7@7#8m%n?cVRJfG zUAjm$ChEo($9E0J=!lTZyO+MCuGn1+;FhPraDQ7-%EP6aem!w~+#o79n5fa6Wj~UE zhKP4m9Kc~EE`CZHh=_DTgkbS*#tFb6qa!?6{v(E|;WPuK=N4~8wlwVVqPJ44F*c*H zvz@Auh{LNZtQFpLMMo0-N}*V0O<&w$IwYvx$V9t;K(Ezwto?;_r}_^C%S3qXCn5mx zbU8VCHf#mU>s{fMO^NFsC*_QTV z**L~Xf6#yB&u9`uP zK4fsP?)ad!E4eHxB8~_Up*AKwaxa`E0f37zizdd^i4f9Z=L5XzD1b~ zYRpF}PtS@zUF5;=WRWtv_RM#}zh|cwZ$EqBJpb3dzm55;^}2MJa+ME7ML#N4s_Omk zaVcEw*S7WXSGTtK&YEi2zy#b=->zDBW98NT5+j?#(V_he3Rr!kGf4ZJu)ETR0mQ+ioZt*>I?#+wNh~L|`eg7tRa?#m> zZCCg1ZvTDp)N?+!#vAI-LfM;tT~sinnz%na z#~C`wVNFm(-jv8TYi*uC%9wRc_8`r>i#<|*C> z*WV$IR$viST-dI-W&;anbQYtphp*EMzvx11Z?j0VPCF)Z=PQ^eD;!@YI5Yl>=HyCk l293$RD|C``G(P-iEM9ur;>ouwpd%(2JYD@<);T3K0RThh81euB diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index f581a1aabd1868e2726fded65bc19adb97cdfc95..daf5d70e0e2d395ed3de231c5c2387bf0ef4172a 100644 GIT binary patch literal 10583 zcma)Cc{r3^8$UA?<&~|(ODIa4y|jp#iY$p160#KKjfxh#G1IEDE40#LElZMw7%c_~ zQAlDGWjDz(gE8~n51#)1F4uJ)zd7eV_j+Fj5AQTKM11V;esSt`gp`n};Z`fZy92F(H<;d(uA!!`$vZnweh@r=bj7@N z-xdm+nyi?A;F7Rio=>Y!(VmR=l5dWjh%8FY8fZR2rb-^()X>suY*}H8$C=*twzL!+`I25IvE_p7b~15w+ue0|O=kYJ7ul zY0dw9{>S;0^C=~}lIvHcxOT;xuVWs_vv+Zm?NN(4YPxyw!^_tDePWZYQb{xK&A(ay@OtByXc6aX|3_-G&)XMnL5im{tit;q)~emkN*ARn_vFT;a7u&_ z5?WyALa$$Fw82MOPmE)bvUWY;>F>hji)C-!S$^+3#^Re!?TxU4qvfM_sDqP|tL6}% z-uHVw@6z{k*=A$f%zT8zbv%+hWfcOYsiH>T6E8;x8LGV?uvg=sEzNdqJ_;Yxpe)?S`kEkKcLG9>W}Wz zW9{sfl8%`$49&JbeQEvI|B@P$3{(-4)pDpWcXRqY7R0)sAE`1l@47I`?H=A48Z#bp z(LfO)TdSxKi62VR1}?S<1qMsARYOPZQB`P_oa2(-BI_NA*M^4?ic)QKe|sx3(WikC zE*H`jkRnLHDqzEyED&=tz-kJuB>f}|HNUMH&Cn;1S4l10)*=#ZA%KkG#4kB$tD zglF?6tW%i_ye78$?aw%{=gQ?f^dm`PMckJNU3_ms)$~4EbpjLgVDQ0sg0lj3a-Uq- z$es{IDMV|R?Xh%}?X_18$!;HKsVSsYKXi!nwiv8%KQIi@aGAU4HedA;fLZwM_&64W_ zeKT4oo&*?}I6!1@B3``t?UkCeX*5V4q1~|w+g|&eT~y@wcvRpscUft~6TN>$lD~%^ z<`R&JUCZyM1uk1pZQYltc>foYIlg8pGo#I+Z&Co!3J>m*t zlP%AeCt#h%WVF!-E~~NkPexb^K`_lxU3hsi-}JWqz$DC~U5-yLTnNo)zNA>LXIYKf z{J!*n$6v)zM6~A{O~b>T(#P&S4j?}5{+hhFs(a9#DTdIy_i^SU=aw{i^8AxoyN9AQ zCcZuFx&8I&_Ij?Jx&T5s0grQ>rM+QO6J zp=$n;LLT#Nt_=wxrM}yjLq5FE;W?j03l~-X_hAF9SF$c2RxdJUO{MXldi~cdw0z2O znK1#;ZZ_|$S*2Fg`GvoEH3~C}Uw%d$A(QhtkMgI|7886H>ZOP-XmK>vkt~8$@ZCUO z&*U99&xziY3qSc@pQUK$Vhq+|PJ6ZKB!A?bnEbe6#C){x8TEF*KFBvOx}Umbf+=R3 zbJ%Drql7dL%Om+)Mx78gK34K4P0*I17pWRiALa$uKul=q4GrleI_~GiN28~(SR@8Q z^JN)R&f|(=^tq+ohc(I(qPZG|MK0ZKaqSGdiZ`K@_!R z`e%$<@Cp=4WRM3DmO&vb4#Z>{7zYszp%A}6mnr00_VBuN?=xRYJzMrFhe@A!@uK)? zD_a)PWF*iccaK|{Yz@CemX749riet3JAV|>K<0^)g2p*9wNGu_^(!etbGJAV zIj{4Pc+!i;=0g9UO?ihJ0;=_cvsh=Mp2i*9RX7rpJ9?F^w!1HERY@)3l@b!a3oY&F zr|(&8K9um)?L}W)tdlB{xL1MS;by+4YUJotbD5h2L0FPNyDz_qcf^pqF+!dn&EFxY zv|A9J*J|X%OAy94G;$)jvJx~TfrQ@}b~x=2>})dZApOxbM8xe#iM?8uuf0r(3ZLlw z-hk;V6^yA#q6#oQBSEN;Afj7wotSJr(Vd*gAd6*a(^jOE8x%&B zm1s|lgxag1rHVqypBw15OHOc_OOIwph3;SZl^`zy#*hCnkn{e*Q^IrFJl z4c$jGFv)(zPk*9}1d;ZG;=En?2Tz?@T~5!S94%^e;H}Tge=%;b+}AE;eNRXHT9cog z_&jsZ#$=ZMPPI?BgA$2W7ZJHPFR!MtdJR3xUzXDw&^B}ib9aK^Ml0TX>aVb)%v5uC z_)vaOMEoRa=1M5a{aA26Nugv(yTxtA$;c2-A*D6KC~3cRIhY{LY5GH!7Dpz@Ya>D1 z-9^(M+H=rsvp=TU{ebBYI<^BTS^rswx#s8e2i0FgWcSWf+^Q?I%AeQ=B}ouyRu#jT z@*P#v9}_tuYP+NfRHgqd#mq@tw-mKon_%vgm#)WJnutJ?iyN_yZ(=_yi3%e>i9ek9 zOY(%Jm`sQ%j8r0qNNe8-J9EX9iS1GZnxPi&(WQliF#dmrC}=5)l3`GTmKvfClEE@$ z0NMz4kP5aK1KzX@nJOc3JqG=>e;9(&5XBfGrTt;l(LX>>9le57E+O)98UELwONmf| zKtuxVfF^IW>a-w~%0%%EL}Ze)))@5~3o2O%qQiB$*)}#OU)pKBDy(9!x)on0+^wg1 zzlG&*6*xx!K6&E<&@3jRoTbEXONnnlqXj@@B=~Z>;KguZng;-59PAWCl3Q`G2EcY4 zhycK8$iAO0;su$19zzTOXChUnX{~P$ugzmhG?}B zQd)ln`Cg$tl^~=Eg3oYCowWtg-VnTpt3b3ICt|>$&clghM1V+nR=xHEK$Pv~l%4g} zE8J(mzYc8DWRUC7r_R6q)xIStac|CP!Twez3oPStt1V+EGcXQfeB5BpDl$@%DrArT z*N|y5q*8j$zr42LvUQ&hTc!O)pwA!p<(AHEZ1QS1MZ(Bc2)1(&z!$?kg~-cLl$Ie) z&yf7(3W6amKPx8r3$&AzXkQ>9uDeX>AdIunS#88uz%bk~Yh7V60u8)0GVu)z{0+F8hhY4yndL&`!jRCzK@fnQIN*XX_BaRv;DG}!gk~TPAWp>vaXEv< z;oOSLsVIyDFU`ssMS(dMkAezaBHFraR!-N&1e&wNtenrTBJxLE&Svn9AI|3ZQiLQ3 z>JpeX&n_Ue0C%pd00GIL!`TE2BCmgNX$mD!sSMs*5A1U~lxCSGNfD4a4JR@XM4I#d z5{V=7oWDd{;p~J}KI7fBivhTT*9ZZ?X|Sa;F1OjBc&C%ef&V|FkI|G2}J&YgOdP=I2eRx+i-9a zzn`l<3x3!>rY%%V-WQp+?DdsgDgJT_<>(taXZow z(w-Uh_T3U>Xf?w}ty5SD(ZXiDD(x-^Mz}nqgNPOp*mTUWxg<>h0R(0Q*meyOQN?VG zJTOEA)H%cDh4?aHb9!d3vTswyz>M$h6Q;q;#AWX|4Z^0Q!d_t-%*j&oM|o{8RW#5uJ%mXWGTK@g?lF)i=L(PTsf%erbnktNXou_6nl> zO<(T8%pz{$XYIT)_NqmF*jg~yY$~`>^URK1^n@tgA;uo%fuj z^Y~4^z_(4wnbs^@zZjOz3K*&~udgHD{Uu$bW6j(JOgF1bs)WJ2zCJ}3!5)S5MX=l4 zINy@^?TUGl26xEY-MykF^o>e_;LF2dbwagz)}oh2YK{kJ2(*hrqmAPiuaBhrQr@s- ztvgI3x%)ant+js`!;cE%jH>QA44o#kbMHA`Rm zN$#fiRDT+A>vHOy47>eX<$mz4@aWBLjf0b6TCgu(@$Ncpg2H_jdCbh^MX=dHSxaJX z)6k3N^dL3zggP;5^OkvOmQ|mn9ZrvxmDHwT)jG-PTRllVf=sutZ3v|a!%xHII}LtJ zUMz&}4MZ#{PgqhrV9K)!Qzhit>(aMp%1%hUcKL2f9|EI>f%ch z{9cO)tZDl8F-huZ5-a;c!-d6j_h&tDUU$MQAb7PB@H+{*9t`vdpy~!?g53%lk5!jg z%D^n@bzYSad#mnl7E7S$&2`%QbsJK;vWcL<-S4KQohiDzMk(8l`$=jRFfNaLI2oW$ zh!WYqztN8Amb71(|0HLAJGJ6p+p17R=-h4JcRbUnz(Vz&W-WVF`Y5A)D^vAd$`QNk z>cySMmyne2&^asTZGZa~7)K|>D-$c8k2UIM(huMJwS?3J6ffv}Z=iSx6b8YS#Ok}z z35yGo_wbzqh%{A*=Ela&_8YRfn>(D&!KtLg(kqJ_OM;yjMW}vwLO22ZCE_%0lSTcH zoIOZwV9Qzz#ZrG>zMQ$>l~(6un8X$W+O1RaC%eWoF!)J~+PJ}6Squ#p3+){x+7c0Y zm)~orQJ+^YKe6Z4{60_b^`<{#vga9N-VPtiu8!*}3`%Sl>~OlX*oQrC@O>o&>0?b^ z-l|x$fbR5=An-DSuKPkozbUtgEo=PUh=x`(M0oD`zgx8xp)x#V&IJ6Mdxw+73yeXCCoH*z|;>dLS9G1=P z8)lS7DNvVEs8iR$BrD$8@J5RUzO#D5UmxeiZ2^6px}=K#{nvu&#P6JkXs5sgQ;sas zeU`!5B9@N(8)z>lvo?MB>PZOktI!1lx;U9Z{VhG9AC+Z8wK<>7ZT&LRt-hq+veuHX zCuxaL7*L(u!tWKC7)xPI7(8+h`pIG>OnLq{+|e`DDh77-T3GVO_mS()i64=T|2h9x z%3fvteGPR(Mqc6Y_tbsk#e8j0N9MH+Z}2#$Y(pLx1e-a4lRu~Kq&~msEdQGjw1@&( z)K3KT9-8VX=7Y-~|Bj{W)sFOkx}cZ9rZAGgUbJlPl7ImAr-?om-#6B*hcXhl7I=5X zmRy(^(x5(B%dIztp3b>&TbuK`j2x!INj6TGgF7`kST$`^Q-xD)af;q@LewHVG+gnV*4B5qLK^DtUeP#GhFKJ^9 zT@_{;MokN14rT+1rN2)1Xgky6HhJn*Sh26pNSrp!`qzxaBL3Xvu>LM&Fqbe$#~(~H zyEVfse29vz`DnU(T?SthPBoim+J*74T|?Q77;v%zRm^5oap9I@`;K?h;)V|?V*8GN zW_zF>#x^T6BFXTv&5GHK5H8#`*bb+3mM9x`IDQNqEC7Iam#qNc4PGM(;4jS*0#XT^ zR#2|PNfAVyailJT4%@sa@maa)asUtx^k8|{!GS#hWgG+okeUV5EuiaMTx^awqPgI} zPXf`l`Zeo>$0_ z@m*o`?je?2_~w=8=;s{|yYw|x9ZX+maz1l7ZA|mvdKaz)gass9+~a>@NjCR#j`F zTS$2^@2$o7X8;b+pA7JBxa{$KjlhLMkJ?Z>|Ekz9`gZ)7bNy=(-HBNcsM-;UWe(@H zP6j;hZG5tKO67i_XMPrsk=jt4UCv%r@Ch@2Ypza%B@thlO9TW;ED5V&u<6uu2av*K zxkfc}L3NQ7yczIM9cal9 z!sOlpk{mMt4K2kKF}*clpgP9Df2o#Oscb2XI=4w;*aj<=Jpu2yi(x0MRE`8}=I&pS z23M-nZvo`tS2ts*6TYdy%tphVk=Q0qr|Fi@ljPxf0kxw>AL6&d~D@ohP zNU6#FDq@gGSgH5`LY{l2^ zBg_y0oia~smKi0(#XVF>x{e%%cMCN$w=j*j-IV2Sxlyp17}vzB3yo?y1_E$dQ?7f{ z?a`qnLEE^RN(gPMBke5LmmOF;C?7TQlI4NmzDc2&?#JJ)V#;v8GyQ|)2cMp5C!w_- zANJGBfvbv4tU~mHIb@aGN)}utE6v+qr;)s!@RZ7GtBz^=9RQ|)TgC5h>ku!}QyM#SWnI;l-;8S-(k&hg}Cp&Md&L-s*l^R`It; zA~)`@8zCx_j}x&=vk#7Kc3-~QVmD)7UY_cB>N@!6j1(}s9y@5YC;1QE*mx>E$5$m7 zV}38krz4slY=uUQGWmy-OI$6%2&bOyb#mI?x5sikaz4#1lubY6N{#8a1^F!)Ulk4P zU-8ot*;3&FYdlI~zrbceDL8t^iODqdrSa)cb<^4vr{Dti${qis_Mg3(IehOCDcVCb zcsn6gFb@{>4_Tx?4g4dI1ju_o?=Cc|ckZ87S9INiJEAUAuWu4QTK5N-?$0j|oR&Fn z!lEmFb5Z$3u9g5fCPT|9&edi5da*JtY|5!)>ibK=Q_8L`A5-?b({KvC(sEC)>8!qX zn_`3<^5h+|IvWgzy5eNoWI0kUHMg{J{T=G69ybeEH_@rd$`^U|3b(sOSNwxawUfJt zf8;>~xn8n<9dzi~hTH3qf^E@w)3b?^J9ogWcQZ!rohYkOyI$OyL{KtymzlVmBrvJ~ z@pst!zLn;aHU4k6E%*E!NcCje$LH_T(q7!)yBK(f#uf*ckJ|G*mwVbbOCi%;d(J*e zN_8*6?no=P4B0&xT@zd4T}Jq5+rPy*tibG*671%l=V&LBNMklUi=>C1gd3vlR*osO7er z3$Kt_X*DHyzn|0n(&Na8bLMpIX=X;6_nSmENn&5En?aXn&7CES=ISl4&g|-dXn${; zUUcls*|WjvxkZhUdxk;TuS3ZpfyzdT*VpZ?EhoTMvTwuksgMAM(_=% ztq>@5vQZ|m!Ij>VExEo}`1gTgv623p#jjdT>;`{yz;LL`=v({7Wsq%g7d=%ka8^nG7#C233dozJdNE;t~oH!P* z_%w??M>7t4v>@cxqG&Rq+@vUnXde&sWL*fHH2BlG%W@1JVW=m9H-Bc>7b&hMv!YUyK;F8p=3#jLz}ZY8(G&jIu?f7|#aFZW@iHGe`^WTo)S>Eo`lZ_pT@m zo}g{{JX8{slNd81p`6DtmGLlK(vmqG7OZO^8B zyjefLGKgHimI;&P=|lOk!7KI^cC;r<`5MeTo;$sC1Hmh4(VG1W6-dK9N3_SbKPx;? zuWh=I!kmLDE?&@{*x*vFzxtM*;<0b9HdQ(GM5@LUyfWsL&DmAwwa?h~k#B66e$#<7 z5n=lFd#|%v5ONf)xNW?=s8yz@A?`!=zrJ^BYP|1Vz@F)e6J|%yukLlCxAukS3?Jza z+E*{{v2N!M)%U%IrkNpaLAR5RMs^X=>L!hz(g)Nw)kpD1MxW&bln!2=l=(vUA7DW literal 57610 zcmXtfWk6KTAMV*@fu)yjSwccm>D;A}kW#vl5~N#VX#pukT0jv{LFuj~r9n_a5S9i3 z>4xR*`@i>onsd&VnKSdt=XoYx?~ytgF%vNW0Avp}R15$BjC%wFPy*a=9QgARH-LN$ z)Rlm5qbyqh00$nbC_WCf+G!)qyZaz*$B04DV}8 ze9q=?F7(PpLZ4jw`b!HIzlHrx`1hq{y@Pl?8Z3&3N9q+DVH?ZHeX6}^?$^Hf;%{to zgGa#POlY?`A}9afH*>#HpGUufMw#<7;c;*{6bg<+;f7;Y@XrAG404kH`-=MtL<9X? zFbD*sh=jo)moN}J5Gu7f;qbrL|Gq*|!yv`NIm)bjqo~W#=o~NVk2@xnvUiS=`%WCR zhO25o*|F=~ZRuLGsM!xvOjZHW?;(djZ86leO8N8$6Tyqnt{e|Uu- z5vGQOA`vO=$8GkM4B7dbGgt4}$3{!!#)0&A`*QNmX^)(-*}Nt1)qw=QO|e2HCK>H? z>$V$Omhs1G(8KUEb)l#YK*~xA0|e2)|)qlR44{P(Xi`xCos>NY2H))JN0PR3^F zXUMNVnaXHv-x3%pPQn!96rZ|8D(48CC(7SjTZH$pqMk}` zeY7^-&Qc`I!*9(gkmSe=jkQ8Qa$@Pg{s`XE_cba#;Q>Uo6inud3pYT0*N3a6i5n}Z zIosui@IMRek5oD+_$CeoLt&YHwhjz9MC8B5Qam?PH!Jv9C-aD(Mb(V0SW%r?KZpMC z+9Oj00u%YN<3$eUbzBtikDDaG zWnA8@tVPVbfnT`R&%Uy>%+wLybga+P(PmD1Ej$G+3Og^MYTe*oP8d$ z`Eu{SQ=IobJe>Rd?W1f1hR&6x^L$v}5d9hKeCtXCzCq#sF^s`s%l-(ety@-w*OC0! zjVnS=Y$=%GSniCW+EY%BMP224geNHqHP+U6CAgbDB=A=WSSQuSAIRRWD&*YJgxkDP zpDCw#{!^ww)wNDte-p{Iy|d`urA+9R4B?ePY^@QuCFAC?Vzb?{w*POQ<7C7~<+Pbp zSKDZdfO*an27rg^$4dOMeVy9J+M0rv8N$e|Xh6&n#7p#6fT0D+lHN$ml&i5q_k2qS zeu9epEuA<%y5bmjYJrd+se*drdWXl1ttJdVOIJWhLHF;qDmv5(?zHgsvGHuLy_4n;Nm z;&9#8pB;Fo1YND7hkR)tgwQD#B4B}o#hEf^TxaEzR{yxb?+PP|KyAbyjg;(PLzkK~ zprB6@vUFr%w=#S<5*;F86x6_iqyR5SaQxQH`FEtTxW(s|Frp&i-gKA~dUJoJI5QF{ z4YmWNGX5}zezd-k#G~=gN9H`j-={$tDcXY3yr@qUv9~5avxs@)bk9Nk4FL>C-+&20-p0jJ~`slr9iA0WZ;w)<; zu5nLRqub-U#fyF+7}%~W;h}UUg*@uiqWk5^G!|nm0mkq}a!*|@?O{9Q|EU)WukUqQ z?*|3l6bxo}K0vH6&M<}QDpLkfG2Om;M)49VE=E13(EayFel zwidX{iv^{mMzn{AABzD>KyYy2RfA9bbjN<6)>a3oe1m$Q3!Qbuy4-s8Z0;91JK#uW z1erE)e7~-`u(Efdy1+G2cKyNnd7x|HhmE1kvZbQ6fMM&Gi0@C)FBupNFl96c9j0yl z6HMifkI(18>u;qUGw+!K#S+8xczS|1BDbGg@!`&!pH9K?*wEtRr(@e;>o2JAw;l&@dTtr!RsGcKKUwDY7Qzykb^=T{5K7|t z)@9KVjAq`R5QGW||E-TKOE+D$B(l+1T1n)XR`foDh$=m^lxEn zXD~b~;m+tCMo&S~mVB9RFczUBCGRmsu|qXf8fZ?0{J~;f`Ei3jAQy9J99AWq_3wp~ zSkB(V&Sy5R1u&ZS8f}giM&kUWzwKDnC?Junyc4XK1gy@!Y?3u6bdQ``d{W3Z`uiB^ zk2u}Q!-|zLCNSt987h9OjD|u%CQ6lSMkDO#L~K|fb-JH%)V|8fWLy|Gs zB3Ms?i7D>=z83gKyYq%~E$;<|%+;NlgAC#OjH$}7%|J*r;Cl@_3B}_(_)?>mE`P-p zdBX*JN4oh2q`mHU9QwvfUVoE}3P=gqr|YbtvA~X!w6E1=<(+pDNg;nSPoiMi zP?|{c8wR{1hnI@*OAeS5Ogovk#l@$N!Fzm$pi9l*TzRyc%rxQF>$|xEmr+Esx{<=T zJ7iUG$~}7R(vLkgWY<&~c}j|Z>6$7-G+thTc-*QXkM9*SiM(iO8{7+_2CL8WHzr5A zntMJEf4^bb7LU&S6Kc8An8(Acgx-n|vO{Np)v1*}!XC^LU%ZUGSO^=TL>Wv!Wc_Kj zsH2h#T}Y|UzgZz%@E@sP>rwkB2!&9>!0;p63(BmV>eoetDf&-!T#`D#{D+ie)Uce% zMmlQ1YRAVm#;sqXCB$lz1beeasAe{P4?^q(R$$bFx+-O*S5Xa1eqX;X?rbWJU~$<2 zd2xfnlvcym@^7ef;U`39;;r8A2ypX^!#xkHGT_Lq>DyiFz&lnCRmNh~z&VCzGsTFp zpKkkiS!&^p>?E_5QL~l!&o5d!t#sO42Y62~s>G)F+Q5h3N;C?LP{;V%1R#AqKig42 zORh*f>>=GZ%NCE-7K6+}LLQS$--l3SCh6l8u{WMu#r_ks*#+<|2vl9#JyD?Fz&4;eLx*oqUg<6>2|d-q zxM(reI8Psg>VQg&h;Do_dG`zsTzrr8S6m5( zg?)6p)Zz9%KV=qf$taFngF(z#%PLLLGR$R@JJY z{k3j%xUCxDnAs>u=~%+I_vN}W1HU61zX!w*Vzy!hfs4%>H!-&l=QkLI?CMa3HJSP= zIniv=tSnG9jAzb-Fj9S22z6aLI0-w?6b0m=6N@~mY#mo*1&uZKptM*&Iq;#x?m7G4 zVFJg6^AE#2OFOQ#ThnQdqKL%P^yde7WEEpzbjy{SK(w2j8~EyJ>0-7I(o7ry#=deo z1K1xXR}p8`cEwcdU&GPYJR5fbEZfU6s%60C@&nn!^~))mS`rwd1t$T?NQUhy|JPV=dV_(=#`{m zpHc2&1+b0xX;X-MzXVCR(9YwN+*6v2iR^YY6f0Pt8~n6eKdc;iaCcq=B$68Zv1-=v z(r55A4AV=o5WaW!Ry`8`D!5*7A&4DFwEx-Ro6sT88na*t#F7huT#}fFpDT(}j_tT;)W=Q37 zqwJK_o$m8eSdQt*$rC6tH1K3(_J^_jiv6lG84YNo#2gF z@V*caz=OpvwdX(gkrjp@xm6g!Q5E4~fA=Zna*Hk>Zg)UzKPti6UJ*igBW@$VJSZ|r zSxkyeO_XwM7;DD{xmFF~PA~K&fRJEeh8bmeQHn5D&TxoGk`yjq@V|rY!yyA*3cxJ_ zx=3o;#O5CMr}WO2a$xb$5gZOdmc*u@i*>y|Fa%etPW9&Z`4E|C34|E6cTGEhFq$v8 zV}zL^>E0@2yF{WtK=(;zIgFSezcfwuqCEc=kwEC>MS|S75`t2o_6@)8s}3E7HLM-F zCJX##kbb}MUw}VSZVe<{sR$g+GI~Evbu0}xBO|l_S89iEl(y+4tjp9obfbcbl?p{i4j z*i{7S*uIU9Zd`?q2c}Zo&%?LnFI@DQUSqpGYi8zR+hoL*7{|kUQ$HxDc69H^#S83N zgUW@&s;|imYVd&sj2g>uiJjUPV{%-7?JN)G_Ce0}o7hGRAWS^TZ7baCbD3Lm1NfaW z^n<-~HcjEbapzlON9lai=yymP1Na_p);2#kG<>gdIIiiiHp-JU9p&7(L+3|@QhFRN z!6*(2t6uRqK@FThBATF;IX2p}8m8=0Pp>*7d!Kd19PwN2z8#QtNQkaARu1p6;j^dU!Pkv8kf&;lx`+4{+YT&}AYxfy7p;1tnZT#k5oHSSH zjua#d+Cxi7EYqiIGe|Y<0)qP9BN=DY*cw~!zc^@vOs0u~#^)h?CDkzw|{3xPJnA{-tc@KA%$XD?wTBT?| z2sl!y*h&7jf9z~0^U*x0D?Dg5^d#RBucPnS_uC$@TSQI*+Fa^(J-JiT%A_Q5=~>wmc{(p`~htE=kfv9ms?`DO7|co;@F(*tJg;QB8c zkO+RT0cB&Ao)ucXL_6+PwU{z2D+&<4WG{ZD3U`SMZ9rm{D$;<}3auB=OJkd z2)3o>ZHtK323TrCkVg`aNmpj|sVgWC&qi=Nfkyl01O^EGTq)t`=awoS1FK&SvE#s- zKM{Zvbbq;^zFTAFy&dE7B@}f))jZxvp=WJee>Na%b@PL;84u**APDU@Z=*|ZUSc6j zXkQl2C-@M~o-_#>-4cdfAk#f7g?|YE*FEYiPeq9uZ8LV>@=cI+3qPlUd|}rchcWna zyrdC>yi&DMq(^T3(!Zp4CA?My?d8v(lrn!SXt%P%3$_^?m}?^>xbJgtIb6Qh3MmZ0 z%gS=4FOL9V7-_odY=-$jh#Lr3ZNnhJcp!4pRe0ws>ign^Qc;C}^$)I^=d%8aQ9)f#1OS2Q+{Cz4o!&xL zqW$f4KKAGI5IQAP8JXh_^h3R20)+|sp2>(qA&6eZ`i%DstLVC3kE;(P_&){kfR%t| zpZV0r)eFvVYYNOvx7ua}k6r!=Z%@fV(9nx$)~AO0jR&5Y9PA@>5J*HY%%%bOj_;Xj z(^Gw~8hwGbY}3Db--_(=z_J26-hEQ5IJ!&vUmwvY^>ZQNGarA(Z=cswJ+jzL zy#91DJMl>?S}-QYZIz00^|gLwWeN)pcN@2cN)0MakTXsL<^;o4pGXS9oF77&&N-mg z&93LzT+igE#J-aPRN!%)BEN+r!ylrqDdKX&KXDv1CVSW>m}y;8QTH{y ztd0a{%ezYTb<@TD_+ysEZozK}tiU#8P*tbUuMlM1^OX%JFgh$;`hQ5E4MfU8#8Fm5 znlk#y=R#v02zoKPQVkuNvtRdes^HK0?-aK;K<8+IIc|e(UMP?R;T?#ZgRKD78Gba~4>I1xXqtaYLj5n!6#Xzo zbHXkDY8K#=!Va+wVvq?_o@>VtuCi?vZ_TjSBjy-M!$$NL2-iSCdQY#fW_?MUo@#T2 zxT;%?(%`I$7&60@N~D@b9Mc(XGa9Kc*xR@&i78t{9iauOU=~+bV zsYq_zByzXi$y=t!OyaDGqh=*QC%8bKN7g30g}Z?sx2tzlQ1nTu)k%)Xdo1FhNO2P& zk{~J+xOr6@HoaSMF)k<%)t|<7Z|Dc)PgXxT6@W04fPBVKKxWQs$udC<7}Jm4@g%;p zcX@X(oKaohT@ZTA1E!<2dj#oDgX~l{qQG~2tZlF^5NC4o9#;6-p{&(OTEQ_-pE->ZGdzJ6zA$% z5V4Z?%V*!%{47t*cw3p!so~q#Faaf?(T;87q$RuY@t*mY`){uPgEL2oZ2CTaEOXViM+3s=dx2F$Y=$8Jc`q+~wx~WelF+|Hn-@6M?;W zqJ4Q|8x64wX3;<;A4UYaXrLMDe1u2I$Nls5kE~k`CEz!y=jsR1f+BG&QK3`zms}rM zK^(yQpd}3ZfG_Mn>=qY|RiU0=Y-FfZ2jl6UjNsbK`EC{zv&IYKKiIz%H?@h)^L{$C z;PH0$qRn2X{OzFD^OfjGJ2gQFa!z7CLdwGE8*_H?T^2B@{i)RpY3jBl zmGZ!Tt$pBX4sL0czEDpK0^^w@9ILx( znI^YfXxpzgDq^OLQ6fgo*CERacj*Dbt2L<(1XIQ})+i;>LKnBb3^GvIP;Z_{Y*GA| z&WO8@^vqrp=iVjztr%UVJ2-zcuszx|YFYp27?Xrsq_>{16&sOjGtC`rQ^>(at^g8% zhS4Idt~uWu)oXma`yY#h{0_HKiTc3DdFQeJum?zF?-7}tww^JYT;RFSoC<%T!wMgy z@QI#Ps{*zE78T%uZyWN@VS^p`h)%v{>;)6~Nsml(J=BL3GG*r*RqGJHD6n4)gpV+Q_D{_ikXMgMy{jWH`m_RTpyGs#0 z-xzYO{~+&8_CLBxh5ajYb5~NG#4F+K-o&lFT2*KEpIdk1FHCQxFLlD zwu2U}3O@bCL$v$BWR>4;<*--YVDw;^(8d`B7uQ6`!2Q&evAUAi`Lbk*6fYF=oA*WI z%n9wwM+)#f2(Ysu19M6xW+aq)A4OpCjBP+m)~=W&LE#|D38ld$6Hl2!)spz$;$#>1 z$)DoYGqyUCb1bNfwSFcvGH5QN!<_45zuxG;30i z;=_dbHyQxYI3=Mw`$$D_QwM=L7uz;IBrvT3xljM%8>Nn(nR51Cm=ulhekb_#$GQ&N zG2!FZmt)ey6`CZ?w`b@ee0ROmS??~r^JouBOzs5)eeHCw^>Q&A5_l~N5-KRV-qVI@ zu703b)4CidhG^&5@V@FyAdQ&Vu3C;UJ5Te3F?R7A~>e@${kNP;%xW^gu?=a0u5k>Du2r)kj$lBwk`sn;&Z0t)H*aMqk z-4g|WEO5Td1%y%GgQ{Vi3Y^aAz#Tg+d)B#z^=O0Y&d? z=*AGSuqF`F@0i{vS))$g_D}lP`IrE1>Wb|G$fq240^T{vHU0WC6xUuEcO9O`$2l9>7SwS)8|_b zrRNOCaL~Tjr{&20?=&Jr{j+r565+oPR78d2)@8Wif}k-6np;cVidnj5{RQZgNVH zrSVf@(EYdpAs~>nj7cV}g_tm7il9GuB&~f<+9Jkz;!AjyQGCbEp0h5oVw z7iH&}`!(d=V^qHJ*fv=uDd)iZVpGk|;Oy8ifI#%vajo~i^V#i}`~EQQYeRZ= zAn#xLE4XBOdT+;&HL`W)N;YKZ{OCDv&9-ox(_noGmSRo9-3vr>p{IJPM2&+$aD_&$ zAYQpDhLDEI0K?mueCr|IOdnCX%kT@WlJyX&CtkU)CUj{V^=|&X;vRypZHTau|^O=Z4gj*1wJYv6dHoOZW=fbQ}rvp+k~qgvxUKh6_Dl zJ-SUj4ZJ+jb3tFwuof`r{)LQjUl6QKAkfIJWkB9?_!*+f1(*3gB-)UVOiM(R*EavH z-+k};G2Qbdf*veFA+&TfZT;6JwAl1|NCJCA4b+_Heo#oxVr$t{?dD|SX4ZG$RB9bE zRx6mNST^U{u!JI=s8DeIp+uUXJX6d}zp9%u^vm?l=7;z>CmjbW5h`8wJNI;p^HYFT z!azcooBTPe3+&jrit)+divz}C6|^F=dS3j>CZw&C}rNxe)RKuY|zk)V669Vry-m<@S#cMuN` zcTB^0^`PVWk&4E@{FXm|-F5i_L&1k9^-kZ7$-P0cagN73a8?2!5e@`39@TCKmtp-u z?zyrClrJWF4gRgmhVhU0kk`dnqL9BGXfu!mg-|(qB}(a7RL>5!LV6S)m~t1mNrEYP zqT^SQ3aTRT1bHWM{zS5wPjx4;b8M9r^Z`hh=xBhZ634=@I89S43hiAN=)BD@34)@? z=lXR>BMq^uo7#};T6u@-ORqI}xu(@k4Ba_CeR6eRM^mJPkKftIFBRw~5!K(65R)Q& zbe@q5JMhXJ&g_GUb+dLt{)EaNMM%hiY+vz+w%ge}Os@OT<^Vz2tAu~43!7U%wv=4T z+n!I}ou`t}h5}LYWV8#1$A0-vx_Fnp+}*DE&bNgt<2%>iMaMtvH$)rOpPC>xw3=4Hc zrQlYyI3hwTfP)ZnS>5>yTv7IskjfEytQx)FJ}1=px3k#Q#cAQ80DvpwqaEA$#g5m2 z;xBY(pW#5s$30JCD~@|KUzFGDD7W>EynZ-=Q!}^tXgWtKJnMuV#drO0-e}TW3b6v; z(3vBOYy(|G>loJjlkm~zi(X5-e5bIfd}ELjR{c?xzZ*)`_pltLB+*}uOoR2;Bl8xX zJy97q{!Ad{WG3Lpgt4QgDoago>gjV*+aVU=Lb(R3WI;6u6yxMh*Ztl>RqwOtvhO@j z_%?DEu<(OLhpsLeZiR~O-HhZa$V~EunkM@n%Y!^e5i4Z-`;P!+M=T0I?jN4Ot+anL@3^tI}&o|m&sxaEliTf0w zee2_vhcAp5FB8fTwb$|{?R3W{#B{-1jKV*N3+@SF6Jfw}bI-WR{fYZW2I=-els1!L zgXEmQ_^LmcRKE=Ok9dpmk$fYzung=&f>%NHY=EobogHbM+*7N5{g>nD2p zz=dD*bf?8E&m#-l?Gn>qK~;)jvnI) z+_Y65uOlAlVhY3Cg>(y+a)zNT||9Id$77u15{3(>rwr)(WJX|$o^8C zduUhS%b{Uv>nRkjaF4UHa@Bg4^%2nOMmNj)BJ~dzT1~O5cG{_)I@h+C_3ynrdJ^mS z?$&suqJ4O!60;j>$RF~?HJ>-yO_`uPjdUkXto#RQ`TO)LE*g87=54=q`_pPZ8oYZ8 zlFlQhN?_QIWDB7qHRaP4Tjw$l>r&SH)TXVjA{jk40=5eziPV5H*gtJbxb1}2_~s+0 zOjS?P=66P|_nGAgMgB8aAo*-jFQSMEWb^QcFR4)GiQuJrX5hzFf9@<{#~2)Fg4xHj z%6MYj)Gd7DWoik0I}QDpPq;U3JFHMiqW&)wT62AZ;r!E3*${Z=aM>e?qRUj8^hK{o z`BFNY6S^l^kTjySW(A6bFLc)k4ttZOI%~W{CT?iRXRgGA3uLR^W5Hvj(#vPnyJ#M} zy)c^YZffW_{6cnPq||r|MGxh-^-VSD!j7$d=1pjAx32T}vQt_EX@p2_DnmZ8Xe9&c zC+Y%(e6o=*4-VeaU7@j+oB}}K?Px?`TnaijTfF6xbIzZm<;3=BfWqIse`{IvAY$oQ z$c#N+EW-?^0y}0g3XdkrqkOF%fIyK;1>6d7%2;Oq>*G%Bi*BK-9Y=a}pf?cjS<4Lu zl2~X$GX<^=&4rr}u?~A^Dv835l8vVAIOWxL9ajrt_*%vBuc#Yh86a<^fSC-lAq=&Bf^j5;#2}GxOyGG9Xw3n%HTA9xSZ6dsO<}YXMZLwY@=lMDsLT=6 zUx9apJeCgdUyll~I9;+#ny+C-Od=qd$c^52^GipdP<+&r-cB2 z7H~a+F`8&hAeXgXIR3qbcY|^bhoMfkpTAHlAPT(IL{kqE_}>8okItCKP$ReA0<2)$ zp;P)Wo>wsSBZ2^bTIo|Q9YE?ZpYtfiEtH{#Vn@aQyZfsDu4v2cHEq{t)DJ75Xc|DU)q<{0}z!PF`A&EZqvc{np zbr`y@C+6Q%h;LReMN3~SSWi{tQR zOS7P3zlIha9zihCk`V<2GW~1ElM0#kXK8xT690QlzIkzV#V5U;g%yPwEM?^`(o^O` z6nc91Hxd(nw|hN?eOyKp-(P{jUzu!mdD$r~bH=nTxkHCJBW|J5=|ed~1$u{<8Z;hI zbvxg)pTMKP3!v1;B?B|Ueyt~dYp3B#wBOzF6S$gW3S^{@%$;1)1l#a6a=HX& zM}NE$?4=%zk#{4+TVe=2v)1@a(%Lj+aCKB(c>dK=5mlZQa6bA~s&UDEW$cU`mw$iL z9$~3;68qe{nIKE4`a-qFq@6*-#4|g6Wg(;6dSHPGlp_?mz+%%AEv1)QlfdzI&@ai) zRG|*n^Je#}P%}I#v7r?Qe+elOQ?LGbaH5VQLjQ!#*2M)gF6Q$9r%hvl)N+>q{rUZ% zfwx7BA@^YzGVY|AF_ybER{8itKPAa)Szh&|5no(f$FPQ*`ee+2Q|D^f;@lS^@xo`^ z{4ZrTO7{nT#6VY76MiDW@2RKI(vDynGUEcL-Fje^2$?oQm8zpAGQ^yeA5J2CJ71Ol zfI|`QVD;JvRR}H_AVs%$w5?iyGs8|bRN%MCo%Ac5xj1#zggMjQwlcc2gA1wk5z_@n zscSfWM(e)18zJWpDWyRxk-)NDAz(U2GFMbFgfJ|CpZrg7#lUoC(HRUg4B`g8UztWa zEVl#tnBqkvdgNVDzd2m)d*6=U{715c+Q1hZEcB#y#>o5fo)IJP3hJG@1G$^7x%!y) z#j>|}FWv$$TWhN52^{k}z0;PQ2&Uyz2x$E8}Q0 z-@5dOAx8x~or3JKwjY65&W~8tvYZ~J<(;D8#+Hr{snBt|GxR}O(gI+PFV$@$4F!}t zpGD3-rE%u~3#kgI6oyiBQ6}4nWh-V){#PSk$gkjiE3<06c)NFFDBOp4%niy4zI-;< z=Tx{Q3=#qtAl5knwG{-u2XfeJH~9EfLIJgs42@{9>_|_U0F3(FvWoSqFUXnf42_Gg z|2mgs8>)it-ZB-^j8>;3J+ z>7V)RQKF6>>CC!s?v7vj!MTQ>z`r!krAJDp@84hzFr|V=xvJ6z?|G@-L#2Kr8XGgM zsxB7myTNFZ+x93a??v)qx7pb41`k#LGpqPzk0IxPB~@C+P|%B$mjiby)d-RIxxv@L z7BXO)c}te)yqECrs%&iRtK*AR@{-6Read6@`8UKr4I${DEk@mm z-&b&`lefcBFBFv=i}}V9OIPyeknmNJyL6o}WkQ#}5&W(2IarW?a;4$DDjl#lPD;Mu zOHaN~jbB3{4-U9}_41@PB>ly4q(!U9Xb$+H)V2}YN|s<>_5;2Ps+aJ60*Os>VA|JL zjz}R^>OUw6ZE-q6vouB!Kf&dT-Y-Hx$Cta9XP>s^yXGXUuNRo-#`7GSp6*(@$UrW_( zL$3DIju)OKl8PBtb!?!p1NCmbsE2Q)(MBN8RI4TDO#H&Y(C1)CTV>P^k|lqWpf+UK z{t*K|p?XLt3LlEhXJ?6>n!aajU3c)-c-J||K4U4eO%W)Wo||&qbDLOjQtVG(G3mue zbJx>G?VCtE9(EPX_0L<#Bv!ubi>=w3Ym|qOHoz@G}zn ziGaeg(-mC>qQAQ83u*lNMm2lrQBQwBNJd9G&%7>Omv59^uAA!oZx zt~-C`&Zz%>@m#EB9pT=(Q|(5QTP=|Jmk12Y30^<%CLJaCX^8x8((i(o zH?{7g7u>f}6KR2b8P#7}YU^;uw!uVwTZFh~b#!l`ut>&99SSX>sl_&I;>qKN(~5WP zF9I~k4A3Qt5r=QZ8-eww12Fc?1oq7M1g*jtJU5j{Dn?T7Yj9Nv&YBLxTmIndJa1oZ zV{VZbue>5cCr&x#?eD0IZN84Iabza}GS5!5V-S>AF26)+>RD>Gbz^ZCGA>lxlOU*o zbBo}B#Xa|4thEO!Ah7<)pR{c#IFSSS1C~cU)nzJ4ln|$-*z@EsQTo6W`Y`8cU{yLg z7s{C#pIfUaZ59P=gFW?Z1Xf?9&~pHe3{dOI)pqwIRb#|N4xPZ*ljdANTlw=`K322% z;Gy+y#j_9LM6;D-0b+R1wQq;;0)_v)ExMeIlc3RIaOjwj{Tlk;ikd7_?AA}Os?O*o za}8Q?gv3w`@SRYKr#!aKZssX$_~Bi0x69mmAnWb-@Or5`aM@Im9ME!~TiEd|ZR)E& zksKD3J@c>g2}(~8P1t9mZyYyMX)p40Ef9a&p5V0vGuDm;txwh+e&b9c3b&zy19Tf# zJR_M|*xYa%x8hXN@2_ST>!p^I8ZrUrr1>Jkk*@Il5?PU&HPGT1P9sq{5A_T|ufp=| z`^YFo=pvRiDM@2oCh%c!AQoam=Z$wi@Br#7BIVU|usLy6!*9czMnHmp(2=N43$|eZ z{}6Hx-ug=4vB6{cdrfG8Q1@MX7svK(Na2n(B^&p0uP4(O z43ZCVb{qwrqXZpYTcnbmbQL06rvw4vwsX?mX$PWUJ|dkkWzi z&9}i&i=+#P6A>4Or|5l3@@Jh4v2KL+TZ)xWMa=PuuLcvsAp-r!{Bf_|GFkpEG}`_ovKPq_sc!Ej?D737 zPX|nGFJ2wxdhM^vCN)_{UI*LXIdKziWbu+AzsmB$9U3Sm5BWgbRS;0kWH!E^6m#8( za;8nX%Exb!XHASN%F?D_G)9s^Z0Mp>=9&Inljr$ZP7^`=?t&_<4{JAq3UxPrVFzm@ z`Cq6IFPIQ4z_+gh90KMGto9NH^vtxWQyW0?n`vGa3H-qmowDfu~#Ap=zu3pQM_2=OSOZTNdgie@nYDm zovo$g%9SO^CKGybyy&|)N>T#VrTP9j+#w8wlV!=OA|yyY>x}|K zpH*ilC^=e3b(tZaCyB4zEpAa0jPZ)T4coc>^L6HGD0k_f>`%QHYs!&G9!_LcWjsk` z%Ddw-G6IX&Vc8K0anI81Onn60*O@MEDcNPSM4pve*y<(s!8BCuvshS0J;K&`fS}-g zLW+f4kCy3+yCtP9N@?XUpn&U5cbgLP*YH95^omu7yWvTaS<#XTaMH%AAP?;uydRJU zRe@=)by2`(USf7jgZ-kgM`tHlKty;3PVsIEF?r~@%doRz)WtO7L`*ZiOGkyePDr<> zcjhMl7RsT11Ksa)5)gga8R)G@f{MsZ$^F}2t3}khGAtuthBRoz9Osj@4JncQmyBu+RX*`CSN{# zhmZPoMSguj{$?(GwLPw|?VIP@UrEIwNZ2PLkg6W1%gpzR*f#&46fgmnksbg-gbcGi zT()h*d&tLcF^Ntu=n?wy<0mk2(6bBy`d^pmvfN>9|1kL#VATckY6`HA$0qWuvvo_p zK6bj->-bk==KMx=zZAi8{9CO|XA=R#+cj3-xn`zM^kW@w#ZMLo9$e5c9u}|F-IzV@& zUpd8QS~4tFxna@TX?xXIvG5iq5)T1ras+L1O0zVtgza3f<2u9E(sloV_()_2C-O6cQmF#LRF7Uz zaR(hGPym_fKX_p%0P@pm@D;E%z%%9LHPTzcWKRL*FFFxo1d|RlHq1;0nZSlK@rggfRu=-w$g+raOL*DNBJ z*xsTT2)t!mzi9JplT1>PjWRrZ1do%482lD0MYr?)YwXPoE!;@_`F)VhP{9B>28{*} zr0Sc{fP`MwdppllK!@3mU;uX8GT%tJV|RJ0Ksb2muEP5xC_1RLFe4Mj5;THRY&g|Xf7V$0lPi18>oN)!&DTmDh-=OtcvbGPs+XBSP=OLc!PTXF%>1Fc8^-u&fC-13tV_Yi46T!JS*wZ zzWG=EZ=>p*?yelz6Q{Q{V7k>Tv>!?nTrrKM$wGHL;p8|sToJp^ffx{@de4w+y3a@(u2FoVmfSPtCmtcfO zM>?{2|K~G0;L(-rGg5&o{_o4s%2fQ)%8##FJ({|#!`3{qfUMrzZ~!~~ z#}1wRuoY{FOp zCl}9m0UNmckw{mTW_y1BkS)Uge+xLZi1@3xJPxSXD_~kSQJpfl@1St`s$$~xG#u0~ z3YB~#!UY(pdj7Dr)B0=T2*ry(wu7L)PF+DSpLYkC7j(2MYZ3&IgQTv0`hDC~h6G2j z7ya74$?5R?oj`Q`rtqyQ53i`9(2i)%ooJyMQgc;6E-72kyz1un9*(!DaW-Ft{HHu3 z1Lx6V$`M3^sVKBK$r|dYBWHy>YYbnn5fY990=UQm;6lvhOtJiKag~m~U61v?mcUF} zqY;k~HHAVQYh7DO;ug)jrM?89Kpu)1`e3x+91P;0r9b6VIYqyycjTqQ8C2 zg=@xA@9WpS1~z9|$QOcz?_4(Qb>~XYpT^&l#Hkb`ydfqJX2SXoDvd}p{I|ct0Y^eL zHNkN$)fkAtg99l#lzo|+0bb!f;s`}BlF1bRq%ZUi_N*;aTlgxmMiiIHx4xSd3P0O) zqCw}R0GkkgEE(9~*RxKS0g^BdmR=P{h8-}b=m`W>cIK6iAi3@zwa&P!XmbH8^n{)c zd`H_Q@|IoJB>^KxOsc>EXD7OM4E8DM$`;W4E|&8u)uiGtO+%(MV|d> z4-x&|adb%iAK)q;vbv|(82i?Ro(m}Xxd-^FY2FA)cu}P>^i4*!o8AkrjRYDADeM>b zByGni!sBoa3Y}~0wdH?0t5-m87tR$3rhcrT__%(mkRtV*P(G0?%>Qt51XpT-+h;!< zxtKfTzJp173>h9$bUmn|#!=Qv;ay2)il2-n^)x^aDx2gFr{$>q+VFLo~n&vu` zb_(pL4ZN%mbdSsE^FFg=4;J$$-U}Dx0GjaPs`ezAD>rz;kIOCw{*y(rMgYCtl_nya zGemH`t%~;ysIDsxT`pg)r*>dp0XWMPW!t}UwP+T?6})Z@HPhE&>?Ej*qpu13nGJd2 zYxQe8z17sf$?~KX#ke}i1Q(pAO8OL^{eLaMmGBNWle&t$Lz3Sxse}+lp+1Pmm+K1( z6s-wNr@v*pv7);rFTk5<#j2o9z>h5O5+C9M70gT#2&}WKL#8}LHCk=Fv)J)>2(lq> z^grzVHTX!V85s7Ehb=T6MDkadjm|c=LNKb^qJs+FqNra4(bYQP7{cBUi2H&3v3JEC zB4+LQNxHX4C#s$z-!~ses96FsxxMX(dB?-+-kpxTl(Knqm$7A8XcAU!9< zE^%s@2j%ArDC+-E_2%JFe)0SGd1eMYxC z89V;s$Tm_c5`Px;I+d)c_Ss}I7NmX4?A-pHDeTSuLv71#DqlBQ5Z?Kgof^vb(~k`D zR%>`Nh*0<1K zl(T|jMjU!2ODxgfd#wZ(cwr1w7bX>p7_vBDBrx9g&3*gRuPFZ85jryhMO{{qi15-` z>&;!{#qrPmfAQCJL6`iY2E%-}^<=D!Snd}hf^+@&%gm)TqDS^uNDg07QpEEbkfRAx zg>uE~MheZDBTI4beIBvMRh40jEznN%4Xx0y?&Wjhk^JN3QA~LMhu>Gw;~B3kCm(kL znHemh{YP8Wgtl%24=Q$Eud&6xxm?1eL<} z{d3%)oN_zw@%bIGZu{UDTn3`jFC#lOeRDv%r8%&KDeMo&yYy6}9&$B5c-+;5rfU+s z;9C;{@sAn|#SjbGpS}s1ZKXcsZM@79ih|;4z!6%2=mS$g_)B%YPo(seK_zakf$ZeP zBJjL>j?c+%^z0|xV0E%N!DH-d*$PSoQ~Djfv(|otb5UWj`>C_^)3eFpoHXZAs*<{3 z)H`UUCBi32t>{>+hU??urgQ8#*~y&^4;nl>$PAi`h)K}puwXSHN+Y^?x9lsjBS}S& z=CY`?zm~rV54iydW@=5x)JQ#&KCWesx0Suf17R1Ma*Z=G4K^K%t?5X&%6iUq?VHLG zD+k=Vt%G`D%?)>*%4EiYZ3@X;pl0NRX<1$I9Rosg$2p!`f8ekv^_0Z)NG{~x z5zo9wj;A3-)cA2<4j0YHF}@McR;Rku()%x8jnC|^pqpI%05d_Jb908$XoFGY%A$mG zk*C5(*EbSR_Q<9^?Y zX7vbqIBU9$(i==G3TlMrMqM-2`^x3@CUQbE;;qzb!>rgBp7B87ciJUym<{$su+F#ylc;t8 z-nrIPFN16rbe6t?haT(|iH}LB0_W=O`}>T?BteoB6?4i_bSK%h0+X={ah>hhM7~s` zL%~|;xEX?Nh+Kr{@iae{(B$`x_(fDWAEPm1ogt|^Na&#NmGJBF0S8oqKP)E6PIKS* zD~lKu8un_w6siydR(Xj2aVzu2SqL`XsNYp$d01DyM~e4yB9^OvdWG$k_wrw& zRz$q2E^?4QbGPrdqnST)#Jw#2zt+cXJX_)eCl%xQ>Ib`vPkZND6|rYF94m^}$DF5& zf15cwohfnZj~FfVnE6YI=)cQY?Ym?2Z*Sa@d#=cc*RM<;V@-lcVQb+Dh%mKT+j~xy zMM(p~A4UWo6oB)r#**fOKjh`5Wz|@VpBb#%an4h-v$1?OZ#Omd$!2Z5S7`||)K$7? z-`<^_1#DIhlI+lAZ`>OjYe{wuZkrG+ZPH%v)m93UU~yd_UJRp)l64~~Y_*rE+-$tD z5e#!a14vWZ?8>Tj-X|`XID~){^Hau#8U}ELEYURkf+A|GzFho6s;%%jlMZF9E$U_o zU}_;95$g8*Cd63xv9JNZ$Oo*__nZ+X(?Kx50C$FUWw8sy)d4rlGLvq7ZDlUsr2DfP34!cAW=Jdz6xJ~yB14!+>-@WPkrOWXcF|K7arn#7$ z57%`V-SZ*BiT1PKWDII2vOeoW;U|oZMS}B*+pa6)1yCQD*n%1_Ev*}ZgT9bC73MCl z?TQg1)BTfTuCjD!ikxbKVO*E>wdTw_;Wwc-CSywn3*Xk_vIX^ZKi{0ri@u#7%q zcYC&}CS6*)n8cGx{h>?`e$($c37^5Bet6)yYaiKvD)>L1`}7t3QJ7?;;Pl8>{YsC) zjl`0%y;i=|_QN5rQ4fvipaZX#DGR@nk31x@Wz8&0ZfkDaRW4xb3M^gzT} z^LO#CE~l;aznwkZlH+fkYd`5u4mg=9qWb^R80gBM3!Xjf@5BI~CnY#cv%5wio^z>y zgzyDt8-7no1~PkURyc6>^+k`N&c(#?N8BLSM6qWXfr0l^;-tcdJw6}Ki9g6MX0i{m zR1U{{)BL5FkQpu)-MqJlJpOpq$FJ2bdicY~8E{1$X6Iwh&$LtcDgVrNqkJd?uo$_P-6y7I9dR4j8;ESohxil!!mPbm0uzzR=6 z9~;u#B%W6YSd!CGCSe_qF2bf9y$jDnroNL&UqA!(=NqrGH}9Sz^V<)G*bpo3xhX0c zy8a^bQp5F~EC`si2{(a;IhpU81#%@p6ue@7n!fOm9^dPa8%>*{9#L0#j<;-{B+h1< z3_nzXRXUoCgke!t5X?*yfBe3#+FLgn$x{4?dEs_}=q%~xRKJNv3fc(A=0Ne1oidcq zxSj!D-`@}Wf(&Ig4Ryi2jWWLIHF=UbeNCEh_<*Ic^siwJnxobTUXr6%mM=siHTUb^ z_3stM0G7&Koy$&(6+F{Lak`<#*D!NZC>HX;K6a9(4`BV?nyE7R1&H_~XwwzE3vpw6 zCR;>7v(|@u!-z@8EGgXg1*w{UCQ@b2WaY^qQg)$i$nH{u?^XCHUuu|Q5nJ43{eiW! zCiFtFPHT1i`NHm{2}mxtg~q`h1bQ9t*zEB$9^H)u%1SGPVVVQ=m(?`+r1@N*O_?jeCp8vrScW~M+@38q zCwi|$HL00){dXLZ7{%X-P*i)q_TC4MRUM*@+Z1iYA`x;!L^thOn)*>Ud$lgpgIR1ysJhqJ-btNpgS$0 zrPXTY23-@xw<4I>S-#<-f7j1c01zAaWuKq%a0Mv z7f70+Caw{8iI*f^V&HcAh`2!`wsKUimyq;=y-ewAE~U+J5+2Q70xh!=zDiELx!>Rb7>Hh~tyOlZ0kmA%*2Phe@i+xyi6V;O za|ptW?@)CZp1Ne^w?7KMj=M|Ye~n{nw;@a6!Smh8h^A);jNP0xMg03Pf%7 zW~}`I%Tp)5a-V+-iLf7rk&9FN{enI``k-sGA>Kve)D};s67}A{ZLc?<968#kn*IBE_g*Bzh+CbYk7+nfAdUQ;xVOk z%{sddHPf-K7%XicuJ+TK%8#<&n`95^D?@3}%2E(gPnmc-Ao|pCe zyuEg_i+@)`w0@Twa)>_vp~IRH|Mc1tnl)_|K731tlF$Vgyk}p@dZiRI`Y`Ap#c3|I(pQQz z1@V)go8s^5kT0)Sii}gNeRJZM0Bt09Z*u|53z6Fn-EX%YWvxD+FG-joC9Lbb)W5Wr z-s3UukHY=|bjY6hqwu3yMN5TlCtJ(~14I=}@>d@hrtc9`jn?GABu+2eNs4%kvK|nD zs|~q<-^1)daRL@H695V1Y;nWITAUBn@i7xDum>^Ym`PS-eUw>vpD=Fu_U<}rvOu5h zYJdV_(!6{65vd`dy*_uV(R+DeN=ZR*k524+THM=@d#+$>!j-Swdrh`=*Kvk4viqdG zJwWsOZ-)I=j6vpfODTaJvDM-z^pd*(unH*QekS{OR6y5`SA8xJxmiG z;{qaf#LUxpa7zr zXyMNTMLYlb4!@Uv^aEhlLN4h^$FiS}iubETJ>Ma;nG`6ZOF3UI0ugGj#(0ixTIooo z?2oZP(hIE-$bYXxA|otcHzNqWQECyhT&f9AK3`nu>?Ba_B44MkG!BaiSESn4?q8cIs$+cwpTWE0nSeo+6_{5BF*Eaiu+S!|LOB3DBGUlm} zI&JG71*aZAL5?EDC@DWbQ-@a+fb@#GqH40c>2Yf<9J$m&MJEiU^#91#kz&So}l4k_H=x|9yq+tJ;y}Gn0$5$vv z>o2?St-{D#T+pO*eRWYiO?BO-%bzWchV&)hN1D{|5pNtmizBbO2x65m{MX|@0P-zx z^c6UJQOl|w`42RNC+35N@)t}wrR#^Np9%PB?SInN{khZYK0eT*Wt#$O=HAWv_cH=z zm>{H7cvcJSsiIml?#bKQO6GO=5KE{Q@LgO5=l$x3jU z`=*4#6|o`r3=V?yWiGXf-wqG&KGy3uhgQV5j;194?L@G1?YGpaelwKkIz9ioj|o;g zP*M~i?(`fGfxx6T6%rXzcl%Xm_6-9G*(xT;NuzJb@yD;Uhbfs3FhSy zhM=F63?GZKCpt{Lg&(V*W{bOFAXd^l&MTQM#_gqR==SMp=qDlGJKKWZ6BoM@$c!|D_j;&u`9O7aK`ISeXzy5p}3-z2M zk(vlvG~IFRSc-k=dnlWbJ>ieb<-)b{_n%+DHMY*6@tnSk!GUtM?T;Q!hzbnhhav+> z0UI`JXw-R0u3q2q!8y3}r>;qYV-&U)1N8_lO7~B+=?E@JCtl3PNveXHq{iXRG1CXL z?!G+L$DUS|DZ=oIUJHW5K*yM9Gf~4@XFC5Xy2X*(@pzq`S?fyyhxfBEDypQP0B*7- zu{7Sl;uC7u(-#8Y`ydDd$8Ltlj6GJ!Z<5!?5CnNFoUA!<-kwg}vkxLX}OFgKFDqO|BE#5si?EHtc6B*(=J zPDI#lk4~ieJ~V;MH|g*nb>$&8Z#IHWAnDx!BJ5vZrZx&QXN#JD9C=dYxN0`H;|*$$ zxpo$3juZV5jYy5a%RVu=$5vVVHFn|k8KUmB*GD%ZU~#UTM2l+c6MfQymP8PcQX^o| z_0pt+s`3ZtvV>Hs+~I=Q^%+LJax+5&F!TcZ9VNiYdsgCsx5)t?Tj>48uNOp~ zlkYYBg%Q|}`vgfn#o|Ef)OVsHQkyLJLvC;*j4$kT&>^e0iAh1d{8J%~4J!_r4`s_`_2{}sN*D- zRvcEV7G*YKbwuaoTV298LDPHwq_~=(4CwVzYc5VSz@rrDu~10=sBgeW=?z;%CviHj z!_8RIu4l`qaSvEtxNNXc@IC?@jOnLQ=z3Eqcf+(>_U|LSOyFdN;p;x;*;QO&s`2#e z<6Y}W&>?@ozvQ|7X@!b)iP9{a5eB|J3g`gVyxA)>f^;nWZqdFxT|$BPp50|A1!ThA z*26`7eYr&b2GsaaB+r9^fT#nW%k`wkls22+UUz#nFWaSBz_&2v)`5%&#lGVQ%bmaf zyl>lBogFpWuM@5n{-Hknu_QP{?9KBx+=@tU$oN~-_jHAC-_Jhg_6x7>YLN~bsl?0j zlfBHqKIh-R2=$Ws;jn^tTkCSG^Otsg#*J=`-5MCE$alEKH|9ODPU8A`x%Nr7i_X~k z=yuKWy6ZU_FxDC34EPToHK?jm#U7|3?@&X-kDD7k7RvX9g0b#NG-fDXB;r@uMSlIh zP1xo{>JSb=-4^)l8TrTrEx|J?oU~$L01Tt})Bl=$K9CylQQt&mkdURlUMEGbvg7%$ zTk-!uWE>t3N@GfA%xG4$L&Q1#dLosxLeO}(GWMc^9cZt@W#05tOj5)H6qDcM7q}ks zO-CIz&0hlUSA;?$XO6G4K^c|q{Z>C7;f>jF*aQ-@Vj_U$!smanS=_T-2k-fS?{>Ke z%b@_+?VfhOt^}-)>m`Ui3g1osDIWPAZU17(L>aGz!F%f#_C50bbmaGV>($K2eH0U< znxToZzYzP;5|{1TSvgfcJAeV6KAt93Pnx14-rwF`jhPlP)C~s1i_@QFd5l0Aqiec@fqFf2#!u}~S(&I+-3#%8rH{pBhBKXdbkw3hXz6i^CA|Jmv0pewOo%ckMmAjJ4&Dhqz4*( zWxk$Ay=hMdGHzah48)hUfCBL!(ibQ!1K=ki_}}Y_T}^+M|cV{XEuH!)Hj)0pvY}-qkZKTUn(E z(N{ivuLaiKzU{h8U+S8Nlq^5KdBuL53hz!PztvZGr4c~NpX*LpS9?c*=<**56v3vw zP?({R7`=zs+q-07@-||x7!3~p3Tz#QI~IKY<+88(nrVOrPd>xPv2l3(@CAyn&_x+} zZ`-C&l|l&qN$a52%#j`XPD+Rxs<_|&s=hhuj$OH1Im6_r5xlU8BIw{eg#;3ujJfBt zGGbQzm&}{@RgJ(O5j(|@rx7ECvry#|uSunR9N=C$<2KEH$F%I(x;0(4ETY)SO)BGv zI5L-s79T0B1_b$saRp(~$6p#S7Vkz)wT%FL@jzgOkBh>kedh3hp>_MfZBAmcp*o%l{hAw#2a zNdv^9g`_ux3_Ug75`}Xm+!l6I? zjG8_B|9Sz~h(^`my;6)tWJ$c8h17Mg=NJ^C@LwiU&tJR!`rXTygRbk7?KA2$00?hC zW&JoLT=^H5N`tRDzwrQxay}Oj7Y*p40B@OQ-cX6LJPr)b=<~zkSxZ>&+;lYSqh2;! zXAkXUU%JR;b{>Ub<9GS|#3enG#8MP-OBn=CtJ@jr zRicQKRC%68kDlMF}$Ai&k_xG$y{>;tEO2tNWP!ZovBDsgA1IV{7Ci2U*Dn#AegeR0OT#zL5f?; z7HYCI4S`+U@19E`4lGw(E4+=s%_unf?B8bjQ5ly`dzu=xY*EUWfvBTq7fW#RSp=?o zo_3n;}=V1JU^J`kKN145;}ev z!kOrfvxkN{^!$2>xow(82Wk0`uzm%8V;e_?(O>pg0rcDM?CT*paPHf5aYkq%n( zm+8w4X1ul{#0R)n)#W=GScA_mnygf|9QJTSOf7RuzFuseUH*NE)g5RN#BF7qD-Vax zBDfj7C7_zob^*u+mSf(E-=&ck=jjbbUB&p*LhUXbLGe#Gnhplq;}s-m)xV+48V;y& z))nVyL8+&GxZ;b@Hd(<-+_6B~rH#VmXh;z13E!HEi<8Q5>@#9ed{y0gGL#xFpaxS$ ziKAV!Bn8t@hyBMtgD&D{RAkebQbFEY=>VgtONRFazR>_w8>tYDodEb_PVy1(JgieHX`7__6m@;y^Zqb&0B-uH4gZ z{wmFvH{AbZZF~Bqjq?cHhFkx2ABvU;CR#u?r6q7-zPJbX^$n>>f{c_Od^2`!L{Y=r z<#9`n{>uE*?E-!k%wsyE_RzQs6x;>53crixL_XD4pF^zl!H<)-hz1>1?3mfJCyz_% zG1pLhQT|raBfIUe}@D$M9TXXly>UZ_<@P$pBsj zVbe|4jYmIhG!82J;CY(eM{&yY`#!nhO#^Z%6Xm%Z9eBiZ%bzsHNfDiUN{(~*TWg`h z9Kip>`uwE0GwtL=tq_IRgeu5cjdMoeKBZV7EJ{>ACH7V4`gNS`fCCd|lL8}r-%PUG zi=wKkPe!mooq--U7@PHsr1j*9B5)u4l>tBPqmseF{+AiT6_=U+fPeb8p8WmD==T)UIWTvm_;a@M3Eoe7!Vf1;Kkm1QD-{OBp2no_a|aSYP2}PY#}N-cf11 zZbD6n1pLwk)&^Unz4R4Bs^DUZ3sLqC1GnbFej6!>yKhj8Dn9=sz*_nSBQ(76e8POW zF<(gPaaH5##+W0&a|iKjW9bAl9w{RP2M_MC%Nok6h5{C%KeuYHeWG5em9uJkg;VocBZwkX8UFB<27)7rbQ zcZ+^((Zoq+y@7@fg{K9kdkG7Qw+W-C5VV*Zp(7Vtf8|ljxynIqoNSu0^?6#8Xf5g8 z?;YpAr)8*RQaXdu8^5IN;y+YYp3HyT8xt)KG&Ph&p$ytEn?ZqwjKFjjz#9I{=_?8b z5BKRLeHN{H_V)OWwWirFom2@uQs_0=<8i@Aai85OOmVbAji@mqB+{NN0kNrJjCPapthJnxL&-TsHWUcHz0pU8QNw*rMo0@lDN)5 zKWiLbDJ4k_T!yd;$p$UaX`t1EHbnhd8JZxFeDk6)T<1zQl=Q zUfEV0u_B8VUNCX)Y=i-~%KA8Y%ktH%r^AW#YQTDe*FuN}``5?Fh1uT%ano92<^-0m z{AF|?e3zB8BxCM6n6kYLY~P;PF#fDVrLNK<;qj zr_TB`q0BVTVWn{75*H4+=!n~D#F-&1HC}t-Scuqd@gv50^wueMzkr>5tkm%uk?Dm< zWo-V@QVv(XHk=a-txdmH%qC}?;$sJY~K zI7!qM*I0JS)68SQ3v5eSygJ>o3!K=D<>S!Z zr=l6g=Wum#2Uq#frGHA2j!Di!QT!&cw4{Z7!NB#C+n3)L$M=}ZiE#LXt~!NPzgu^V zuhY7*)e$)z9RQY}Mc9=JxH(;he*TK*0wD%0*{HnWtQIxcU?ddN#5^?NkL)g}=n|Yx zJ57XUc)WTLM^{R`k!}YDyi|_4^r%KXy$KOa2U*+MUM1WqXx8@w=*JPiI*ivwvu-Hh zr%;n$e%M&IX@hYab)XLNWX!9C@HaU_l*_c7vWO9pG9GXlmad$kC)d(l;yjI&H(c20 zOk)%w6ZYVhNu{D^$|1r2GaNky`_P$dYj?uHs1$r9gZETveg#7#Jl>otPJzhj0|ykm zgMW)|`%BGxWkb7__v@R-Ex)91Qlc_^XXQh9=EB)U2cB#FzRqrVPfYaO}nBHTSm=0Z0(uF-&7pXSejDZOF1n>}dbwXa&e z$nvz1o3;_YSMem97lh(k^6x;w!9$m!j7K({fFTQ%B({P~qP~m+H+?lcW;)M~5F1e0);jH4lCDb$5`)MP9PdTW~rwC`D`Yx=Q1=mhA)&lWN*O)GoKC+23(009&5^>RN^{s zLV8rVo)|8`O=c0Lio(C|jV}51b%P9r`*ohOO`*WV^GOjrfXALa?ob{t^9_a9yoea& z1a3ifM7Y}o%NVe&uuoflc)+$%LYJXZmo=&lFB20zlurKZk6ZBD;-?7A%Aax|s0tsa zzd|o8xm3smd7M+K$~u8xXW@I)l1R{;AonaRA~OZ=!LPT4G!l&?IHrMrUVAF`)w>aM z6Lntw%P98$kS`Brg%+}Yp3&h z1R|HN7o2s-$uqnl2QAI2Q%i=Oq)rTnA8l&_|2u3KvMk{qeG=Aw*J)N{Idp7;tlvEc zku(?@5dE$~oeP1XRKrcrUw!0(!Xff`zVZ!TynB>*vf|su>IYKz7jmV61rh|g1DVz;G_EoY`3PPe598G?-yJi-)SU#9{2w0J#bUr`B8fHB|- z-1(CnnxG&~1|@Fm;Otg@aPE|a{ zNzN6v)v$@_f@iRKzTJ=p?<5iy@A^>`0%<`n1-yg}AE(2U6LT<=0w;mw7i_rx&XAqxIv*PcCvE>X@qp-1lh>RP0vm`{C;}F zmgz(gT+#-?E3cEjEsS7|uKXv1=t&aM1vOnPkQ1ZP@9?b#y8UHk;O~clxg^yP(2^kD zqFZF*ylA2Yw8CZRKn!8k<}`gUaS%|1{sZgF(4-KahQz-DXmwtf4>_Ip1C$3jp`W7l z5fspq%qD@*n`R(z>L6HbxTQ&Z`dOx9{^L$lL4PXBx|0R}jTgEny}j78^5`OxMc%ZE zA~N0lbdWFzVN7gIcGH9;Bl96lZln?UEtg{S9wK^}I5NDx*FT7O2hINXv_x05xF$L{ z8_zm;0m<#P_txUGwPrsTs!T5dam0r26VSPI_!GVkXOhFCCD?yMU|l=@&}&YYcK>#5 z@S}S8_Zr*aH`PhY{}kmajF&_k67G^!+OjYU6Fr;y*Oz(zxX}{V*i7iM#NE=>l8-#y zt?y~D2%H1nMiTk{oR}v)BjS|=^0V1dt6mv059S0^bNh42PI{e>V8k^RRXt{m74)gpEz&n9Obrls{BCgnhGaNQASGPe%W7z!wHA1{nTv_|7RCh-5o8t$rF+q732>C} z&$@AhrUU6_*i&^-rHms_g3iCp@`9APuERNYZiaREpi$1rqmj2=>&6oQX$pLSRzA?1 zafP6YrwE)Ktp;Rdt<8ym6WVw6uZaLqUlZETlxX+7C&3GrQ^$y3uZe-#bF}#4Zf7$( zHtl)dEAwCeG2o6rcn@zJPWbeYdX*BY;Q+M@NvfRj{DE&@Tq1O2a(S$e z)PHM`C^E2k=`00Pka!vqRUG$K%z7?@YJdgGP*Gq5v>ewI3(-WK+MQSlLZ+lK2_SeQ z&rbI;UabCXvHW!BZ|zDA#%V4Aj6YHbEnZ11kdP{{Ue)D}#!iwLQKE3|7OUSjD<%nI z3qlgtt3Li000AKGI7BRWdXP>`1_o2%Bre`XpFhmx8067FaAO}Z^qW-(u_CvB^p)hc$>SzG+%?u;OxY&K z-9wpqTv1UZ?Xe;Gd;=aSN)Hw`Goo=~G{A7JV$_Lm>+*)61+QJ^yHi{UYe5LaOZ=QV zd@-U#~5&j-i+?4$$LCs3K~+Eo693vZw32Bq#P47WLhfVt;iU)5fV z_pO0t1nZ?C?5YN6oWJP@^xidXSY8`2ruFbiXV;#$j1=vvqi{*beDhN5r;NS93x*3q!1;LGpzGbWiJH`Loo1Buj2z(v~l)(b> zp(UrR!W>Fv6wD8)BVniK0RvKQ`>t)Nv(@P?XL^BeerE?F8SpmnwF*$@b;5NPvh)Lk zY7>VmBpEmNt(l-}U@KNg*I|-&7DCsZ!fycnPL`TEG9lJc3u3ZP0^B zHqb{&3eF;;4zrpJrO$l7JCwns2X8*JeIeg(@J4+giVf{#g{1`o`V0q-6W5oF)v<3s z?o~$N;QJ3D3ljU1!4%u?ukaKVfP0WyeRYX3UdXo=j)Rqcj9IQzLkOHwY%h+qhdmY) z0kIy6`xl>^<9N}8w4k)c`1DaynaC1gKB{sWY8SBZ2)R%GYT~QlmC-vd8wU)vkoX?X zvu5c-3UEJO=q&El<-&e(6UtauD_zrv*T^@m_A($S3J<4})UGpt`1q2hl zc+NayrOo-(HjTn>In)gHn(|^_-{=fhUCY7qast;^+6pzSUhJM-&U6WKCyGEAzRu~% zSB@Di(G$uv>xSzkM5zIn=;tm;XFXU2Q-hw~T8_5+Py|0b1^y1CY?hn0MK^Uo3*PTN zBsjT8u4$FHU|7YSN$~R{{bw@*a35jX6f)h}kusokD)rT`@;yOOED*#`K7%MXN2Z@6 z#b3amAN-iZC(;`G0juGN16%Lj%WnP%m9KvziNJCcTaPp2JDQlt3gO8U^Hw1Yi)kPy z+0?hBAa!SLwl1`;lfLhIl zlYUHm+Fct9@=)V@aLzX92JSZ99KlK?+h-Y5@vb3L8 z-c|5FECep}wma>wjSsMd(SsB>>P7`ScUx+uLh%2~w!%Oj+3u|`T~w2u-eB-mWr~N2 zR}na`$uSLlf1=>vuT@lU>s$D#Uk~149+jNvBC%p8+k^h6 z%~)UNpF?X3G(p!Zp!uWnF2Brx}&&&(#zfa1R>wHQ{Y8Ceq$@@VdErG8~m^-A4ZG(&1|Mk zKXplt%pr4FqPnYXdQdUyk(By>q5R-oMH-kwvtAg_0&9r0(8R%3(PN9>4569- z2|W26*e~+IV!2dLTM{|IMyZTM>WEUYflIZqwH=3?XOJiqxuddNO5gsM3xv)b2xRZP zfNNlWPPB5SDG?Xo_hOZ5_9R&vr)s`6b14Nk}z=j0aawf+aPhr71OV zkxkh%{C&1VhB*oxbopSbE#eeOv5Hcj^n_jg6sJHh?5fOF#AMl3-o|*L_Tc7U>fc;G z1Y4rPEt(}SbDKs+61;4LY&F8eAZJ@XWjl(}%ANW_G>#J$;2j?(xnJWW zo|Ei+vFs}Ong}Z(-TlNR=e}WIxmLgbHx#6}r%^^dzm^e_4okY`4`8cKi)S<;fP5a( zvwoo@4(rQt5G_nI_A?_Rvv*%U#Z(&zu(8^A5d#fn~@fZ1<@$pC%oj66(g_WUCBos zB5V*Dtn+>Az)fL?BI8z>V>z~T^dX!G5?9cXR$6xuZLO@HbK~OBinKG&EW_w=u1us> zHbyuau=xQBTzgIJc5fW_Z^z2WFqrd8_iRX+$NX|YyN0WSIqCN(m^e7!g{$lM+QB&$ z49^*}ROd@S{&~>{Ucls)iKj~^9x@SxEaJ?OLJ4J-*t_FCK`8RRFoqm))6=2EQvZ>9 zZZd}K#Y@k793;4xuX>z(mLCcXMi5_z@oTM>WMllUJboL_4iN&dr791t&U5y=I>kE+ zvaAkPr{L$)xrbOj0Zyx3_K3O?%GZvS`Uxa6-%fcZ3mi`Q8QkSB2#36d`&F4fnV-i+ z4%5jLJ(OF1?>l>2c|B^IM*hX2!T&LG^{ZsdGBI{jalY|kxZyJnAJl#%gP8o7#)*9u zJ;B~XpUmQ>*~3&CwxOAqhd?C6727ow|Q=^rF7QZ z`i=J*_kr9)%NsA=>bl!pQUi0Lic9zr$6c{f5>@fX$pLeZ6JR>a>;@;ijb+{m%^oHA z3MO*`A1UrzV7Tzkk=i=vxe(4c&8w|OV0NZW1QGc^9=)?dkki~hTg$Pms#(NZF5a3w z3gB?jjr!tdt(U=1Vs&2qV3l7v56`MN9_&RB(||p5ihskQL&v{o=SE-;V${_C@gSn> zncGHmTOKaK->sZ2$-H{C_veiU*3Q%Q_PMX9E2{=*IpKxTbJ`cnTZ77j4p>>-h6M%4PsR)=lAQIj`)TYZIcJE ze~wN9(LxbldTjJYuPZTrAquW63mTj#jfGGmW>Ro{XJT1+d*nP1pxudiYjeDDkaK0> zz27@eMDb3UbZL29SC`<4&LfN6?ZX;!!X8=Odp3jqe|=FYL%#>;0c+RVlTU&O?APs^w`#Xe z0b6qG?JH2mW&FyN0LSzYhjQz}VI)UP&nB>lktp;d$4liHZ8#HB?q0QNsZSOHvMO@2 z_&Y_r&|dkfBu;`ZDFSw``aKTDPf)@>*|Qf$#OVG%n!Y-&srP^TYz!DZx?yxkD@X{8 z?gkZ*kPxJ$1c{9jFaV{bWGFVBlA}{3rKM9E>DqJrJm25{ytsGHx#PO8c)uaoRo5=! z4D7V9yNpz}yXQkzD>I_|f?NPIWVNbZKmQguG4^M_~`rTzO?VI5g7~It(Z6a_cw~guMK>pr9_7bcHtA zRK^>c3+c$?t5V7dx^9wu7Iu<%vOH8aP3Nl%ws8v|e`cc!$Uu5NJxLeC8&uMMx($^O z2dit`;0CZBeZ29~cduI~t4GLB%KKaVyCk0x8B5Jw8@m7|TD88h8!09H2e9`dFu#eG zZ9bvaoI|3DuPSMQbb>NIx9&Dy==0I!kN(#qujX7~bC0-EjS6k6F21-4pgjMRv$SKl z0Y-2)?ICb|X(dCQRHE}K^RSE}jJ3%LhAApvKd)ZT{NEknjW#^4>cn_I7n~>8QrcVb zhM&mT3v+7D&{1#<%y{zWl;O;(p`?hY3HY)#$VH+2U?r{{ z{1H2cX9I(yU$O{(sR5J?C-S!VGrX@fs`7f~f41huL=3{XKkEZ)TWYk5dmqGDecO4+ zxi8w9=wxN>eSV~R`vc}j{4Vs$&xE4;5$Nwdxj=m4R z$h$z&(uI?W3NjL9VXmJW7e5R8Z|oKLS`T_P z8_*@<{P@JGmjUw?6(}Hjeq=0$D+6nC4d@UeuQ-e!`6z2c~ zzZidBk|K+uFOeWoy3T7-V_d%@XF+G z|Ln#I3Q$#)Tpn}J3kL0>P4RTa;ut_ZH0jaC=+*FDQ|@51ufeX>jlQtCB*elQ$P6mk zZJcEB0E}-RL9k(rH2y9;G=MI2r1z%Cw4lEl7n>sU05~P{Bd7||1lHjf3ZRL*v&GeS z41Bxx_ju;~RB{yPxv9GfFF_c*h7NHT2(@KV_XDSuc=-+@aQX@ns9Zui zCx|Nio1N$a?kT03n3=1(Wa9^w5V4Rj&=TDv)MI6?pYT+-azdgZM7|5|Dq)A)XFwGj z$o@nJc3W7xu{-$vv-{ufuOk80A)k3XA#1?^2m#XxOxHC*B}lK9FP+gOo}Tft$dnQj zp2lB_UuOi-AiAZg$i(xldk%5vt^ZE`LoU!Gp;$>9`L2-%+>s}sNTCCBTf>=YQVTof zj_!#F6k$I+$Kr$O}ilM7M@anRC_V#UN&f<^Oy0sT*pkf2^{RJFw_UZ8t%0@j2 z^@@sSb4BJDeget+9!%Y_<5VAdBp&tZ_R8Km9a{LmQ8nOkqi+lWQca(a)u5r%wc^2D z*@$d=PcJtW-jtgr!seS)0)SRt+_f>0FNGJy5*}dYvt{Bp;!ku*e7X-EcPzOb?wFHJfG&Jcywbrkq4X6%C&33gHB*xZ! zp%1JJ3vwW5H|AiurgOyU+qpLD2Uv#(DNq)M^!*6>EB7D$cY_V`id;p`Q8>C+?nL6c z8j!EnXOUXs68mPG%1AAvi}@`XYpL>3F`-(G}VH_^~)$f8hPt zA(xT-X_BFI^T)JqN3hwh>pc4JnYGW!vH_Mx#p}OkViW&orCs*C&XrvS+Tc7HI|-5) zj*{{uM}FbuYyFu|^9XCXAp=ggvv5cIsYY4s=ipyu5X4a7L}2o|Ep#z;Lic_@=#2@e zr{gH|;<@?BTkHA3*lUEuL7$PSp25`RHYi96`n3j=mPfkR=Osj=1Q!>#L( z5T&n{6@SLm0qvuke`3k4?i}MJ;@9{jY2yF90zlrN*t)!6_4=WH;~A)I^}i9%Kav2> zCH$J3zmZ|?<(P8ymMaJodKgwWEpo*>{e@u+6~8|rn7P=oMm_x>29^B?Vo>3jE1i;0 z13zehYFaBP33c9thgu!|u@hS$;Hhl-_6<*8{Mp$WpdnDDT|KZ3?@Kkc zzM`QL;6y>FEnTwz9?s~2I1b9DIHZ0P{sug&hA*LrvKr4>*@Cv0pR546z5k##*fKWm zBYdRX2r2X(>_u*j)H$AAXq1F;ds2ahy7?a#eJ|M{Nk$5k9H$!=cS0M)EWQ&VFqV|A zjr}A-cR+04z|usa74FhmCzF2} z4-dw!yZjLVDzfcl(T$jTD#Z5dETI`FQf4tgMbGC=tb}fxSzM>X6Ll;H6lH>`_NwEo zyDBcyHdgeD>;HGLea?mQLicoUrIgunf=7?|`C>I1^SbSe?vEYTCptyy0L%ww8sY+` zCf4)p?l9lw0V(#G3D1_I8G!4m2fRvHxTY*W+>@ee>3UG^s5?e?i3iVK`D*lrVf~(6 zH5PsD^<44ke*}t80X0JDW{RJSD(u$sm#fNj$WZ10OCN2#Z`8fY(o_om!Ja;+82IFQ-`|>Q@SRM2K%^{AF;9;v+L*SsxQX&*U3p8x!UdOAL%9q|{ z>y}M5Bjtf&N#fnhC{OMB?MD1cW+c7HA=oJ!S-f|2WbUiDS6q-?IlpC0Oxdd^ArcY& z0$H6!2|~3xNfT@sXvo@B8SRu;BV+7abmdR%0E{Tb``^*qK?iw7s+RMcP%4=9$s?iS z2VbR#o9_2ETvF+DC#*Llu0B^_Gt5tZctXY^+3JrbLYLa;ZtYk0-ku>du{+7#b)|zK z6)_K0<2Hbc9BK&i6|pFDMZKUgi#gc)1VeOxMUU~7G#;_x{jsBQ`*~)g@>`)2f6 zmGn^$!dMP#@{HB2mH0D|P->C)QQIf)M*XcLyG)oM5q3_Gd-3YQ&N6-yp)lS-4X`vE z_gf1hi=xy&f$t&LQw(x9#aFl#7+i4+tKAp$Y6EACkvd>v^O}Y zs{}M<_tV=M!b#|W-2!-CsG{@;kqt#ev&(JuY%%!mQ=?f@vGPmNZ_shLM);Gyp|?}4 zzlJ+#Fwgt{FfeafC+#N4 zO=J5~qtiRCo`dIFJ*~{4nY*~exI*ueLDmv%Gi)GWfa-d#~ndpNxa0c6deZwcn zM^*Z(8ttJxD4g~g6k>gh*Kdm816U0NKd+VixKbPUzbyQhSE?g493g2w?t{Oeg{hBF zP1TeG>5jX>4rze4MU4Ilm*S|*(`)3S>zBg}CH-2Fx+7q4E*98(DcxHku5xfg|Gc3T zhS^3BPW#2YyST|~C#g;5w>p%A27WKTxiY(vSN7pP9m* z6!PR;`5Y!_5VLA(@)U0`h&>YqginXH_tTHB4(EO3ZQcw?7k)-Ks6$JLo}J*wL>g99 z%ob%^bY2PoJ6T&Mn^l~8a|DR4;_X%*;B1=Y6uC&aGTh~5@WcJIUMc^z0iSb3ln3@H z93>WsEZtf?w(q~}_o1_QFeGkt*PJ`yMgq=mEPt}Us2y{G()4T$K}RcuU>T`Xd6y6-kWP%?q7)X1P%(MoAi zNugjN(aKAT*|+~zM2eAFd8+D1GjSQa*AZ}YaV=|#RnFza3Ow)aAu#oEvcnt!m6V_~ z&^epWtnQ8H`g+BZg$Nxb^bo_Tu=9wwS-x(M{fv zA7W|J!YL4j$G7<-lT?ZbMeP@SYYwAPiFM%{++EP zN0Q>5oT;$L11TudMyBg7Fw||`NO++`@YD^jXB`YfX~S!DwQD|ugjd#AmO2(n7FH;h z71np2iCD;m48fSVpeR#KF=8jO)U+4B3P&&TvX8^OL*@J;a=z{U^H24-R|h%77*-L-_imkyp^I zcJU`?cD`z6C%J6^@0DNQvSRMHv6D|jZxVm|h-5x~X56Oc^vDBTA8fqCTt7)gS=dQQ zq(51!$U$lzfNY=()#mhnelFs-;jeN-|ASUc#dyz0pFaga8bd#Z=fE(+jW>D@7us+t z(+#$qlh*QB^49zmFzGA)VgG1Vyzar_UHz$29$+=cg?5nVF%XmpYWB;0JCVE=f?iSZ zpj);TmHsHvanDR`C|f-f39JyUD1fL4CVu~)-iOkFsdteD1m2_@dMH#t2_A8I$SFFg z?16IJO~Se_OA2dLD&)PfObn0{wF*Kqjv%f6H{a`DvO}=jVwpIU;PZjyyUl7q@wPWU z%6TUoPY0ONa(XyR* zQSEi=XUJ!KEn(~BvdEnKKlJ)t0|j4mLzmZaIYcy16`T}&Ndp8)%f2A&b?pIH`gf?TypU9b2xV8zA zCdCOLC^}#CMShI~5D;t?K7O@yv6{-keyp)H+d9R}>%=-H57JT9AN=DJDJbh@fQfBaTl4^l=d zW<9N^Y7Z|9_p(M^U?ZdV;J>dc5jhMO|jyZGNe%5hBZF0UrY3~+- zEFemax$Rk$1T-v4oEn0_3a`LVQ_&sZ!j(8@vDxE%e9VVvWyN;dX=u;9`>@R0; zCThOB;~mtW>iBh8=?I2NyP(Pr{y4BysEYmK-~WDd(Ua6^q+cw;*-y2a{D>H&zn_}F z*L`;!#Ruin#VnhB1K&dwm}7G2^gMRlQR|mJ2yS8gmuZiW!bnvLRqyO{#s1mg1+DXc ztnfo>Bwn2k!5K_<8SmjuZE0&_3FAx=O~ z46=^|$11fz;K4>H_;!*`>Y2tut!0wx2c7^Lpf>P9fB1hsowQD4yKiuJ%aj+E=7D;-({2qZ5xztp>uE%=f&%r5$a0jN2#r zHM*?zMe2YRSp^Z-wKrMV7>TxyW zJ%|gt`MwWC`V1E66- zoW6}|wbf5@n}v3Ty%@O$p;W{RS6%Er?kWqAFp|==AL2oNp225lAPnaB^F@Z`fQmJ$ zr&j`sk(B{S@2dCi(hRy59Bk!^nKi;lAAK8;x^rlI^+D3$>e|8FI5p-ihlb7HPF%X` zd1Cf0pAR#u-g*{!(k42{^rRFc>B-ygyXi=lH?+yfg2NTX0|`Wwt7VDp!Fv$JTE9IQ z*E;M03X5@76Mb6$7GIl6(?@XGGmZ!uBvtJfwq?Hz%CoQ`#3C8b1e$XltKaD}{jf1) zoSc+bnF9Fc@$-6f;an+b%W{<~CPl?g_0P-Wlw%9mjcAg_NaAtAfh25f-S6rK^`Ncy zG)*6-(Z0{OG9|r6FTV7i1oBcHSYw$03y^Ol8r|Qy?3W;UBaz6^XrhTF`gaR@*k|lg zrINI*+fDF(SMW;?pqNO|xzmxOE|NLWJs1Z=8672~83vRZR))ljzT7w8 zkUtow!6f!oU8`fyC0N2icc~K5D>x!Orw^ zo5xCDvxpwv~g^5YI>M|I9$4U~zSA&-A*JhRq-sWwZP z#+w_ON*%u}swFSs^-WSvzj}Pow>2Kz*(zJ%#WO|xx8Ctz{fcMM4?^TqUu+Qbk*%%- zAV+i=+b}>{6p;-1Ms%Df^!tU6U}oJ{Nl1`p@}$71%~iVqYbe>%bOkWqtF~H`5~YQM z%)Q5SnGC94t7TO*SEUtUf9gMxz{gL!QZ86em)LQ*j|$rF z>0q;n()na;NCHRimyk^o;Bb54)J4#8c@?MyjpT(7m-uqXOFp;3%UeE99(={vBafG5 zl3}0iYRA3qCjZk3$B%~VEX7XlhO?fQGer>bpytD;GOlCuBWqk-K~{SufSdJb&3S72 zDGy5ArLfO)_dJSE{5!oL0JF8gI5(shg)ZE63=RbWA>*7fB$T|#_$xXXh8e&L0vWpB zEg9|eyH(DBb#)ew>Q9m|%n9mt>#Ove_j{>!254dba@$R9su-F}4{v2^-PdfV`^Pf? zh=_0#@Ue5&J|f)#uZLc&Ll>W-u6aS?AVN!2_hK`zf`8H9VJmOIw={5af^y~6)u|c1 zMlcPH`3rfbB9^m9YoqyN5YY^m650)gpdZ_#tA|{vu0whTzTO9|^t>;>0=nElOdKXf zwjRs}qK{=q(VNQNBW7%BHskuXFG%W#SF@c2+ax@FlNvOhc{Uo6%kdHb`Jm^g zhg*PGDm7Mzkx~=!yxniK`6UuKFv)CdmS!#4a(T=XvI}qR2Y16oHpqNMYxpRMvDN#e z<>ISDFo3o!zN9(2x*%#9C5LJhT{pk(L2r)lsRr_!0{ZQ$_OExk>$2a0f~KTT^zC~h zn0!jq2T{z$&WEuN3i^9*hr7&T_8|^~vXRXa!dpK;i*FY?g^g)IkKpn2m4s9X6Nz^8(cL{Kvn%N1|%4s)p;ZfxLcYf7EnTYOaG}E-vgTTbJn&=gxVeEa}0%Mh3p( z*ht*@DK)@Vr7(z|{My@Cb{J%^xu^%ChTVwkN>p>tF~%UznnCceVn=;5c`6Prn`3Ar zG_ZO^nS}vqYDMXa_m|_xCsvzRi)LM$GyVUc0VRjb2Q+JrHvayiqIU?M8l}MgE zL{5rbGd>Fsv?yuQytOZW*c!ZeXl!avO4(_AsNMj>aPWhY)uXvD0ppiwqy509y^EE7 zm+R1A*g*-to84F;IT)Y9EjKF?-nI%WjG?NFcG3RQ{2pVwjIxK!IqzVSIoRIA< z*;-YhJ9occS^R0)69D}0Jnw&7q*`YJbz$)G`DWr$iuMah?Z%LJc08JoEZY4MPDFSXN8n zx|Q?-6RoV7t1sKL>&W`24&$wcKQ6bz>3dQe(vJ(+4_i9tFng_AKNb2v@)9YLehFho zHGxQ1%xa)7u-U>8QAU-Z=>R8gt?9vNf~9F?wb{ahX{+#?UgX9BD8kDKVee$Bf7ovj zft4uhr|6ux&gCD2<9?N^!fn^d0OU9BZY+mKd6&Qcof3dI>>qaGj@Rb3WjHMcJYMXg=2IOLI2ym-9$Uz7w;c5)a2N}IyZRd&NIj1N#E>7sLnTqj7{_A zxs^eNkLK)=Ss{)r|D@B?ANG0StIf@e?mMN4;I^XLr4Y@pTz8~q_r&?&8fO+y2O|WC zu08p|M|1KOEp_4gB__s|W-+SfbR<}HdJS>2haecf_K+pPYx>!5bSQW^ z2_>+?OQu&AF%W_3?wcU^xcO%T>T$zA7@B^?zfV(7K;2ZZy2n{ zUqDIgQ>G86@<2H3){ksUS?A`W@_pu~Ib)ZBl+dCP{7iK)@zG4RZZQpV5BX16FcyFY zvW_IcJjy4r(P2@H?o6PfF35bbyI?nric#gtHxrGDbO)t7p>tq)rC<%8&ZvCy%SJk^ zi;45r6&wgSc~?GDq&p2@r$FiCom2JlW~65};_lWxE*db3GJhBpYxwVlkWp?o za@Zm0SKV0DMpZ7hbV@=&PyV-(oB^V#g9mV29dGAH6Br8pm#U=4PP9C+%6!UdYTlnR zzK~fRupP|bf5oUY^4C~2_mM~fdoIYuJH#4zUA?0_p#wVOs4=1bsI%X-C`9@4ThqTdFZNgdD9?|x%Y~C*-Xcz_^dMU<@2P(f}a{PpJNl(Plg;uzK3E?My}fA3HU9(xVg$co_RUl$a~O54w8 zrU9%BkO4-t)E%?Vzcdn8nz;v8S*D`9XHTzPS#SfHP0Ql;cJq_JN)p^isbkg{Y|A7ZNYCthbS**T1r@C0 z(8jnAeS>C9bS$-*(Ex;WO|AWQM=^dT9rGP#0-cw-53NH-GiVm55K<`wUN9(%uJ?W{ z!yby^sJDZ5Xz{}Kv$_O_LDS+>mbl=G zc)MPLZD2t5qIzF3kFbha^lSz#x#$Hh4-a%K1v4)B_`*P6e*d;k$FNV$J$iclGca}K z;7zozC?3B1JTdF!vtwY-cL6{aANr{ex6@1WU22t9LJExN=-_Bs_$qgAqxzD&boe6Y zKijfn$)6EQCje`AdYpWAC(=uI@>U0qCuMAuLD9k95;TkQar|&Td(8Xv#^s^7p3?D0 zCbn`NOvQ}q_PIg9;N;0a!`Hq55-Q6?>!e(A{vS^ zwgZV83$t)oL^+s?cY2ohDn14*WXG?1{6oL9R3TOy#Y-14yU1DKEm}V~#tIP(|0Z`f zDYJ?8m!?#zhD^SCbji-FP;%On28!heUm9nX-qT06h4om!hhuD4z12E4=5C^CuP3PD zo9&spgWLuze>di!4Ou^r~VLqXDN?v6P@i1<^wSwJ0Zb+ONeX%W}q z8n{0!8pW4InDJ&C(>kxt%cRo8*p&?USpf9WM0&5^lo3-dHeDCLk9Tw*zy-&C2?5iJ z5%F;tn!Q$~_k?bYsV8##-RI8M4q#Oz%S3idtBc_N*%9-+^mm|LW?HO&;FL!Lz-$3Z zQ|WPdw|d-Vy6F3lC32u|LP`9?vqpEw@_X|W7w{(e@di#W5#~eJG>tH+au;O!$qZi> zAMApoxLA5kr6uhMaR) zz1+M~Tk(3+?*QwNgtjb3hds!8yKbb+h|9i_4ZtHHxdfS0l-)()A6;VlH?;KpzIh#) z$!*>O6Je1 zxT+#IM}r!p>g-ii>6X_l&nXI zfqTT|!FG1GcLZ-DH3a)d;Ic250qymX8u1JTCcLx{i%nbX_I6-NzmPj%UH!zO%h+OL z%BJXtw4k-S>`%MP@B-cwumQgnTtl2}B$UoKOJY%K+biXuSceBmlhCs4A9-an`6m&e zgQMnaovziNZ+8yj0vHv@66%lFK@9xfL-C}lcid!@cHdpr4!lgTaxK*?Z%!xQ;ob_$_5WqKtL9D z;6YkO&6)q)@==4|ixFsRp2RIN@s!t6;06fP-jSl8Jl^$_J=i1yJ&ObxIS6a_!X&LfruZ>pP@Jv%%qy%8c7gg!$0VY8GD_b7=~uaa!} z-{>Xe$$)HE3*L(V`TJszj%HkaT&s9Ph91q5XGCr1EAasoY)xZuF|ue;xsusUKAZMX zXhPKe79>d(<@cq_xQtVI>JNwUlPW%%hbIk;+@??Hk1xxlyA;0Cie32AL$0o1j|^L;dL>vNI|Xv41lO# z3S;+=q9LgHl?ZGl22XpbtD1bzDXRSj_(nv}R}Qqd6IOs3gGwr`QCz}hV!{2a`Lozp z#m2-?-Erdcg22zBl7ivKyXnut@;k<#(OATO?zqvO{DAlLmnW6U%Gln^$*qKEL>28i zPNv9j@Y9JC&GSPleQW{Vw~ha5#PWu?+vD*@Q7$oN(13m$GfX79>8$gkBkVCsQ+3{6 zI}yIr$eY@Sc`Ca5UNDC~LA+RO&SY_Ed1ytok@V%! zx+p>^-PZSBMw5&@6ma^>g?_1a^`6Q+RV!)IPB?>T3vYJKlIa>~_GuFT&=tPlP8A+- zCzVrDj4JB{1|SlgUEY4C5Uf^>TW`I789V0n`o6Ku+Q)coQY$QA*eF zgGzLv9d|=U0i2vQIF)`XrtwDB5c~-pWpM~aJ|9?vgy|YA`YX!Qs7W$JcqB>Odxj^L z=JKgV2@6=`W1`<#tD}z#ubCjOSm#jso#RUU1`ia8Ikb#x7Y{#q?%r89ig%SYAc6v+ z9#uAqbM<#!dmbZ=SH7C)0taZ5`3G&pFRP)NS)9_BHdOC}FXN*3gCU(5fIQ8@MN0Op zhhg*luP|GfBdZhoDJ`Kpi=tAHI&3XZ<@_REBKuRoYFp?&N%-m5m81ahBn&rIguC=} zXM@V+m1Eu>iWAXhY9#>L_PC@;{3?el{@2%QMNF>KIc&Mt3@D+fanX$23g@ld*vLeP z4@I+O1c2}~OU*VfiP9rrRXn^GtfkUJW~}~dwB~AbW*pu$@N0iCx)H(_9?o;5+{i#M z7045^rcL;(Mf3Pg@l8u=gcotw_fE-j_NpUZdvWsyI^6k78yLzo41FJ$t9C_^`#>z+ zI{oYyZN~GSOszP8Eha_TxNQF#tpTXy%<)r!K~-G4cb2F{XpVXmnQ+AN{>ygukL}8O zMG+V6ZYPY-Lt(CZn4X7gz0$X>fn^wWS7q|md)bOJdN!*D6-FBFRTVLIj}r-_pJWWW zXWS6Q^4ZZGqHIEX+_U?Il3=(p-@D@r_u@hl{;vKF;Zt|rYD|T}!2=W^=qi$T3rlkE za3QwwyPL@NYS2ADQ))oOw&3fL=kvU7@#porSZXkZES*R@kraCg?XW+3Wrfs%6Nf<% z!=o0J9kNj41K!cui-+@{rZGI?xcJ7gYM%0AB)0U;02BFf8!n$4dopdDeE)Nyo$K$C69Gh0dVRw$qAg-f=oogsieur6Ju%ArLkbnbN9AvsQq$c&wMiPE zy5I1#NFTX-e1n+RJ$Ms;K-ae6D<2T$5%J>bS<*x)vDQs+D_^x8{R;zmQb-s&H|5;w zopGcRJpH|T+ovRyuLvCTfR@SUYi*kH)}&3+hZvbj3;`=34fN)V(JV#sc8Ym{&L>aEK)To*#hN;H zj?d=@9Ay%X+F%{1CKlxOS<6TV5%ldNW~O8lPBh~O4{vGqLH85STHjpS7B2#gXS!A< zU1$*!K=i0$*X7~fYW0<5j)YP%)m{*3%IAARH0CEZ_wUm+t$5tF++9!(-OGr#-M>RJ z-mME@I0XMZcgYdQ&PcvNxT1iA%4X+YA;Pk}jyK^X@o-~0BuB#fQ`XKXfKFbp+|reR zncQPGD$1y6_!_)IO{=p4AG)^~1vno}DVX7l8^j}C7YGkuT%D)gz3$s@g~i&qJ~SB_ z4SZ_C3u#vA9KP;06zTEHfgYan-cfI{BGc{W2ac2nk0OQ>6n}FBh~#AWosAoAe74_^ zUDPEbv@oVqwJ;e`rB5}t5ToAtEr$;)tsj@-mU2wQtC&B~tpC0() z>RDX!ar98+<4B;S$xdH(?l+FDPy<7oW?)dL0k$;w=9A(I=x5m3;rDn#e(yL0R*AF6 zo`wIK*tvDw3Ml=2Rc~PcckcWmP4~^WpTIoVoos<$)hnacT@+<%8is6ccfS0nNgcp*S`RsG+$f0+u=Q}^ zT;Q`DeFh8dB9RDMSjEwHe7MNV?Gu;_z8mB(Zm9H~^?3*-Qy~Ud!ka-<7iMDFlQL&v z!ZvPIQ{Y8M(Jw^onRzF|Ef7 zCIiOgqd`4E`wE{7F*Jk<-qP=?Z!wD?IxWtD5+%z=LAk0O7N!lw24rt4UvxW>y7P}U zRpqG0&+3j*y_Hi^nccBIt7X6A9ag}(b)})3DAT9rBHn62@Ts2c{`_@Vw#`U}E(~K- z;u*w4eUc5RCsc7E9&u~8(P%Sq&#_o|esDn57=%@-qvn~K-#sJvU^PR^AM2(XaY-l@ zyINAQicC9VZySnuzvAt^9nH(^HbvTP8>eo{QV=mwA=qK4_oW)ABGo?@+`IpxM7) zUx2;04hlOW8Bek!02_}8l-_q~`k_s3_4-l8*3)IOM`!kd@RE+Hh70~YP9fQ#ZN}}N+O6LV?!*Q5 z34vL^_L{k`4}rK9lz(FN4Sa^yP4v;OZt;}+EUO5#soZaQ!?1k#k0Ephy3mE8frCjJ zbbF$_N)T3d%ik%?@2JU@*I}PnT{hmhE3ep$1tS3w+!s8)aq;2C#i_zmHYcO_r zYrn6mSK1205aM6!?Rw1YQ7t%DIwr=vBP*{-V!x8@#91nbjvxWptjZ+T8S1`^2!oh9L>u zuNcTm$9FfVarW|OZP(Kbaw+1sYESU@MM|lfuXs8>p~Rh`b_BCjDP$K$G2agj`&-M# zsMx+n*32_@F8$pO=OuA>qdOWYF>Xk z%WzUi+pAyAS12h&o*GJ#N~cK3gt*@zd9HY<^%^DC`+)xbTgFAc%oLRMFgy536v?h$ zXxLlh;8%fS^*v(?A!u3Y7khi%#tC>U7$)JQF?{yt@$}iRpNZJ(icGH1rlhHK+?}=4MIDN${l^GT;Yt@2<~({ncO0p1G$wPERv%lxp2cI03>#2tQ=cb>t1PZep4igemCPcZo zDp%y@1e={6^~}7WaFRWX{jIwsWZC(9^vX=O1KqbYi8>*!t9t!jpP)%cuJJ>6=I@%$ z@B+JQ7t&I+Wfgv|eLwCd_?@I8cZLD6Ib=RJ@-Lbm*O!TTk%I7Y>Z`oG7l>HMSEcUM z;AYVpiC;2{9Hk*Id;$Hu1pCecb_d3!(gF;Z%tvcC-L6n;cLp3Q!nS${uknDAeMz8O zgU(*oY=Ni|5TuL#T-g}hxw}X;H{|)c$n1}Cnn;nQV8|-#N1b^}XhQ+m2CZN?31j}_ zgs5xqH@u`Z=@H8deuK>UG8at(h0Z{E$TK3Wv(0h-Pq@mq+X%hBm1;@C-VQxw58tB(f6JuiPKixBSS33&wd|C`|MeQ9MBN*3Pne_jBJDvBI>s4=tCv-*wtjj*}*umYhp^3)uH zVCph8`v-#DyjTjd>Wp;zLR4EI_2L>t0IEkQM|g6X*^;0@!w|$r8Q5M{BD-y;4{SYx z-k1*=&@9X~VRQ18H821XIcfY zTTeWNeNS-YhO<5I##mS-gC!blUK3;dxO_izkcIWv+lT9(*nZniI;!Y(0|(nfow%6k zgVhfyT?an>*1d2(!_OhGtzic0HQHpJk^0?q0T~XLB+>rfp<rlc_%{2lx^m@;9ULVZee&1+qt^5dPv|-o zgh_l*IYasRK9Q5j@@LEe0&K8-<16ust!(US2HDFJOJuqW(m_ts%=q?mtcXCu2|!n; zUGM`O{EWH0l`2|k>Nt2ze4&wX8+&%%#jN;=e~g>urA27tij}Y=S`~GH+Z+A)mcxNbcBxO_pa~4g@K~TThEE27#rUN>!@z^wc!QoIe#GGq?l#Zy~0d zJWZJpeu4tgIYEL)&>x2D!Z!eZ&D>C@rlZiD$7_zS#8q*tUKzFWY`uJ^k~Np#oSi8$ zNvSc8fLE;E;1SJU8hs^foR%+GY$bY^yyrPa)3X=$E|}Y2DlNo&?P<0^Ot%0V?-~c#Oo_RM1o3u zO7%e(xGN%OE`8eq1aD2bWC^;hNz(tdkaMxOTKF5O++-~nq`va9< zxS1YI$jpAJyY*KHTq>Rn>R+jWZnW`JndiI>rT%D^1VK_)#D2>CGgJ?WE`9 z+S)KWs5Ekk`(1yCp}xl0cj|cvb2XZi+#%tc6PJ%i)e-O$wgS0i73}MNx!*C5|6W9N zm!)4O65=-3h}|rU+)DI4%+y+$n3-cXO8RiSw+d8%gokszk6iYWsXQ*+_m1r5MdA)D zxqNDhOC9Ix%c=$6W%-M#mpmgu*4-z&0eLp3!5$L7=;MeNcN7cdO)4n&XuQUu91WH_Sd2 zU|0=|>hKB&FVLk0_IJMq-CWO&6VR8lyNwsmoveNVZ2roxQabZL z&@&yXJ{{(VVNc(z93PZ^`-%HOrQKvH_t18Q05JqCh5ypcSOWqPDe#C2F-?ue?hIt4KSwHru-P%wg|Hjs*iGqW*5JVq}KUpSZ zbRFU(TDt`VD#DOi`W&_+GUBtZAG+<`(E0oC8#ux42{@=;n0_%g7P`Izvq=V=fN7&i zT)^sK^<_!-@7F7;@HnT3hrVxeEPm1+<9eWB#_ks(+|lIrTqoCLTO=&)(HL@f0=9UB z>!G>M`M>{34vWmbYW-sJ{)~z)(NeN7Fsby={_X-L7dkS2TnYZUka zIk4D&wE8n%AaH)j^PYfi0s}V^hK-qHkX9t4pAFb%nXU4U<_|=$eM<*?6gJ6{;_r6w zBEunDnKx<@kVyE*bByN%lg>+fSHaZ2%{yYYX@dY;L`S+vYUBBD;|va?r8k$EpNGqaN`BP26>UMnjrMH!(;LPkP~ zdm}qso645Gw`={5&-eG|Ie(nDz> zY61o|JK804!EwqDmraTU4?)uev1B!R*}sC%9J%U38u>Y1sEb~#Qk9A? za46ag>miS&7>ND`M|r&6K=gaIy(0U6j^?{?4~U4wr2H+ZXpoUIH!OYVHbBcze6aC- zeE_pI$VztfO$CsXJ?<-!{D#=vfNdUg&wYGg+`*Qz;*_HFsv9V!*yJD;XCjrCP4IF? z+gwFfyomN!NCJ(ptm)WniNasWRg%0^$h2Wf?Z3VirGv;HY85;0n4yH}Tbfor(_@Wq z^_>=XbaF^UyY{faTs0Y6#wk?n&`2Y%`MPih3yh2e5_(eS1_Ni}6c559t_|kDdrtuk}vC*3lZ3!UaH&W!LwwpOP45i^-%5~vTszFlOWb><{6s|yP_z) z_No7;b55jCt0o!F1UrAHD_OnDS<}^_&+Cb2ssi8e^&AnT~TQyzt$t(9)S?bvsrz_8aw~NqfCqWB0Xb7*Lj~=ddV!jgzBXH{3K;TM%nf zsuf@Dj~KAjv1eHe0lBhkrGx9oJ-Pp+abDgOP!-^tJ+JmRwEs$pU0-G2 zL0VHABTKW>oZe2ea#`M_rhhJa#;8$b$r$1JBj*iS<2CVr?)9P9CQKB_VZB+ zpOgI}naQrLQzJrA*F!5fDnnP(*^k!@wyba@&M0Mo@Blv0 zp~?Z-=*@{8%)g*vj4GtD|b2(WK$)>R2$%CsuYU zzh=EwbEcw(VKOw-1LjeOujx(}+URJtq;l8aY#SN&|7+m*yKeY+wxx)EX{EYV9+FKf z@`^N`!$ainhq0^|1LQ^b9jYNl`ZgXcY0s<>LbtJN$6Nl;VjhVZhK{hm0A#93QQs>T zW4t1%=-s@qAK!^P9(nBqd<#pWR16;BFMc)Kz^09%n({#zSk_rX@e7`BfF2eQ+O%U% zx!^h_V}{I5bHzOnx@A9jmTs~tYsCWLw&X4g)6P6=JzzQa97(UACu1gXUaHf?a%NqX z5Wk$v6;xAd-K8GGYZEEGV2bPZq0^{4pG?HNjSbdMN-sN~ zv(p5#s?9p6enbP%A+D(>(ERMQywYbA7`AIrG{~&6vCR;7Mg6Jz9Vbj#-Ptlb5XelZ zRewu#uf+r%8+z`ptnmvZA5FZ$scaM#tt}>G?=Z$Pke?!1Rz+@5z0mcwFfGSY_&;Wj zg(?5LproC+jmMAFLcqy^_(@Xv*uy9h;T^2+k-y)bXtDTwP+V$MT903vMC}WE@s>UOIaY#$BbUa zCqHafWIc$lWl&NjJ2e1_7KUNm0$k0q5dfMUxrH&&rQM2csg! zuECp*lBVw#*eNa;r?S}oI8pBnM4eBko$m?KCZBQr(%x%a$3R3k@)drVCEec$e!?u` zx!HYJGl(U%)kn^Q-n(-Z`|t*17SOcZoGp>sOr}WHZr_300C*IcU_|opJSRG;vjVNj zm5Oh;iWW&uCs!I2dmbjGAJdt#b2`bPefj5V>7=q#ho%S*H_RqwxJ5TZ?fK3U%OltO zR8940X)EayISNz~V}&}l8?wrWw@1-lL}-doG;!jC*4(UIYA|tP=1(n>2H+SVU}_Xj z5^KJX*Vp|0GD^8Du>UH_x!Q=fihu2H_z zwF>GmQ6s)%9|dOqLcZt%g%893%1A>se((ztsM{~3qC|sxVXRj^bbnnY0vC~2jv=wJ zB_04ohGh4zvm2(9s?PalNCXY4yvJ&XsJo@y;KX6^hzi-D8mUKWBNAymapwg)gN_gh zX-|Z+p7>)@+>XwFA5RTo-d!Ebea4^Yh$~cC{_+weR)X$7Z>?n(-vG~Lk2hd^PZGPe za5>`1^PyRNFOwGD>+tsR6U~@U5((ZA?A=%wDn_Yr0@pvJI>Z6q;2w+hWR5D9vZ1H! zxLCIda%x z^Xg&3dKwcFdkZ+f6y;uTI-l$N_wdM()E5o%vMD@u5I0uA@@7c{JT%?Iz1y=J?~8du4|$ zJ9=nJ6HHm)qo_Bb-QM0pNM+rk(=aNdEJ!wmO6d875TxCi-h4HyK&5~L1L0S*;T{KGOfj~C3>|)Ay6+8FOGje6O1~+b=tu+B?QPukpP|Hn$ z-}q36fs&k8mNE{<6GN7L$G{yXXFi>5Kt~s7;bJ+a2dlL_PXa- zRit9=r}zvmlM>(%_-uLtr>5wnEOorKje)62SYn#re$ASVkW!Stw8yfj2;r{W-&Q!v zk|8PWefgn5t>V!cZazin7A*WJ0s$o;th~I0p~c(rQ|4ZcJjz>=KXbbT7LN|JIN z@+#NF)Mg#s@i1Iko+dLGY6%L>k5B4Yy8{ct=ljaYWWME}@x%TMaJ&NQ7)`7%SWFt< zsNbplM5ZP;G6aW17MGWnQhQu~&lQv^mXi(sYiwPK^&o?mp(@|tG|>i}daRtTOOelS zK<>asF<4cKXzJbe->g`-%$!DtES5h9U$3|uyec}``UcLwBM`i^v2E4E%0qeOiGnO) z{2$LC=2qgIZxL8#5(R^9%ykakuUlq^@e*X7bAYwWHayB7rA08(r9laYzP}=c^Rnk& zk-usn+hrr4gpzQZtaO#FNr;`z^k|%U9HPq&P(C3X-hwL+vz;p5xpUUFakD4>38u{q z73K{lf?>bx{1R;k1O$us8=_DP;B-ZVF3BtN9*PQYz{F2G|2~sEjaP$_D*V|))FllS zaDPpC@b$sWyC>kop91D%jBNiB>Pj!|xd4k-rPoQOgF4^mCaSUcb|&FWKFiguQ9@Y% zesw=>cXS-uoWSI)%b#^Y0!88O0yZHY)2(-aqaIU(l5NNL(w@T#DG)*S?G{Yl-gO0p znN4yC?O4J$e?5D7hm-5wD>;(9TIAw2VD)Ny=3u-AADxM+VAW6j9P`yEEC)H2q$k%4 zpD{S}`x{42+GK0S-M-9JVPy2MGMBM&-Q{#L=k`kFeO$miyvB8hed%+y zC|o)og#6W!X**^~zzEfZSDd`2z&zx$lvBv-oSGO~nuchsE>JgtqQ-JOUM%@YW?Ck7^)-1%8-3+#?oBpC zZ}<<(;IDfvA5U0=*5wWDMr1Y|N36#vOREG{tV`_0OxUpVUt0Csn;LL3GDjR*L-OV> zYu)M5?b_U1SLGJ5r7GrIr90#bBlQFlDaqL z1V)lm1$$32+S~&F6fgbizpX7y%dbF%r-EGBwsX4%5vrzA1zsUuWyFEZ*Qm|ALTJe{ z`LvXnQ8%tD^OfS{pSGs_s`3Mes!W;TGs`!C<*4C68KkS=;iS&-{=va`@W_O9lOgf< z26JY`2}e(OBMdx8PjU}|) zkQMS#KWqnTeW-qFAJaRgl}sA4{!-_c-96^VCg#^5VOB~|hnE5Xt;!fxlWM_c!n5S# zfftAW!jtszqu3=6z9?NP!04!zL#|RpiO>%(T?fsZ zkn1(XiG?o21@3K7fso2E!n1mV#1@p7p~eAm5Y5|p@TmtR8tV=0hFqhU&SzZ=_(Eac`d z5AuASw1wtX0@1mMMGeXcN#WU(^{DH$8~EJQhShyDnYHb;n_v)?r;F-x$czVc8~p^P zZ)d1q-#HWR%$ISlnowachj~|;nZ+?;63Mt((GGt+C5)eqe{Z&(99h*zq(G8ykXm?g zbCR~Kgl|##?op;YG#l0!j_*v~S%(TLwOTXn@8uVn8LGsc9Cr%>R?k%!6v6pW?@;H} z_5+Q(JFRbG=l5F6s@$e2kUahHypPEii^oNRquAZRnUAWf6 zeO2C-j!UQExq?rez@BM@(p?wX%Mf*d#X)kztnc0ruCCXB_9dY!2^3*Q>yJ%?UvG!b zRg<>{+XPcp^~6}%8W6ZQ7hX<@hkZt>7(VE=3ZT`Q^RO0o_g%4|3+6SqM_X8sE1s3A z8|B1DiI@0yxY&3pK1w9Re%{!UP%cBs$8ORo?fPM)aXaD14>!8JAELWVNW+IgA<4T@ zr(H50ff|lO#7h|9j{pq@0xK2?Y@M`z*MXy61-ux~=6`mD<&Dz`f*K9yWo*iH!l@Cn zKK}|TtmZbTj0q8bR%aS*4V$U=u(k=3{5fKly{*muYI*hJGoLS3B#0~V3KUbf01Y5e zli@V>#ks2I%u0HeIk8U!^z_w~7S6TD2oNP?hzV=BX*X8R(}W|NW+GH`7K}h`jHaIY zz0M&3(kj6ArD?ev>@8lkR`QkomE>!t0WZ7-A?DCUxbP{pv|?F!Sor?AWNEwP!7qIU zzBk9?ek-{SBJ+zhpaM|Z3~xfFcI%HJVUox-9$kH60oM*wP20jdx;5hA{U?eQR)q-> zsDX=r?5$b&Z7id|U*iql`$VN<_*v1nu2!pg?mHV<3bgNn2Az83Oqr|yd1_-HcU z0yI!7^;E}F3lpL=&80ZtyH$P>GCyGkjaDCt)N^`;x1CmbZ0hw#^BENjd>&>DFMr@Y z@Rwdwz^#-z>Vf6!)o#fc5HA%2cOS6ejKFsyxPzalPv<88Sz&Q?agGw>+P~VrT-f>6 z4A_maT*-)Bq-%0seiVIC!G`m25t0#$Xx+mb@+-y90{x}dxt=VzN-8mL`IdzUMC#Of zW{2|iC5dKv9;I^=953woXNs58-Yn1+N0}CuTxDNoOEusMd1NObrC*74GOi;d7QA}i7L-)s)90$&y;l0Iq$#VAW;L=Mwc}CS^~WD z9;|SOBjvfhj8)DGk>B;R1B!N?=pZ&cJsqC?Y9b_M*A3e7j)DrEPK(mWtp(2Td2=7S z?Of=e74bkyPQ`!qqdUK1aA3vv``qP=OjF`Q8WM`C1>n-8;ddvMug`z&E1(M-y?e$Q zoHLkbnBZ-cHiL_)Zu-!#84eOWv!{@Fp{!E5@73Tn?!>&H$fiNE>rG|kQSLE@A~T| zm?W1o<<rj92K%i)~q+H<#vCJ|!XY_E@ z-L;`q>4iFNoTbfyTeSyxPw13((s#*p-R3MLX)Yq*_EaZsgO;zjy7K9Ldw`^M`jxOM zT5V02MDJ#Zpa3bCE@lU^2X#-k_%ZKbk2#mH27%fBB3Njw z1Le(gcL8$*>>=*`U~z|b6dvoCT@)!Csb5?>YMCZ*D=ec z$w||3YB>Xn$qEQ{uzh`f{d!33NLzJAQhN~3m;|I_Ub@f}{d38Y4Pji1-u`?8e65>Y zvsG4SyJ;_Sy#!5KAUVTh&pw+}=FC;S=S>-JdzEVT)541a7MzmZXKJSPcC&-x)<$YxKG4Z%}9>R0bt(vWbA2nx!VxSWSF|uaZQCCxqB}} z60~7NR>Zqz6b4*a8u_5{a>VzH3PU)6ob>=Xx3gorcJGm4qLM3wfSvJL#Wu2%E}D8} zIskrgdvGH@NPxTy&(wB7)e`Dycb%U3s?4htN4WN|dcc{lW1$W`{2{eJdGj8d}fwzR%U*bbRVnE zY@kba9nG=lgYoi*gxQH|(j+Vmt3WYfE-)P>$CXG}H7v(R9*z_`B^dSUZD+d=P?M@F zy9-lI7ugw6Vrxe^h0OHGQ2 zmK6UyULFn>S|wssN!TLhgWjVq;d~0X7&=QogRKB+eZ`cKRQ**{pA)mWeDb=jt=Acz zzyoR?X`Dvuv>4tNB#nDS*O6pkr$R2_{K0NEz+^cqSoxz;-4nzELOmrZASnr(%F<%W z$f+iqUa&=bMe}~;22Ir5^J)FR#qV<9LPXPaj$<+-TE9Di0&5S%RJP+ zKxS0WeP*$1D+weaBBD$UrK3dTt!rG!Xx`C+?Ijqh3Y`)mrSi@irMfyl32C8I(oce} zi`pY*U~IJd1?I#XWKhE511u-cjW15VY9;bkyDSrKA;pf1vDV>%Pw*FKq}XVMY(SvQ z7f0Fb39vE^)&L5a665-%k*6H_;;`cYA^!ykK?xWwzyQM+FUA1| zo=geTf9q}uzvv)CGYyRXsh~AxRL`r57hAn>zVweBxxdA2Rm2*xS3P33W)Q*;2}u7W zGmW`t=1q5oBluIC51bx0;K%JdMDy9ZJiS(-Rs75>pdi<$7i9o_IT|JqZ%8oNP$)D=wUGO+jm3jo%t41(e55 zrJ6#K8>dz^$eRao)wH3a=UhnWS~<+D<74hQ_402Q>52~2i8LAH{_x{c^>H_RAPJc$ zeB`N6?ytOOzi}5&CaCN)ZA>0(f2@~;?#tHWf3IM`-kH`QB7l42QL@_X>u1i*2lmmF21U!!I5r7AZ2q@G5cLgdiq73Bj z0J$?#$o=2?4*73><$ukRgkYkbO~ zDxSN!M1vYPA0^Qsrf+>79bEXYw?-)PyKD4MLqbYo5@2{QUPW3KNJ2>aYiSYjku~S2 zrSWviSoWWn zFO={1LiQv`Zlx5nTA^~IR9DeoYiMN%#Gboyytw)4+gKyLgBQls$UB-{Y#_naMYYiB~{}~j~O2Xy2gCriBN!da(Aad&V#Q|K))H3L77Q8=81VNxD zTG(lt0-ZO8vvkl}@YyN9?nbY!TYCSPR$Tn?KNmcF=b^O{BEj|NmH9P(qn?)Rq|Y7! zPrmti!JbvCJv;BNrQ{qI1N9#GN!clIQz`>WU9?q2Z5sM$wW#8nvdkw63^yu+u(#(4?^MwJ0wmi~n*O zTD|BjGC#9F)+?^hRVK^#tR~};zuIEx(rvl#MKjCGvyY5ZpNQ3uauO}>?2lc0pZhf3 zHE=i@)!-i*V85Jajd{1`%2h}}u`Zow5k+h8-3)Rk_RDr&@ukkKsaaOPdVD@@u`TBw zzyImd)wQOMYkE6vuNnqex{x?`hX+n@tM#G9mZ!QiwHxFe*mBc$EMf#)=#-!5bPT!t zwPO-LMK4kJ`L=xoF|VvW<%h^kix1z;*vEMPF0j&qu#-5$`My0H-7kY4U z*@cBprQ?fkRDoel@HG0==P(8GxvHk7%w7YOe8WpqLxz=~QCy=DS&#c+*|Bb7wbgiO zKID5F<>bmm3=6vxaV^a6Z!Gl%p0c<-E=Brn0;eAZvbQNIbQ%zeg)Y(FZ6S_Y^i<34 zWM>IuBDYC>x4*oML%j(|KuT>`Bs*2Rf51=G-16&h%Vgjoc95}g?~pS8FtD!u;0AW( z6F5z7^1oq{TlKx039W9UD@_+o7$3K;NycSR1ZS#Q4!MRr(eIpB2E&Uon0{Lw*IUzJ zsHn$oV^Lzg4R{rqhIkZ&TaobZjY&sC&$t&IGXBQ32fHC)m|&j#gwq~p?`gbc@tCcE Vg@26N1%5X`=WQ`I)mFH~{{s?%?(Xgo+}%C6y9SrR-5mx9!GhnMbKdj6Z{4~d zy7!l^UERHF?|RmHek)2@@hd74Arb%pK$Ve}{Qhrj{;vxG?%!Q_8jj=N0Cf5ORUA+| zLwp7RkOO2S#Z*1@FZ~d6bk$NHKQ`MIdtE*5d&>lgjJ`=Dgi3`}6bvO5kjQJoQpe5s zQj%2^vvEaNd=9=<`9gpP1B(eie8kDev|q3roDhluMNLhO(dghhzomA)b?tHGQ`gzK zAk!&$t)@ZeM$xxjwcYi&8IbF7#sA@gRwy)q#TQBqiU4bm*wVMG=;Ab0(owi2hAv_e znofM8l;jyqDnOi@6WPcOL4(wkLEWZ^l2_=%J6*i4 zy$y&du!ZOaOqi{}X8EL^ALMD`6DN(4wJ}B>_V?LkVc~GddU_8*WxjkLJt(IGT{~-) zzb=#smM_(jjX4nI6B9^avOuBkfz|}+=XPNr=D%7%PtQP7fV*2%PhA&*WF1z$EL5Es zcv8|>Y7C>U#&)X__Lj6B%>sa~??V8tmE2#Ms+nW<1tcAVz~7)M(m!)rtsBC(e^@P; zW;=3V=86@Z0roNC)|t7myYp!Rs9;I}RB{8Gw86}OefSKXEHoslSFrmHmefki*Vppg zL+fNfzs;63oCDnTM@4UUo==KI6rn{mhcyQ61M@-(q@W)1jrL2qV>ft6At63mL?1U_6?QxJ>-*Y9(-v^X$u%>vs za0s0+^7I8K`54>DMLXS2fl7j6&ZvRqVI}a7mN(@*x-YRq$Ms&P(9a9GiKY$?RAV9` z(!MA&6f^ig!&0G^LgyRPDk&_i#|Q~+DP)iNk1bt;5ydr;?$xN4_w!_S0{*%xIK`0b zCt~g3CG?$bnbi0-UCeCx`vc@IwIi4ch-!rdJ5|a#VIf}RSyBaGwmQfP!BcKTczS-pM^ z)Wg9}C{CMw39tIHxP)*vD}+S2T%_fTze7!cG)%_;GKj&?uID%nbXcz+KA`fttlU=j zucGEQf2n1-FcKT%g&zqwe)&bQHGWfB8$0-w^BY;$x8BroG&GKyAP$@E+FaFnB52rK z`lTzWSx`OT`Qg%k%ZI6!Qy-{s6W`aTE$bu@R2x>}@n?MO7M9T?tB?whJ25s4Ct(uW zHk6#jRWb~i9C}fBXKOz$a#d=2klk7!Rp)oEL$t@|J+g$K9;nFl+P~JCk`MQ{KB7Lj z@i{*shHz|Ts9rq$4|e+KHkRQmLN*0Gb7=i8=P-a35#BDZ0mk0ot*-6ExqIB<72=5} z>ACfy!wps-*9P|?#^u^(6Fz_cc34z+(+Y1Qu#;gCmy{|EU}MW8fz?^wa2wbv zrYRPB9YcC~m0s83b{om6f5@96R6wOMWsk*nor*;lB+b~I+=S}{Yo4NjY+yJ9%qk%j z55o-qof2X)(?IrO)fB;^>pxvX9LBWvtuwIK)m@l*t@?-Ciaf`haSlz{J?kB)Tt28b^?&~iI{Y9L{v zor4vL%D?y8@+p`hhjh+WHN1!|ELv26gAPI)DV0|1=e;yo*Q0M;#J~*laEk(;e+cVg z_`w&*kT(3-vR4=SVQVH1D?L%{i3OG}V8D^m<)zr%{UN|6`!j`5P1 zAn+=O1by@_fCBsh9QR&9hqpQ%Q>QWfC2m%uD@2Umj@>0;5?&c-7x|tb0CZTB{3yJ{ z+R?vR({YcTyMvoglnsk1=?4fYGyJ+b{5dz4X|}`w zs+$>eOVW-DQ7@DHe0WR-oprs{?`A@2^BJN!ZevTnz^#K`Gng6Jo{K<|CpQz9F{W1D zKalk2vmat8H}p~>3?Z|#|l)x|Q@Q;tTp2ed@4ON<)+m%YZ>WWQkVQWsF0=$^!k4qm>K7sD{X zKqO6dL$u~OoeW*4{Kq_F=dr8bc2lLL?d~_sW84_AVj(vE%H&8j_AMb&f`uQV(C+y;j;Q> zZD|whX=v7Jq&xxnALp<7vT*gcKKFEZU|s$Z+Wv6W@%A);^Q)K#)Z2kE+pGXdTpdbs@61MRJ6cT2cyh+J<~h(r9wDala=$bTYh}}fGS+5|#rX)xbvNWVzGZx) z$F-{7}X<;WkL0n!O1PCR)&5jMJcPYBvzY~ z3{r^I`#uq4(EBGXBF4xc2K}^lt@9~bI6ktMMvR~wKMamBIWX0HTGv}=Skg0u??zQc z(vyX%zi$$n4}H@Rcp1126k2(%=POMA=os(LDb%+Pu}Fy<{)Q_9a?z{R#et_5#cgU8 z#<&w>cZ>>U;X7i|u@QKLn5kWEFfellE`d_NeZ`}Ye8PES+02maP*VbTW^ zf?t%f$bug}CnAIf@>UUBo)|rxW9w4hI(!|rDIBnd`|cw83h#OXX)iqKg~dw$Y!9~* za8MxQ7v{VWy99=@IZS|RlF<(0-2>!B<%Dg<+27S{ME152C2qBobTA7hY_!T*?SR5* z3rdZV4z<;=wDn-`&bdC%3?N@Sb~=CrdHO=lERhy-Ecnw1v&{^$D?l8?u>9~^$f0B# z93D&2lW%5cPspb%2!2mpA`;^Mzl2diUnIu3%>a7Hrq>ZJbCLGs*wI6dWEXj4t2eml zsFs2jMzhf?HxHbX;4W)nQ-E^xCSUG{?smcs5(o4hU;Oba(kYt` zJ`)b4;vO__In?GXuHDiiFMp@mt#Y0L7iQVg%BJzLjMCaLRBQ#@B{nXljyavA!%|i~ z_ggA>s-(fkXZX{ZWF3@P($oT(33+2AOBZfx?AsU-gmGp3)WVGY-&K279K69RWc>;C z4|hdAH`S~6Qyhmp_7Phf8g~To$Kj^u7^#REUt7f;>NHy0L(Evwv;dZmR>L&lM%^p1 zOxEI#i-8`xPtgVRxlTf4im>IWW5?UKMM`J7?@U%RkY>_}H$dpED!C@$Q`0d=Wf5|$ z=o`wRR3O+pidf zFNWOexVbsAnK;DmsE9p=obV)hnkp8|5MOV4_qQV{)17rBg2lbsRdPHiZ8NA5%xQ2r z5C0!lE(y4`jkHQC4T2E0!+oLG#h-O!eGSqZjYPz2_Q5w72QHGOCw**g0rQ!f89pZ%&+MoO{u+p?Ufl*WRkZ+{YA?eSn2 zAnakDYEFm_eKnW%Y;k{VPZe z2XB8DCvdu;or3IG7x=>>2{3k}(n6`P`l!!ADB)K%vFFQ2R#xuZ<8$2G?RbZq^#%Rk zo@wx1?!y|cVF*aNm@GZ0Ct`?skg+KD(`$rsqD?=c@(Tgj$+@!n;bxP0imy#Y9298c z7bcgB*;JC^qcRKW)VpN#khfG8{X)j)L_K3DF-G&t0DYnsX|j!Vf3dVVizNl;a?&B3 z_R)%cWk?8B(5z`;=v4cGIq$Ye&49rzpAI?q0h&~2JMNnB*RRv+l60B372ASan+)x#3Jy`-2*1J_^ zf|~=5mdw)5@FC$5OLe{Pz^76p`@x3r_M>i_iWN z1TNqda0f5xm&?Z$z=yQQaW`R3WD|rDX7%82kotVuAbTPD*@j-J&(|BHPJ$nA9mG>p zYJiC=iL2JBxhfggLtVH#jUGqYy2MjSxHKWE;ECmZ`il3-*Jw!klc+Js=GJ^VqJ*p} zT+SE6wj$J2*0MhYZ(UU@q+$;awL3?cwYZo!VjBG&xbgAVSCm3@m!AYFeWi?NAcK9} zsR<2=OZ48BmY17Dj>FSfON}yi-8D5G1B^VW-41DF{qUrACM761>E>kqF?IkWpe~$Y zY_y7P4Q0TDt)s~~I;40v%b#+h)*KWN}eB)kG!AMSQ*$UYRE*iD~)^LFO$4pDMGDP~ngo1npQ-D0lM*etQ7 z8c8M)-1su=BQ9zzdzwdtmR#t!AC6>4_!REV+RsGUiISs-&iS^ul2$d}hTq7)QeufoNGbGYDo_;g zyO8u07O}n+T zzPltM0v~wdA?Q2Eni2EDZ3>KnhKBqXl1t)UHaBR6Ozlv&Vo{341FAzN+qXA^hO_hl zCNco@D)if`d!*I$+Zy}R1RoZU$DZ8;%+-udwH+vDp_SO=`RTGSm#!w;W0z-U2&&nA zJL*I($OpiYQ8yrlLmh%#J(gj82-&g_3LIzBoCs9B@BPOTn z>1E6%({Ys+)t_hXV=TgGT8zwPR6@JBfl|T>CJowm!+p?PN1>I@AZOn=?oMRD)KaKN zug$GlAiI-j&+ou2Bq-W$3^>>8?Cgf)W3r(u0y64XXgkpO`xe+Rho2V?u6gOk zJ#KwRs18UXY}kenb1#K?^jRwegQUe)f5jh>A0F;5LP;o|+JDcnfuxU~boSRc~U z(McL!dvY=4mH4wacH{SPF1pFAjRTzf$Dgnl8c-(nU2;5NzYxl2eL8ZETT|X0ss7H1 zRx7GgHqKnYJtDNna81gf+abWGIpANZ7L73Zo=7cV;AP`V@^Cl;ih4oKBvc(vdYX{* ziVKI~+5Gt2D)R8i!CS1EFR*e;QPy^iYWcw71(^suF18V%+LxCuEa1mMW9V#~@J4YT&{?>eLf9>R|t+wHe|95yWEYuC4p zop6^al^9b?1j+~zdc!bX{Ltf&p1>~(ok-lNYWg>$ZsOM0dd_qAj9=hS(Q|*xK=r~V zj38#X`}7E~!La{bSXEz%OfW*LAQ=t3(rQDj+i}lVK`s`GW(~4Lc((c)l=Ozu=!2+@YBbJBpjBxEcQFl&^wn zv1LX5?I-XQa1ro~&?@?*d&Ar4JK7C1JnU_`umNA{a+6PE>+cNjTD$M)siHuV#9kXw zQFc2|E68Iq^lQZ(y+!!t-zvt7=Gxu})-qMs4#%fO8%j%iT~1M7iv?ck_Yz)QdDcUU zh%V6ay8bBUAuX06|G_2!xsX~Vd^?yhMMBLKDEDOkHUl@K?LL4ivHS<05S*KvRj&~s zCwn79H>(R$w1ZL2+4&e)@2G7wPw_Z$F})(ChV2sr>Bj zbT!n@3b9SjqN@l_i)&KY=3mNqh=MkZ5hMT!Kpodp4cnKtWVRJG-@ZbEoUJPfR_(=@?Tv{x07+{&!GD_%oUOvjDHZ%C; zZGTbk<+#Wly>R`0tb%C#W|eXv=FE|+e4KA9h6lSzGId~L0yCm9 z9=rDoMl5?9XkE9349;m8E@>ghD$T^;?PwvPJxOF(oVZBW2fHo&1_+=0rIQBR;^tP$ z19XMC0u!TYz+?UvwIu2Q%W5BV{jMjLyIR5Km;o5_`6At4^SZpmoyK_|=6cH{sjKG+1s)@MerR1QJgulc4$RBTAFL17OT&5Q9P@(+MU6^QM zia*h*E$Rz9(bOuV#ZrB#xAk8cmiCRyF`$9#-nFboiS0R=HFZO~TJm5Jc7Ei(>0+ zVDhNtQhfnW%ju_mT&Xc$_H97c1io*1i0wj^&aA1eK>9wkunB3~6kHXnU<0o<+HvcA zuh7P9?Wr#q``mxh6krY$j6&Jt1$KuYzvXTgJa@hbXPpzKifa3*Q(tj!>w8qLNOWU> zjY_)6p!cX@EmN(J?yMhNhKt$dEv+{u13C^e@#-JqK3FWAUg#l|dNJk-a1FNw1-zZn z(M^-QfC))ADLA*K7M6mu%v`@^t7_5p(yG-*=+4_QzUQJk@d9Rzx2SYd4NDwX^zG&-Kbw0HA`nz<3gk!#JCoTR(XJj%A)JvYWE#Y zjA|X>2_+~;s(%RnxA@9yyKjWgn*_W;r^x5Lb8Hx(%UDyPf6pxU0JfU!q1E*tdpMeE zEfMaXz66tR`~2Ye25)KEi+V6-v7lzg$K6UvxOb>~6#qVv^t2M-Umxy|5!!(SzNs*g z5afw=pi;>clR)(dxL{qGfLeVxbn!fRL4R@2{}UF?3KZyt9==C|Z;P+4`@PlCuV<-4 zq@57yQb+`QYQUTs@YVtS?+=d10TjLk9%FUrUQLb;lzB<$L08SE`T(C}$zG8*-hZQ$ zU90!1Hqi9HS_;(qIe<9dGQ3FM&%bc-a)qpUC;|z8>UQovN4*95f3UMlO4M=sNNlwW zz%H`?ekFRXw61j!XBbGvuPD^e51@Pa$)X|gM`_)}u{4@*`0VSyD&zPIyfSk^Z6_MH z8bs_Hf4EL!Y%^2G-*x*Fmjy3hl15JgLo_a8HqHt^V!{;$8(T46)P*y{aQTG*EtisG^ffM^(_?|B`_I~S3M1uH`BNORB7 zC8z{|yEJNtCW!hBXsKfnk^H*pHExA2KNnE)V1OZw2P{@1>HW&2O=X{EzggHw(h2c? zboL1bioCQxf)?7tb4|Kk###MU|MJN%NPe{uM2VG)y3Z!=O6v|@YI_|X6-;I2R>vw( zi37BEC>MtFgoe;9>|i-JNTZO{U<>J@v+$$V=o63k-=Mf$iXH@z;0ISIgyCe9pE=JrHJr!D^!5nu(R`3k_$`9$EHiAv7rBO zwfofS=f^l@-?CQk48CmO_8gr2Pe!By{yN9#qHlbJS$=ME#|9tBCmSvnEP(O+fA6yP z@z>{vRWUR61JqsFj!|>gXRGJ;aomLPtf+ zP4_}Jf%S05jW;j$nbMo0ov>0h0ll<_IL%0uw>F zq8=^7wcMtLQXX$$k>uC4@qQ$Z>mz&&O(&$OSJ-(}KVDAyjh&e<#DDA^4#2zt%Fk3` zT^cn*6SdB#UBmVKf6nS3AIWA@=+xj}Z>n&>&YWO1 zp-osfIu?J$1;KFXfoSOacXQDj!Ym@4KWfsCiUa!^IX@=r32?<45% zqX)VC@`r1l4fjn_Dr259BC&K%nZ(C|b_Q;TAng0Sd^YL7r1xckA6ZlKC5@3PexWyE zvl#IWyiyA^=ipHqw%DOael1tbQfJ}9CT|@%oWEfkhES>fHm&1;OFsAWSYwY{*IvhT zXM94}vpkkqC?CX{#4YGBHnnNFo+D91vYp4=b^Yl~U(X(-&&FFLCy~SFb^YCNGeC;_ z?9Rh6r9QSP-?hBy23*?Tu4%&aOo%|B_Oqd5;_)l&PIqA~W@eRqhh}k~k5HLL@%Vi~ zvCbpQ@)$S&`K!ZlWi&TWTad8D3(KIM$>EzGlBN)E?fJS;!UH0yVWGhr%L(H+8~n zBwvL1`Z+pGkXGwi@q`|=+D)7~{BVU%CW>``FPsD-u{K0i)0Q%#EC0^P{wBnG5QA~& zFHIfg2dj?`C$5+$FU5XnK|WVqRkAC7C&cmm{+jl&y7H2wQ$4Pz`z7?`>Yia1 z6t(Djm?1xJ+E(e5Qny@5?2<9!9M$Ufn!g--Oy@p10X!TlIne5*AHKfihzzwMW>U!8 z=0n=`kpMMW|N0>M5U(0t{YS&!pYD8ekM9?9ES)I|)>Q4PAA|+QF1`U%)7(!Pv<&&C zFP`))rmaKsbi{&<&+*6eLFPNKY}#4ciK_QSFWg)<)-3+|wPiSg{eImg8SI$4hrW(N zVk1KdsJJ5Al^@@6&ycv5d2fvpYy#YnRphmVt~+^=9C(`DgglNvu=a4&s#4zOfRa9* zSe2$ov8dCEHv}t(yGmV4J@9o~zIfg%c1V(JM=_)&FyFtOPWh=Hwgdw*$ya~iy05{q z5fWu7XS#O+rFSdw{)xiHfi{AFqA;#64#rQWXJL_opKi(`fJZ%F2!@* z;t~Sodkk7h{z*?rQO$>A2_(JeMcvAlR&ZnfYfu<|eErgT)xW-R{dM+&2u=3=i1&pN zC_7rva5>ep3lG%(r-&Herls!9$zp!ZEuW_)QT<29?g5{3Oo*g8@q!$izy3|naocTr znOT^J403YmdGV{_x$=nG68%#{MBAN@kMCE~PuB1>KqpVE)^ZOG zoEE(|YdDwlDD<$?k|V=G^7C$*pSkv-E4VSqPUd<)ML6+8VOs{>iii?tl2q|mQPOSB7{g=c~EIjUYpxQ4o zFqCPJL;rgAHL0&~5ds;NAz;+zMYr7z(PpiWxcDEl_*i z6*;v%@R(KD!FKRsryIS)u4}}U6a6Loiv(Cn5NWt3KJ_~b7#t2)&CYi@Wm@vkigwyL zzP^^!Z(-2>J6z|nh3-Y8?PMxLJpAD^NFn+s&Mti#-5A|k+80`8ClBSXmijo>D=w@D zv4UhQZ2^oA<#H=H!m5sTsJM)u0bC6`{IF*(qIrWf>K4`od7Fx*@Biz8>Ec+8H3We2 zg@qa94_FXg8F-t^29B9R18r&Yj3oT1+TphhK~2=W|KroCC~0W%I&1wWwB{_U=&NJ*AO+PPu!$&Lq^sU0Ur!s|79+s&*f+R+C8|212@ppL z$HkYNuE%AK2}_veu+nBlwEen0WR~|8)JW#5H9?&lV%Yt={t8MPyWU#m9E86(rfRON zv9jA4^Hgcas_8z5cfGOmhK6!FLv>(EqrbaO%78=4Lw*s`l{v(Ss8iusA0R^vZj z=^3(Y+|flN3<~N<=4gkzixra zc3?0NkMYzKL0ydvQ_MjoN)k%}m3fy~6~m0;^v{1CC3(=yPyK{hbzpu*%91w=P`8R6 z+oxc?>i}=>J=2anR}~ZFLGu7Ckca`Zh0(>wD{eOFbFoQUN<an6=zypyY9ep z%xpi%n|raBF3Hl|Azj6T>MPa?YtP|G9hwDWII3k1r%e&U+(ANPCXiX8v$z!c&ht0a zd?nJN`U-!o(8|YI(8krY(QQb8vKZd6f6AzsbpO5O>8Kl^a|{n^B6$Q<&p=rZT@>!k zxxy7ksHBV{&--LjL+w4=7}KdwGIJh|Zn->9CXy&eyea8Rw>(eWhyL5JA9+`Yov4mO ztpl$B*TY*0eY^9;z+)$HXf`zALjuL2bI5emA$-vfS_#6u4yX=e`@&nX*jCIjoXbut5I3v zBrJd0=;dJ6c%wLgvFR2q5UTb^DcKT~ownQuwmU{aX(9bIq(mE5P`r?hw%Lsj=i6wJ z-~aZ}BscdkZ7lt*ElViG$VVq0S0R2Rg4Ets)HT$NVW z@8wAy?M{lYNr$5yEK(J+#|{YE3dZ+zj~iVK!n*vf$`sQz*oahJm#J0L=lY+9;HH|f zr2pC+KzmE|Lg>tb+?C-XH-y<2gpyG5y`2~3{5k8nM|+t6s3NCA{W`p6)$@^(F02{? zbQ&Iw=#$4f?urYRzUMsY=FlfCunQR=4nkQCIl83RnBOOl-X%veOfG;=(|i3D|Km85 z%^FnfvMecf$4Dt4zO}k&v_uQ-ao`nmIMdvs))M*Uc{*Mk#M#`G-DWbHJZmzlA~(S# ztQX=W$$3YWC5FgKzqT+@igm{q>=;Id2r!JN$J7a9A?g?XefjpR3(W;V?mb4%P+ z{euHy4)2g3v`y$@`#5eKN6^M*#2f`EH-(_#hWCkRhLU}Ks3USv*FZ*ZTdVQEFQX6O zUU}mq4j1^_vLYth2pcR2jpy#kyvx(%KNIGr$-WSU@yW_DkphluL2~PMMWtg~*u_BK z^Wu0;ac|cmU|XgpOEG{(j9ZreT;OM?%@KDoY*XjNK=+zYozuI_2b0(iAaVbByrPiH4Gc^laEX^Hs%o$pW4q17g+>>$ zKUZEJoGCZxukGuOgy`s?ba z$XOt!cv1XMFKf}atpdV9O}+7KY3rK6_7c)l`(T%9u8XEp}8@1c{7?*<(V3D$BvC#ZMA zyS>HLcSZdaF%t7CeG4;`bFNAmCP`hJbMMufmmQcmtMBf&qV9qIuYgw7{x?B&;zloW z{nx{lM-!!o0MrK>I!Z9z;(%p!n0hYM>icwnrJfzc)C>(F?GPo%Rx?D;v3?p4;JErN+PsQlbPZKXNErPSOvD7|83VqySe|iE z_=9W$Dj(>W3F7tS*8MicaaXI0p4l`KM^%KC;)buF#ujfMF{hpDFyGXs)xuj~2fS-k z=LwE~zC;dvEjcu1pR)`QVZapmFn&$dVY>$I;G4ZeWofv~j3ZKgX$u*q=|)9boLZKZ z8o^og#gLIai2Q2IcK0k_J5hK(0@!O$fmxzQB< zLN(fgB{fi8C4e1oZIbea!ws%$U-x^jF$I#iI^Cf>@a#j8%#-Abk*lz`o;)mtgr=l2S_*G9CUOCNUHR(tx1B_Fl5=G5552q?jf(3?! zmy!O*zM;80e0s`$@(aM zmDdM}-e`?8y$-)!Hv~*4{(dSarHZ>H9qsRX-ciagrO=^mvm$K z?vrVb{j%}W0JXA076qgwIJ@nj8VM7)M@Yxwe#tPLmI@J~C3IY!{09^q+Ns&=io8=-nsk_c za={$3D#*an%Hq5i+A*5A*L7Qqrmxhd_-x$R;brt2K1|%rs8PfpREU#r8Vu|0%7bVb zjB3N`PF%zw2e@&iUq9{8g}Q#vK*l^w|6U`If@gHcx*Z=n09SAndpI?4&g?3f#Z#_%T3jal{kxE2mPl}o5Rp}xj%HVd{{ z1xNIXks=@pD!G|0h-ISdwkY_u7&#p#_5HbSw_W5TUsxxTc zcl!@BhF+S=%ZNs%;t_SHdzZm@OQ-%ls@dTTsgKj$grI-=k*om0E1C#$QM|cg#qC;o zS_%}uw4doz8TutiUiH|X*yGgdp2xzQR5!j@0mW|r1l0d)ZZ#fH(;T<+33)27J%(AF zgH^;%1lR^$@gOj+oufBZKspp@+yLwJc{9F8k~>p70_E7R0x{bbtX8M{#O=QE>7HFh z7xnUTH+3OaS!o{yv{|d`omaNZeYg+DC)Ok+YXG&UjK8GD9VwZic9s(Z(54UTW}x>F z!ny-GenyN%E!fJ$PtW1egWf_l%t~jUiOO7SM^s#7il0vfabZc=xiPzLjb-a3@mU%h zbpOF_6A@JBBvSz~UXT7NCu5fOCy^{?yo!isfZ{(62+f7l{clFyhd)=okWwuZJp*Ha zvS`$B(njO;mN)Iw#?Pc&f~JN`t8L0hwB~<(31PiMNu)ddhkO$=^Y&>f1TO57_$*`t576u z#`Y`2iPM|+!-}Isd6nxIr_7!Zq5q4^*9y>bDaO^b5-Vv^LH^a&qXF`V$B6zY?AFgw zu;<{{Vgj82sMdI9cVPD@USb2QOJvWz}`t4`$?{2D7YN2-vl|pPav7uxS!W1>{3zgM zthi`;q{ND%B#;XZp*e@_kFCHN!D9BZdN+3&^qSw{#s+7->|VqngQ{gb#6p+z=4x%) z5tC*f0hd;`I?&JU*2kKv3ZIwlVIA`|`NCZ}P-{=CsuVluobbSp62%wl_OdmEb~PP< zvhE$ZI5H3(6P44`%gJh1J5mAm_$BqOmn2_&9JYiUy-)FKws*lx!xywnTC9c=2GZHk zqV7kiQ<@?Rrl1XtkBiGbA#jWA<@Es;_~#d6jT{x|IS<;PTDZA6MMle&-eZf>FJp+6 z-;<*!;^lMg$@P*E)iliYDsVbk-k+X|ne@>82#v>p+$1&zK~Cf+o6EfJT!0 z=($pQ*`skgfb22w;ZxS@Fafri;tQLVYcEXPAD#^=7epI(hx?Z$yRGQ?1F;k@Hb3Q)lhy?qV!>M?Dc*Z! zCrx9qAof4`^6$f_&HP~`@oYMpa<>18DkSm+wn zelcIk?HWxUAvx1U_XFX{9%jWDkLTrOf+_&)K9<;{ee#2pmT} z&)S{rz8+pH3qSKYAEr$k1DckP&K1k-E3R+VBD<4wyJD`cJ%Xvf-sXKuBHb+hmA~$I z$=mssYj&o8`5UUa4LvtkyKJs%e66Dr=90QnTG^E;GD?}W_4cz9U`B-LQG+xg^@~Nl zAe%LR|Nl2I z_^h?m51$b9H{*%dQW?I~1A> zBxTx~QDolEszwr%542NiprDm73{EJ@MZoTOR!Ic*{Qo8~j~o{VUtT~dKtFP~xh0z4 z$JyFC68XzL4LWU9?%0Tzno0YX^tKNkw|z)h!%K@DC@Er9d{W!#`(=zB;W3Xs>8}z6 z|6&UOv#5I10<$ED8B7Dtcad}{S3Q(2m+BoZW-czxgD@2*@&>s3M~(u&1lvn)yk9?c zJ+LsTfMPPtF)1}Rv}@{`NoD_HuBq>gH6mD9sgs3YPu275hvR;V3C>wu&ofw_gxg&6 t+oCy$MEZ@%r2t7%Oea@Jun}Ja*2AXJu63LC{)Ku0GE$0?wc>`s{|^(AkJ|tM diff --git a/public/favicon-16.svg b/public/favicon-16.svg new file mode 100644 index 0000000..99d5402 --- /dev/null +++ b/public/favicon-16.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index 4feb94cfd3f0f8cbb9b0caa9109917b0f45dc7e2..e32e35eed7fbf9db1e41c31c69a8aaf1fc50eccf 100644 GIT binary patch delta 404 zcmV;F0c-x81+D{-B!32COGiWi{{a60|De66lK=n#V@X6oR5*>5lie#raTv!xzjMyc zjG0(1E20&4QcGSh6iriZCG3*_gFis|6TFm=3#D8st0lD?SJYBSL}-~Yv)Rkpal6%C zj_2-q`F@_~spk>Cm7xuUu>{Ec&vY=hrCMUUO|1oxK`bk!;(ztUH2`)ZyVxBGhP`eM z&#rlSPgR9TrQURZfa(4KolX~Hnj4!cv*_38Zf~hNhEyq-&kGL1*JQEiqqp(u&(UxWlRqaH>yw|M|S@+ZrFC`#-z zjSw(3&{eihU4KA5Q|c8snydu<8m-L@7zTtdAGv-0Fb8ltY%KUS9E~bQ5l$}dxw?;; zo51a~Ge7CYW>qNYMUEqPWu0pREEa>HUqiLXBMmVC0000 delta 643 zcmV-}0(||h1DpkrB!2;OQb$4nuFf3k00073NkldXIn>2K&25b}Ulz|F^?&MHJg@1rStBkpuiFOe}v?#Z3s)b9n0BHf}&5YysbQA-z78bzfzoPPC5sl4C>m#a5;8F>A>C^Bu z35-&7n|6Nn_J32C;9A-uiRf}u6*XOu;k|l~>f*P2m2pz`t4zeYL} zhj%ssPkam^IC*zhZ+^A#%-yx}c&6y7yc0kur4T~`=vEDWy@5uEqDFi)2|yV`N2Jr; zxL?|}a(ngsc*bdn;VF;dV3-JjBuHgVHzwB}ln#ce4Qa($5k~7AhCxY!2BG5UiX=#L dyuuxb^Ix4-r4Ia9sD1zd002ovPDHLkV1k_{FJS-x diff --git a/public/favicon-32.svg b/public/favicon-32.svg new file mode 100644 index 0000000..024e134 --- /dev/null +++ b/public/favicon-32.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 3566489b50b2afc5569f16e021fecd04adb6247f..fa25e04d654cdb655f720469e923c5dbd21a7b8e 100644 GIT binary patch delta 1235 zcmV;^1T6c~3+D-tB!32COGiWi{{a60|De66lK=n&oJmAMR9J<@mTOQ{Wf;eQ=bT+x zWEZ)Lx+03mMFiAxEbo|>SwlKDh*PG0XuQ^p!(=v$R;E)qG0c=o)`xPaNhwET%CRZK zdpTLklENzD1y)5sV0XDJyL(O_c7}RIWEZranf-A7|MNco-+z1FopT-uU|im$SXuTK z08auY9&R}#IIL;%2FsQ+R>>FcIR{e2Lk_;Xnp9QEkbOJ<&%*)271^6F<9qD?OR4ab zOn_&uSM>Mw6((&A z9M0^CaTJ$U(0}Z7`2`{0Eer{bVQ%W2u3tuM8UXXt=TWfaLt-O_^Xc+JVnSntbz?_H zvT%AL;YI^FnJJipgZc(=ubLP)fvt<)MUfQ%%;9D}d%2J(VqfI>fSOT)-d;Eip z26CQA#%Q?zarq5!_WC)#J6j@XaglNCT3$%lz%XnL4u8t8)C+!uDVW8x#}XXW?UVxt z&@_$BC0qIF{Lg}(FnBbdEZc!G$i$)FtFhKJ3Vzg}U>0Yl5Tx%J!Y=>-RaM#WZ5~Ih z#{@lPXfivOe#}7qKuS(kQDtiq{Fv|%vS%eTK!5KjzX1R|UJto@)^p<0NkLDKO=IWM z?F8uz9Dg`*k?NbxftT?nGdqx17!9cvvRYsRq#g*8AR69 zG06XVS@j73pw-pNs-m@6Z&?LBGkF%97raHM+kZ>(kqT}%ix6YX5zL=Fy8i$G_I5if zcCVqjzFN?q$(T=iY#JS%9`=@2(r9lN{M3T6u&`2i{?TULku{3WLK! z1b_dM?G6V|U+%i@6M#W);G-7`7&kms(0{#rnm3By1XLEyNMdND7=Gne3neEnp{iQn z0rZNV{G4q(I`R=gKX<*1bzkS=QQa(@p2p@MI#cd-BXMLb?j&;tU{DLV3VwlQtY zR6)1Yl(V{MEiO+73#KI!KQv76Z!|b5K7U$)+vDxl({BJ-mU;jAt;|fCA?Ou1D_LFi zDsApIvZf@EU=A1jx~5i&k5tm>R`1={pKwW%*p!vW+|)UOUUmC2D|fHK>2fe{Qankq zk%HgQ($3z}3S4ea&l~dxV8V#;JU1ar(5^LJW5pM%Xl`r99AzSHRJ7pVakR7dmwyYi zyLt}y8{p4tXUW^Q5wGeyLtB%LS3X}wgVXo@udR3R{clyMn(q(R;_P70;Yyqx?p}L$ zn-p%@>x99htfm}*gO)?Iy4t#i(mH#cu!5)CFq`5+<0EMkNxCc+0a zfXY7jC&XY}914LC1_)zwV!B@M%(blTZI-by-cOgi=g;qbo`2__=l2kd<5X-~s9MxD zm+;&SLTN2{-pf%6%8{Ft(mw(E^DRcs^u4;j|8C8ayAf2@FK**ReuE*4ZHC0eEKgxl zrz*7o0R$qULkcIHU_9A(@s)kY$FhG%0P>orW?Hhi*YGN-j4S};uJFQ^QaA=Lb9D=h zZTe%=yFZizQh&}OkybysIa}%8meD+xUg~04iI9EG&9=t+EylH z^n*r7?=)qptXm#H&YWez1d!#*qm-F|pyKV)`M+QIaozq*snL;Igspn)V9A+b@q5`WJS)_h$T*6m(V8VLsge)+uD zvQzxqEw}ULB4zN23h-AS`p=xhzrS8Yw*Mc86VBrTUmFBV#$fu~Nb3qr(T-E`D547y z-M^*h#hq^rzbyh-HfJUm($1Pyxqkyu;JaUPA;04x1gt3nI

`poXkR zc8L2`-G8XiwN%fl9l63R0H7-${XRQ2lq#)sO;+GD1zg^H7(-XCjjIevg$L}32Vul~ zFjb|XYZqo+Fw))o(ymkyz_L^uiTaLPy1O7KF%eO<5|?%!Li)V@Z5zJ5F(f#q{PN1~>^7!QJYb$kM*qH(N3`NAr&?1RfHj0PhZ{PYxVoGjbG#&YZUPU?cN<|ToczwlmFJcw4umJmEMYYURm zH-Eus{v#ns#vAeb%kLp`V_;m4$_m>(WpOKztSOW|;~5h1#8!f5XMKd`+SS9#f*>HS zSL3&h?@W&KVJnMf01w0fvDl=V8B;>KA4ve= zisEnR(GabPFCpk?)!@;tBH%5|Uynyc0Mg_&qWLW_8$*CnoU)Q|OpOoJr35cP#{ zY1bj7FW=)Z3VC6ba09QVBOHm93?l$^#k{lGus*w#|7ofhKIX=iy@!#W=r|6jFqI;^ zB7{R-yuQ14?e3CsY)8Q+BtH49sej2%LgH58Q#4#ZdV2hXM3azpIq8|-A22Ec{?UZQ z#*m|ARmyg&q+QzYML~qmTM1knz`(cXaO?b?C|MMyE+g%-cL&bVO%=Brk#MXGCA&R} za-_6E?zEUw*)C97lpqT(2ppf|gbq?FGNdg7L&SvPq=Gric1?vtHC-3h?|&&zN^_Dn zEl+C)4NdAah1(PIf2IP@(R{gBfN7b?+7D%pEGq~GLR~sPI3_J0Hj!UUWPV#lAjZJJpaH}{AqEE^28n}#0E9v(O{;ZejcWB*8P)23G^*A6 zPg@MKLwBWKgPsh?9VRt;>OlM6(bi6^etH9P1JK{=2f%J<80)SC+Wl$Z?AQB1s@>)d z#(2|*alIj4c|!KZ&x-lK`^qjj?%UoCH?Lqx?*GScAN}|3^MZ>Zo98?u=zr3wDgP}y z?Xb!ttHs4GTT${K8GzgZQd_mU;{Tt&f51{t-aYy6+3kvAhwI#k|Cv`8|IfO%6zGO& z|1I0BQRH#ihc1rI{^U6c|Ns8`i|nlb58gcZ@7n1Ew!?ms7trn{V7o!~gK*->$^Xq; ztw?qQHv5h1^#89vyAH*E5a-Uz+y9-~fng1l57|NeR2hg4H|I(w2 z|7}_<{~H6{7`eL_ zwqDo*_Mcg^+5h-s{TOZt+g3%f|6%TT?y&!V_tjktH-O4fkQ(z=i~oryCSkb2ZlVWP zHxL{CgTelR4F7>le*>BS2D1Da$ntj}>z{$He+Rn#0V-?g)&3gj_9v`d#YNMg{XKyF z4{(2T4KBaZUR-|_sPAi7tM``nc44%6-hu1}^?@P%Z#_^r&?7E@cIdAM^}9iKk8}V4 DI812g literal 15406 zcmeHO=~GkLw%_MuBnbq9h=SS*(hhC+>+8^5+ZjP*P6#1kN&+HNmf8Km2`u3DG#NIEQF`xq4V zGHCy5S93-!TPW;faCyoA(Q(Sx)>mm6WHw-ZtW$d}b|I^gJt*>E{qgwM*5x{!u3+G= zb1nKob`yJG>1R+g5q^DaGx+OVkA85`#eORGF{qmkUw@PdRHuF>cHx|ZHQVsMY$(kB zJ0kdH+paQj-`Ukn<7ey?o|FtSsGh{}`*iK`%Wo5bzP&UR7(eV2mQgLbO8h$D`-vzx zWn4Lb4xUtksZ)k}GN>4lLF-&#{!o3ovR$&-Qsi4cLcfdP9MyugHxi26TCjOlSWoc( zW*-&7bz}Mb4R9bHDwl0Q_M~DId4!YbyLHVaS5!Zh52_wGhSd+8 zBieBJBCPr^+kI5}|8~UwXv3G4fvGZk~xOvCGw!2kB ziT#&{5_4b_L|Ni!1iAJhu$YJrPhg5dExXEODV> zdiO;skB!`CRB4W8)kXZ8)1p`*FL)o7KIh_|XzSNO{k0vxw%trRx&hfjdZpTX(WO|- z#XXz$-;Ge7TL8zfVXF*#A^0MY2Issu+xd z#_LJ&$;l`Xgn-?|`YYt~cl6E9g9=Eqt0B8dwu~3?t(l5j^!%`I#hg4{Ci~Sd7i}on zQ6k5P9$b^j5Vu3Msyr??xQsLiaKb{iJz$qPPg9QoU0?706~BLZp{=->*&u)s?KP(o zACA}Thp=_$`eh?yzrMN{0Zv-wklVJLcjZ_#`2KqE<^FKo$`(j2`3rtRAHaP(2@ao& zS$Dg+Y+i0p5#daQ_=-2mDC`rU@9w?@>VP8_rcbM4&&coK6UKEheW!QWQ7`pdy|FJ@LmwYZxrGrZ7 zytRMRWb-~aoQ-P=_Y^Lt$jiWY@6cVG`}1_q$uX*dx{%%9piGSB>B|;#RIsvFH!mM+v|T5?rB^OmDK+<|I9cai?0O8ivH%? znty)TOLEGF0`u=kKFi5#V?c5_b==AAX&aXFb|2^jr{d|eHpZ}l$p6>$tFUdb{ zW|?_tBk26W$vKAh=N8(Z4cI

0iIx{+N50^RAsLx4*AH ze{z|;tTm*kWGk=-ON(m6^>mvNpnO>fNqFm(maWm0~PH$wX# zqyzb1X7mrrhEWFzImcAsnqKn1j_cO+zs{onmD?Br)_!pr58_unwYvXB^6~QfpT*w5 znUKzmQgY(nSUtW4sz*ZjpY1VV?$n@M`La5^e&vK3I&ZJ-f06>^f2UU}?^`?~o>%G2 zx*Ex$VpxHA2a#-z@9^wAslmA%mj6vE(B(gW7~P6Rzx)tq-caY@;CJ>h0qU-;@B9%E zANWh>FC5EXtX_rx+-)C2EGR4z5HHX{_xv5K z$N#2h!buRKWlp5c!by--$jj;5tv0!JTMK*iNoIF!s!Oo)@HWl ztdptJFR4o})PA~I7z3{BJAZ4tnNqr0>*MTMCQo;&W-@D) zk8@f#UvFuf2$wVWDxr2N;jw#e?~KoP79&L)zJycdY&&9h^A~Cf8x4)uaV$<+=64d- zh4Y*U=SM#3MY>79S?k`2@t~T?c*OQn=KJm(*p4j-3uBbF)UUf;{$pl?Y;i4F{5Mm# z94vhr#O|Y@)Ef;Y-Wag;#elUZ8Z2E=Q0Ufyu_FTVI}n$|HO%X+R_1pJP&JA^%B`dy zm)amzZ_Rs}GiS=>^B3wxzwR983sus1JBPQx<*`J#Jer6YW+GTSwn3&d7S0q!!zr^K z(n_Ksw=M>X+6`c8iH7_pjI%YX1ACDKj|}EtfNi)m7Q5K}^@sDm{|;|1(ONxaRm`MU zW9)4yMin0*ajb=53Y+|iGgBg`OiHX#6pw)Lj@xNyp$;fh)tBi zsbV#xSE&#SL|j;c^Gffn11jjell+_S+kFYEP5u+bVt+<$AYLl1OZP@RzuAYQp!2H_ z!PdS5^s$?V<3;EMIFhA=vlWOdHzBS}Sb0!8NIqiR(D&Ve{!sd-npw)pQt|gZ#G;I( zuapp*tr*hbIc+bLx_3hC_Vu5;d7rOVYV`vA>4E~zRVZ*A5SNz9U%D^uS0YB9^1Jum zffc?+YLR$5qh>jt%`x*LuN>Ov_CUi#GU&H#UcU#oMUCT-YLvq{M<8y`u8@>^Z32@LUhpb+zZ1Z2}xO%isde<50R3 zVcvlE`vVV${`1gFzZJ1cXWAw9)P(qXFqQaJ(D&_r$f?{8$~Rc6tLYp3UM6R-_m~Ku zThYd$%@5Xxo*Qp$f9kvUu@mbu+V4{f*{z&b_JZU5(z0bX5!|!;-$~!|w&>#pIF>I$ zT9xoJ_8`@%Vnq7_=g=*(2cMhRR@^s#CJbHr4c*&(N{Xj|)3XO;>)7A99vdwMk-?#J zBAl=VVuma9x}v~%U-HksA3oW7w2&p8wzEex*9dQxQq_bW2EISIsWF#u+Z2zn8C;XN zpDx$QkBRroC>Q3@*u9UFjufy|j8i?QHu4w>FB`syr?tMRF`IDpS85pi*(?BJ*emoZ zLL9I8X2SEqA3jMvn$K+M_|41s%Tmmz7;(bZTRWir#=f_{=d9MI!&w5Ht3>^A%?9h| z!oD56_mS@Od4D)S107u`R_on~BQw+}7ab1%Bl2{hq*G+GMfxjMV z`OM5bn3J?D{zEY!n@=7#)A%cR@1-akFNi8 zK!2J)l%E#dz7pRbVE&vA%pclMg7vGP3Ctf_znGJ^EPhAvG@DO^wz>DeesOQaT*H<6 zHGUabzekPiv-E2A+=4puSR<8IB>Luk?VqZp{WG&}xn883%_rdAxU_$w{W|Al|D-nZ zI3+JBPW8U*7v&awj`l0Jb){aVgEHv26R=<8Kb$IMe}aQ- z^53PU17m!Bci+ALd;BTF$*X*MtolYik{>_07{E`Ai1$)|OJ&nN$)~>eu7aPC{)As)zQR8&9*oro z(zsjgqVXSFoUICX|Ij)B8Z`Q`IIDVK>Eql!K%XxiXmJcH? zJfVfA8$tM?Y<>7)oCH72u2(}5VuXQyUY=(`aw&hkeeU)6A&r3_zqKi*v+A)ga2(4q zzabRaN9Bmo8!%q7gx}W3zaGDhdWGL+B7U1wr^7g(5=yUPUc@SLDn|Tgt+4Q0(w{Dl zpXaq<-U;yy%D0d{+i^`@p4+Uz~Tyf6Q-}E&BUrjW$qwXgr*l&IAzCkkVy z7(ew!K*@jt$8~MJiJxEhX~UxXtM$h(mfGNtADypM&gV9xOqz#b=X(hFE#;|j!e^WZ z;nsIx&P3xE+ra$>@gqJCG?&sGs_7h;@^Q%()Fa9S0JO-M+*tMzgQ{D(3zs{=Dc=DPw|FU4*i{`|e zw!<+l;`+yYxa&LKTKt-~ftMMSKTxlmD`<*(Slpw2S~etqL37=X`-KB@o0jGONOOV4 z={NjeqjAg$nozx{o-jPEpL#d>16+4$nNzP%Drkzfn_8o1EnSg+xAyA(S?bk3FT0|F z@~aUTPmcim5dLok(XbBVl=|mY!-hxIBkwnVL)r$qC!6QbpJ~-6S-N6B>@IS@XdISIf diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..024e134 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icon-tile-dark.svg b/public/icon-tile-dark.svg new file mode 100644 index 0000000..8a609d5 --- /dev/null +++ b/public/icon-tile-dark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icon-tile-light.svg b/public/icon-tile-light.svg new file mode 100644 index 0000000..0fc17a8 --- /dev/null +++ b/public/icon-tile-light.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icon-tile-outline.svg b/public/icon-tile-outline.svg new file mode 100644 index 0000000..26bea4a --- /dev/null +++ b/public/icon-tile-outline.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/site.webmanifest b/public/site.webmanifest index 45dc8a2..06860a6 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1 +1 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{"name":"DataPipe","short_name":"DataPipe","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#1C2A22","background_color":"#1C2A22","display":"standalone"} \ No newline at end of file From c5bc0da1bdc4b391479179e58ad4d9e60a92a64f Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 22 Aug 2026 13:34:40 -0400 Subject: [PATCH 106/181] refactor(index): retire Rubik, finish the brandGreen rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navbar lockup dropped Rubik, and DESIGN.md's typography section keeps display faces out of UI text — so the two hero headings fall back to the system stack and the font import goes entirely. The homepage does NOT gain a second lockup: the navbar's mark + wordmark sits directly above the hero, and the full lockup (with the URL line) belongs to standalone brand contexts like the README. All 9 brandTeal refs renamed; the deprecated alias now covers only the three in-progress docs pages. Co-Authored-By: Claude Fable 5 --- pages/index.js | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/pages/index.js b/pages/index.js index 0f0d28f..f6015ba 100644 --- a/pages/index.js +++ b/pages/index.js @@ -14,9 +14,7 @@ import { ArrowRight, Database, Shield, Zap, BookOpen } from "lucide-react"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import TestEnvironmentWarning from "../components/TestEnvironmentWarning"; -import { Rubik } from "next/font/google"; -const rubik = Rubik({ subsets: ["latin"] }); function FeatureCard({ icon, title, children }) { return ( @@ -47,7 +45,7 @@ function StepItem({ number, children }) { @@ -71,7 +69,7 @@ const snippets = [ { color: "brandOrange.300", text: "save_data" }, { color: "gray.300", text: " = {\n" }, { color: "gray.300", text: " type: " }, - { color: "brandTeal.300", text: "jsPsychPipe" }, + { color: "brandGreen.300", text: "jsPsychPipe" }, { color: "gray.300", text: ",\n" }, { color: "gray.300", text: ' action: ' }, { color: "brandOrange.300", text: '"save"' }, @@ -85,7 +83,7 @@ const snippets = [ { color: "gray.300", text: " data_string: " }, { color: "gray.400", text: "() =>\n" }, { color: "gray.300", text: " jsPsych.data.get()." }, - { color: "brandTeal.300", text: "csv" }, + { color: "brandGreen.300", text: "csv" }, { color: "gray.300", text: "()\n" }, { color: "gray.300", text: "};" }, ], @@ -96,7 +94,7 @@ const snippets = [ caption: "Not using jsPsych? A single fetch call is all you need.", lines: [ { color: "gray.500", text: "// Send data with a fetch request\n" }, - { color: "brandTeal.300", text: "fetch" }, + { color: "brandGreen.300", text: "fetch" }, { color: "gray.300", text: "(url, {\n" }, { color: "gray.300", text: " method: " }, { color: "brandOrange.300", text: '"POST"' }, @@ -108,7 +106,7 @@ const snippets = [ { color: "brandOrange.300", text: '"application/json"' }, { color: "gray.300", text: "\n },\n" }, { color: "gray.300", text: " body: JSON." }, - { color: "brandTeal.300", text: "stringify" }, + { color: "brandGreen.300", text: "stringify" }, { color: "gray.300", text: "({\n" }, { color: "gray.300", text: " experimentID: " }, { color: "brandOrange.300", text: '"your_id"' }, @@ -255,7 +253,6 @@ export default function Home() { setOpen(e.open)}> @@ -100,14 +106,14 @@ export default function ChangePassword() { - Change Password + Change password - New Password + New password - Confirm Password + Confirm password Passwords do not match - {error && ( - - - {error} - - )} + {error} diff --git a/components/account/DeleteAccount.js b/components/account/DeleteAccount.js index 88d15b8..ec59332 100644 --- a/components/account/DeleteAccount.js +++ b/components/account/DeleteAccount.js @@ -59,15 +59,18 @@ export default function DeleteAccount({ setDeleting }) { return ( - Delete DataPipe Account - setOpen(e.open)} - title="Delete Account" + title="Delete account" confirmLabel="Delete" destructive onConfirm={deleteAccount} diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index 986b71d..e3b483a 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -4,8 +4,6 @@ import { VStack, Text, Button, - Alert, - Badge, Dialog, Field, Input, @@ -17,7 +15,8 @@ import { linkWithPopup, unlink, } from "firebase/auth"; -import { CircleCheck } from "lucide-react"; +import FormErrorAlert from "../ui/FormErrorAlert"; +import StatusIndicator from "../ui/StatusIndicator"; import { UserContext } from "../../lib/context"; import { auth } from "../../lib/firebase"; import { @@ -97,15 +96,23 @@ export default function LinkedAccounts() { return ( - {error && ( - - - {error} - + {error} + + {/* Zero linked methods is not "nothing to say" -- it is the OSF-only + population, reachable by the sign-in flow that is being removed and + by nothing else (see AddSignInMethodBanner, which carries the same + message on /admin). They were the one group this section stayed + silent for. */} + {linkedIds.length === 0 && ( + + You sign in to DataPipe through OSF, which is being retired. Link a + method below and you keep this account, your experiments, and your + settings exactly as they are. + )} {linkedIds.length === 1 && ( - + You have one way to sign in. Adding a second means you keep access if you ever lose the first. @@ -120,57 +127,57 @@ export default function LinkedAccounts() { const last = linked && !canUnlink(linkedIds, entry.providerId); return ( - - - {Icon && } - {entry.name} - {linked && ( - + + + + {Icon && } + {entry.name} + {linked && } + + + {linked ? ( + + ) : ( + )} - {linked ? ( - - ) : ( - + {/* Why the button is dead, in visible text next to it. This used + to live in a `title` on the disabled button -- invisible on + touch, unreliable on a disabled element, and never announced. */} + {last && ( + + This is your only way to sign in. Add another method before + removing it. + )} - + ); })} {hasPassword ? ( + // One status per row, in the slot the action would occupy. The row + // used to carry two -- a bare check icon beside the name AND a gray + // "Enabled" badge -- saying the same thing twice in two visual + // languages, neither of which matched the other sections. - - Email and password - - - Enabled + Email and password + ) : ( // ORCID lets researchers keep their email private (see the @@ -255,17 +262,17 @@ function AddPasswordRow({ user, setAfterAction }) { - Add a Password + Add a password - + This adds a password to sign in as {user.email}, alongside the methods you already use. - New Password + New password - Confirm Password + Confirm password Passwords do not match - {error && ( - - - {error} - - )} + {error} @@ -310,7 +312,7 @@ function AddPasswordRow({ user, setAfterAction }) { disabled={!passwordMatch || !passwordLengthSatisfied} ml={3} > - Add Password + Add password diff --git a/components/account/OAuthTokenStatus.js b/components/account/OAuthTokenStatus.js index ee284de..f4c10b4 100644 --- a/components/account/OAuthTokenStatus.js +++ b/components/account/OAuthTokenStatus.js @@ -1,23 +1,15 @@ -import { useContext, useState } from "react"; -import { UserContext } from "../../lib/context"; -import { useDocumentData } from "react-firebase-hooks/firestore"; -import { doc } from "firebase/firestore"; -import { db } from "../../lib/firebase"; -import { - HStack, - VStack, - Text, - Tooltip, - Alert, - Link, - Box -} from "@chakra-ui/react"; -import { CircleCheck, TriangleAlert } from "lucide-react"; +import { useState } from "react"; +import { HStack, VStack, Text, Alert, Link, Box } from "@chakra-ui/react"; +import StatusIndicator from "../ui/StatusIndicator"; import OsfRelinkButton from "./OsfRelinkButton"; -export default function OAuthTokenStatus() { - const { user } = useContext(UserContext); - +// `data` is the users/{uid} document, subscribed to ONCE by +// pages/admin/account.js and passed down. This component used to open its +// own subscription (a third read of the same document on one page) and +// carried its own loading and error branches for it; the page already gates +// on loading, and only renders this at all when hasLegacyOsfConnection(data) +// is true, so by the time it mounts `data` exists. +export default function OAuthTokenStatus({ data }) { // Read once, at mount, instead of calling Date.now() in the render body. // The clock is external mutable state: reading it during render makes this // component impure, so two renders with identical props could disagree, and @@ -30,70 +22,42 @@ export default function OAuthTokenStatus() { // catch the boundary would be more machinery than the signal is worth. const [mountedAt] = useState(() => Date.now()); - const [data, loading, error] = useDocumentData( - user?.uid ? doc(db, "users", user.uid) : null - ); - - if (loading) { - return Loading OAuth status...; - } - - if (error || !data) { - return ( - - - Error loading OAuth status - - ); - } + if (!data) return null; const isRefreshTokenExpired = data.refreshTokenExpires && mountedAt > data.refreshTokenExpires; - - const getStatusIcon = () => { - if (isRefreshTokenExpired) { - return ; - } else { - return ; - } - }; - - const getStatusText = () => { - if (isRefreshTokenExpired) { - return "Re-authentication Required"; - } else { - return "Connected"; - } - }; - - const osfProfileUrl = data.osfUserId ? - `https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/${data.osfUserId}/` : - `https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/`; + const osfProfileUrl = data.osfUserId + ? `https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/${data.osfUserId}/` + : `https://${process.env.NEXT_PUBLIC_OSF_ENV}osf.io/`; return ( - Connected to OSF Account - - - {getStatusIcon()} - - - {getStatusText()} - - + + Connected to OSF account + + {/* The status word used to live inside a hover Tooltip, which made + it unreachable on touch, unreliable by keyboard, and never + announced -- a color-and-icon-alone signal in practice. The + label is always rendered now. */} + - View OSF Profile → + View OSF profile → @@ -101,7 +65,7 @@ export default function OAuthTokenStatus() { - Re-authorization Required + Re-authorization required @@ -113,13 +77,12 @@ export default function OAuthTokenStatus() { back in with OSF" as this used to say: that advice depends on OSF sign-in, which is being removed, and would strand an in-flight study the day it goes. */} - Re-authorize OSF + Re-authorize OSF )} - ); } diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 5ac2b7d..9b20314 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -1,13 +1,21 @@ import { useContext, useState } from "react"; -import { VStack, HStack, Text, Button, Input, Field, Alert } from "@chakra-ui/react"; -import { collection, doc, getDocs, query, where } from "firebase/firestore"; +import { + VStack, + HStack, + Text, + Button, + Input, + Field, + Link as ChakraLink, +} from "@chakra-ui/react"; +import Link from "next/link"; +import { collection, getDocs, query, where } from "firebase/firestore"; import { db, auth } from "../../lib/firebase"; -import { useDocumentData } from "react-firebase-hooks/firestore"; import { UserContext } from "../../lib/context"; import { STORAGE_PROVIDERS } from "../../lib/provider-config"; import ConfirmDialog from "../ui/ConfirmDialog"; - -import { CircleCheck } from "lucide-react"; +import FormErrorAlert from "../ui/FormErrorAlert"; +import StatusIndicator from "../ui/StatusIndicator"; // Generic fetch-failure copy for handleConnect/handleDisconnect. Unlike // messageForError below -- which decodes Dataverse's specific rejection @@ -17,11 +25,14 @@ import { CircleCheck } from "lucide-react"; const NETWORK_ERROR_MESSAGE = "Could not reach DataPipe. Check your connection and try again."; -export default function ProviderConnections() { +// `data` is the users/{uid} document, subscribed to ONCE by +// pages/admin/account.js and passed down. This component deliberately does +// not open its own subscription: when it did, it resolved on its own +// schedule and rendered isConnected(undefined) === false on first paint, so +// a connected researcher saw every provider flash "Connect" before settling. +export default function ProviderConnections({ data }) { const { user } = useContext(UserContext); - const [data] = useDocumentData(user?.uid ? doc(db, "users", user.uid) : null); - const [connectingId, setConnectingId] = useState(null); // Component-level failure surface for handleConnect, which has no dialog @@ -225,13 +236,26 @@ export default function ProviderConnections() { } }; + // The blocked-first-timer state. A new signup cannot create an experiment + // until something here is connected, and the page said nothing about that + // -- it just showed three names and three buttons. Stated in the open, not + // in a tooltip, because this is the reason the product sent them here. + const noneConnected = !Object.values(STORAGE_PROVIDERS).some((provider) => + provider.isConnected(data) + ); + return ( - {error && ( - - - {error} - + {error} + + {noneConnected && ( + + No storage connected yet — connect one to create your first + experiment.{" "} + + How to choose a provider + + )} {Object.values(STORAGE_PROVIDERS).map((provider) => { @@ -249,14 +273,7 @@ export default function ProviderConnections() { > {provider.name} - {connected && ( - - - - Connected - - - )} + {connected && } {connected ? ( // Neutral, not red: disconnecting is reversible (reconnect any @@ -265,7 +282,7 @@ export default function ProviderConnections() { // meaning where it actually matters. ) : ( - Set OSF Personal Access Token + Set OSF personal access token @@ -175,32 +179,24 @@ export default function SelectAuth() { token". Copy the token and paste it below. - {data && ( - - - OSF Token - - - - )} + + + OSF token + + + - {tokenError && ( - - - {tokenError} - - )} + {tokenError} diff --git a/pages/admin/account.js b/pages/admin/account.js index 5f432ef..3d08d7b 100644 --- a/pages/admin/account.js +++ b/pages/admin/account.js @@ -1,5 +1,5 @@ import AuthCheck from "../../components/AuthCheck"; -import { VStack, Heading, Text, Separator, Spinner, Center } from "@chakra-ui/react"; +import { VStack, Box, Heading, Text, Spinner, Center } from "@chakra-ui/react"; import ChangePassword from "../../components/account/ChangePassword"; import DeleteAccount from "../../components/account/DeleteAccount"; @@ -12,30 +12,24 @@ import { useDocumentData } from "react-firebase-hooks/firestore"; import { doc } from "firebase/firestore"; import { db } from "../../lib/firebase"; import OAuthTokenStatus from "../../components/account/OAuthTokenStatus"; +import SettingsSection from "../../components/ui/SettingsSection"; import { + ORCID_PROVIDER_ID, PASSWORD_PROVIDER_ID, linkedProviderIds, } from "../../lib/auth-providers"; -import { hasLegacyOsfConnection } from "../../lib/osf-sunset"; +import { hasLegacyOsfConnection, osfSunsetLabel } from "../../lib/osf-sunset"; -function SectionLabel({ children, color = "gray.500" }) { - return ( - - {children} - - ); -} - -export default function AccountPage({}) { +export default function AccountPage() { const { user } = useContext(UserContext); + // The ONLY subscription to users/{uid} on this page. ProviderConnections, + // OAuthTokenStatus and SelectAuth each used to open their own, which meant + // redundant reads and -- worse -- each child resolving on its own schedule: + // ProviderConnections rendered isConnected(undefined) === false on first + // paint, so a connected researcher watched every provider flash "Connect" + // before settling. One read, passed down, so the whole page agrees about + // the state of the world at every moment. const [data, loading] = useDocumentData( user?.uid ? doc(db, "users", user.uid) : null ); @@ -50,12 +44,24 @@ export default function AccountPage({}) { ); } - // Whether to offer the password form is a question about SIGN-IN METHODS, + // Whether to offer the password form is a question about sign-in methods, // so it is answered from Firebase's providerData rather than the legacy // users/{uid}.authMethod field. A researcher who signed up with OSF and has // since linked a password must see this; one who only ever used a // federated provider has no password to change. - const hasPassword = linkedProviderIds(user).includes(PASSWORD_PROVIDER_ID); + const linkedIds = linkedProviderIds(user); + const hasPassword = linkedIds.includes(PASSWORD_PROVIDER_ID); + + // Which account am I about to change? A researcher with a personal and a + // lab account has no other way to tell them apart. ORCID lets researchers + // keep their email private (see providesEmail in lib/auth-providers.js), so + // an email-less account still gets a truthful line rather than a blank one. + const accountIdentity = + user?.email || + user?.displayName || + (linkedIds.includes(ORCID_PROVIDER_ID) + ? "Signed in with ORCID" + : "Signed in"); // The OSF section is legacy surface: it is shown only to researchers who // actually connected OSF at some point, and disappears entirely for @@ -63,43 +69,80 @@ export default function AccountPage({}) { const showOsfSection = hasLegacyOsfConnection(data); const isOsfOAuthUser = data?.authMethod === "osf"; + // The date lives in lib/osf-sunset.js, never inline: every researcher-facing + // surface has to name the same day. Null is tolerated there (no deadline + // announced yet), so the clause is conditional rather than the sentence. + const osfDeadline = osfSunsetLabel(); + const osfDescription = + "DataPipe's original storage connection. It serves the OSF experiments " + + "you already have — new experiments cannot use it" + + (osfDeadline ? `, and DataPipe stops writing to OSF after ${osfDeadline}` : "") + + "."; + return ( - Account Settings + + Account settings + + {accountIdentity} + + - {/* Sign-in methods */} - Sign-in Methods - + {/* Spacing carries the grouping -- no separators. DESIGN.md §4: + mt={10} between routine sections, mt={16} before the Danger Zone, + so the break before the irreversible section is the one break + that reads as different. */} + + + - {/* Storage Providers */} - - Storage Providers - + + + + + {/* OSF, legacy. Below the storage providers now, not above them -- it serves in-flight experiments only. */} {showOsfSection && ( - <> - - OSF (Legacy) - {isOsfOAuthUser ? : } - + + + {isOsfOAuthUser ? ( + + ) : ( + + )} + + )} - {/* Account */} {hasPassword && ( - <> - - Account - - + + + + + )} - {/* Danger Zone */} - - Danger Zone - + + + + + ); From cc2ce4f4f48e7895cd2c04129c776e8c42afa616 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 22 Aug 2026 14:34:19 -0400 Subject: [PATCH 108/181] =?UTF-8?q?feat(theme):=20Phase=201=20foundation?= =?UTF-8?q?=20=E2=80=94=20real=20light/dark=20token=20divergence,=20next-t?= =?UTF-8?q?hemes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic tokens now carry the DESIGN.md §1 light column as base values with the audited dark column under _dark; next-themes applies class=dark before paint with the default locked to dark (enableSystem=false) until the page conversion completes. The forced-dark globalCss block is gone; html/body read the bg/fg tokens; the loader honors prefers-reduced-motion; z-index moves onto the §5 semantic scale. brandTeal still aliases brandGreen. Co-Authored-By: Claude Fable 5 --- lib/theme.js | 303 ++++++++++++++++++++++++----------------- package-lock.json | 11 ++ package.json | 1 + pages/_app.js | 47 ++++--- styles/Home.module.css | 117 ---------------- styles/globals.css | 37 +++-- 6 files changed, 250 insertions(+), 266 deletions(-) delete mode 100644 styles/Home.module.css diff --git a/lib/theme.js b/lib/theme.js index 8464832..57a9cea 100644 --- a/lib/theme.js +++ b/lib/theme.js @@ -99,34 +99,83 @@ const config = defineConfig({ 900: { value: "#4A1509" }, }, }, + // DESIGN.md §5 -- the app's only z-index scale. globals.css previously + // had one arbitrary value (`z-index: 1000` on `.sticky-alert`); every + // stacking need in the app maps onto one of these named layers. + // docked/dropdown/sticky/banner/modal already match Chakra's own + // defaultConfig z-index tokens (defined here anyway, for a single + // source of truth the app is allowed to depend on). `toast` and + // `tooltip` are intentionally lower than Chakra's stock 1700/1800, and + // `modal.backdrop` (1300) is a DataPipe-specific name for what + // Chakra's default calls `overlay`. + zIndex: { + docked: { value: 10 }, + dropdown: { value: 1000 }, + sticky: { value: 1100 }, + banner: { value: 1200 }, + modalBackdrop: { value: 1300 }, + modal: { value: 1400 }, + toast: { value: 1500 }, + tooltip: { value: 1600 }, + }, }, semanticTokens: { colors: { + // DESIGN.md §1 Foreground. All three levels are computed to clear + // 4.5:1 on every surface a body-text token is allowed to sit on + // (worst case cited is `bg.muted`). fg: { - DEFAULT: { value: { _light: "{colors.gray.50}", _dark: "{colors.gray.50}" } }, - muted: { value: { _light: "{colors.gray.400}", _dark: "{colors.gray.400}" } }, - subtle: { value: { _light: "{colors.gray.500}", _dark: "{colors.gray.500}" } }, + // 13.16 light / 12.94 dark (on bg.muted). Dark is gray.50, + // unchanged from today's forced-dark render. + DEFAULT: { value: { _light: "#1C1F22", _dark: "{colors.gray.50}" } }, + // 8.30 light / 9.14 dark (on bg.muted). Dark moves off the old + // gray.400 reading onto gray.300 -- the old value was never + // computed against a real light/dark pair, just carried over. + muted: { value: { _light: "{colors.gray.700}", _dark: "{colors.gray.300}" } }, + // 6.15 light / 5.27 dark (on bg.muted). gray.500 is retired as a + // text color here -- it measured 3.43:1 on #1C1F22 and was the + // source of the account page's SectionLabel failures. + subtle: { value: { _light: "{colors.gray.600}", _dark: "{colors.gray.400}" } }, inverted: { value: { _light: "{colors.black}", _dark: "{colors.black}" } }, }, + // DESIGN.md §1 Surfaces. `bg` DEFAULT and `bg.panel` dark are + // unchanged from today's forced #1C1F22 body; light is new. bg: { - DEFAULT: { value: { _light: "#1C1F22", _dark: "#1C1F22" } }, - subtle: { value: { _light: "#1C1F22", _dark: "#1C1F22" } }, - muted: { value: { _light: "{colors.gray.700}", _dark: "{colors.gray.700}" } }, + DEFAULT: { value: { _light: "#F5F7F8", _dark: "#1C1F22" } }, + // Recessed: code blocks, table headers. + subtle: { value: { _light: "#EBEFF1", _dark: "#16191B" } }, + // Hover / selected fills. Dark matches the #2A2F34 reading this + // file's brandGreen comments already measured against in advance + // of this migration. + muted: { value: { _light: "#E1E6E9", _dark: "#2A2F34" } }, emphasized: { value: { _light: "{colors.gray.800}", _dark: "{colors.gray.800}" } }, inverted: { value: { _light: "{colors.white}", _dark: "{colors.white}" } }, - panel: { value: { _light: "#1C1F22", _dark: "#1C1F22" } }, + // Cards, dialogs, menus. Dark stays flat (same as DEFAULT) -- + // delineated by `border`, not elevation. Page<->panel separation + // is 1.07:1 in both modes by design; panels must carry a border. + panel: { value: { _light: "#FFFFFF", _dark: "#1C1F22" } }, }, border: { - DEFAULT: { value: { _light: "{colors.gray.400}", _dark: "{colors.gray.400}" } }, + // gray.500 both modes: 4.50 light / 3.43 dark vs `bg`. Inputs, + // outline buttons, panel edges, table rules -- anything WCAG + // 1.4.11 covers. Unchanged from today's dark reading; light is a + // shared, deliberately identical value. + DEFAULT: { value: { _light: "{colors.gray.500}", _dark: "{colors.gray.500}" } }, + // Decorative hairlines *inside* an already-grouped region only -- + // never the sole grouping device. 1.38 light / 1.68 dark vs `bg`. + subtle: { value: { _light: "{colors.gray.300}", _dark: "#3F4449" } }, }, - // DataPipe renders on a permanently dark surface (see globalCss below: - // body is greyBackground #1C1F22), but Chakra's mode is light, so every - // palette resolves its _light values. For the GRAY palette those are - // built for a white page and are wrong here -- most damagingly - // gray.fg = gray.800 = #27272a, a 1.09:1 contrast ratio against the - // body. Any component that does not name a colorPalette falls back to - // gray, so `variant="outline"` and `variant="ghost"` buttons across the - // app rendered near-black on near-black and were effectively invisible. + // HISTORICAL CONTEXT (the bug this originally fixed, now superseded + // by the real light/dark split below): before this file had a + // light/dark mode at all, DataPipe rendered on a permanently dark + // surface (globals.css hardcoded body to #1C1F22) while Chakra's own + // condition resolution defaulted to `_light` (no `.dark` class was + // ever present). The stock GRAY palette is built for a white page, + // so it was wrong here -- most damagingly gray.fg = gray.800 = + // #27272a, a 1.09:1 contrast ratio against the body. Any component + // that does not name a colorPalette falls back to gray, so + // `variant="outline"` and `variant="ghost"` buttons across the app + // rendered near-black on near-black and were effectively invisible. // // Components that DO set an explicit color (components/Footer.js, // CopyButton.js, dashboard/Title.js) were never affected and are @@ -134,22 +183,22 @@ const config = defineConfig({ // this fixes is the default, so a button no longer has to remember to // opt out of an invisible one. // - // Measured against the body: gray.800 gave 1.11:1, gray.200 gives - // 13.05:1. - // - // These re-point the whole gray palette to a dark-surface reading. The - // whole palette, not just fg: variants read different tokens, and - // lightening fg alone would leave `subtle` painting light text on the - // near-white gray.subtle background. + // DESIGN.md §1 now gives this palette a real light column too (every + // dark value below is unchanged from that original fix -- verified + // against today's actual dark-forced render): + // fg 800 light (13.86) / 200 dark (13.05) + // solid/contrast 800/gray.50 light (14.27) / 200/gray.900 dark (13.96) + // subtle/muted/emphasized 100/200/300 light, 800/700/600 dark + // border 500 both modes (4.50 light / 3.43 dark) gray: { - fg: { value: { _light: "{colors.gray.200}", _dark: "{colors.gray.200}" } }, - subtle: { value: { _light: "{colors.gray.800}", _dark: "{colors.gray.800}" } }, - muted: { value: { _light: "{colors.gray.700}", _dark: "{colors.gray.700}" } }, - emphasized: { value: { _light: "{colors.gray.600}", _dark: "{colors.gray.600}" } }, + fg: { value: { _light: "{colors.gray.800}", _dark: "{colors.gray.200}" } }, + subtle: { value: { _light: "{colors.gray.100}", _dark: "{colors.gray.800}" } }, + muted: { value: { _light: "{colors.gray.200}", _dark: "{colors.gray.700}" } }, + emphasized: { value: { _light: "{colors.gray.300}", _dark: "{colors.gray.600}" } }, // Inverted against the page: a light chip with dark text, so a solid // gray button reads as a button instead of a hole. - solid: { value: { _light: "{colors.gray.200}", _dark: "{colors.gray.200}" } }, - contrast: { value: { _light: "{colors.gray.900}", _dark: "{colors.gray.900}" } }, + solid: { value: { _light: "{colors.gray.800}", _dark: "{colors.gray.200}" } }, + contrast: { value: { _light: "{colors.gray.50}", _dark: "{colors.gray.900}" } }, // gray.500 (#71717a) rather than the darker gray.600: measured // against the #1C1F22 body, 600 gives 2.14:1 and 500 gives 3.43:1, // and WCAG 1.4.11 wants 3.0 for non-text UI boundaries like a @@ -157,100 +206,107 @@ const config = defineConfig({ border: { value: { _light: "{colors.gray.500}", _dark: "{colors.gray.500}" } }, focusRing: { value: { _light: "{colors.gray.400}", _dark: "{colors.gray.400}" } }, }, + // brandOrange -- warning / attention only. DESIGN.md §1 gives fg, + // subtle and border; `solid`, `focusRing`, `muted` and `emphasized` + // are not tabled there (DESIGN.md explicitly says brandOrange has NO + // solid -- every orange dark enough to hold white text has stopped + // being brand orange). The `solid` slot below is left at its + // pre-existing value rather than deleted: no component in this + // migration's ownership list consumes it, and removing it here would + // risk breaking an orange solid button elsewhere in the app before + // Phase 2 has audited call sites. Flagged for removal once that + // audit lands (DESIGN.md §1, brandOrange). + // + // fg 800 #7C4606 light (6.63) / 300 #FFB74D dark (7.80) + // subtle 50 #FFF3E0 light (text 800 -> 7.00) / + // 900 #3E2303 dark (text gray.200 -> 11.44) + // border 700 #A85F08 light (4.54) / 400 #FFA726 dark (8.52) + // + // The dark-side fg/subtle/border values above are unchanged from + // this file's pre-existing (until now dormant) `_dark` slots -- this + // migration is what activates them for the first time. `muted` and + // `emphasized` follow the same one-step-per-slot pattern as `gray` + // and `brandGreen` below (subtle, +1 ramp step, +2 ramp steps); + // `focusRing` mirrors `border`, the same convention brandGreen uses. brandOrange: { contrast: { value: { _light: "white", _dark: "white" } }, - fg: { value: { _light: "{colors.brandOrange.500}", _dark: "{colors.brandOrange.300}" } }, - subtle: { value: { _light: "{colors.brandOrange.100}", _dark: "{colors.brandOrange.900}" } }, - muted: { value: { _light: "{colors.brandOrange.200}", _dark: "{colors.brandOrange.800}" } }, - emphasized: { value: { _light: "{colors.brandOrange.300}", _dark: "{colors.brandOrange.700}" } }, + fg: { value: { _light: "{colors.brandOrange.800}", _dark: "{colors.brandOrange.300}" } }, + subtle: { value: { _light: "{colors.brandOrange.50}", _dark: "{colors.brandOrange.900}" } }, + muted: { value: { _light: "{colors.brandOrange.100}", _dark: "{colors.brandOrange.800}" } }, + emphasized: { value: { _light: "{colors.brandOrange.200}", _dark: "{colors.brandOrange.700}" } }, solid: { value: { _light: "{colors.brandOrange.600}", _dark: "{colors.brandOrange.600}" } }, - focusRing: { value: { _light: "{colors.brandOrange.500}", _dark: "{colors.brandOrange.500}" } }, - border: { value: { _light: "{colors.brandOrange.500}", _dark: "{colors.brandOrange.400}" } }, + focusRing: { value: { _light: "{colors.brandOrange.700}", _dark: "{colors.brandOrange.400}" } }, + border: { value: { _light: "{colors.brandOrange.700}", _dark: "{colors.brandOrange.400}" } }, }, // brandGreen -- the primary action color, replacing brandTeal. // - // Same rule as the gray palette above: the app renders on a - // permanently dark surface (#1C1F22) while Chakra's mode is light, so - // every _light value here carries the DARK-surface reading and both - // sides are set identically. A palette whose _light column were tuned - // for a white page would be measurably wrong on the page we actually - // ship. When the light/dark migration lands, the _light column - // diverges to the values in DESIGN.md section 1; until then, one - // reading, correctly measured, in both slots. + // DESIGN.md §1 table (this is the real light/dark split; the dark + // column is the exact reading this file already measured against the + // permanently-dark body before light mode existed, so the dark render + // is unchanged pixel for pixel): // - // All ratios below are WCAG 2.1 against the body #1C1F22 unless the - // surface is named. bg.muted is gray.700 #3f3f46 today. + // fg 800 #2E7D32 light (4.77 on bg; 5.13 on bg.panel) + // 300 #81C784 dark (6.71 on bg.muted) + // solid 800 light (fill 4.77 vs page) / 500 dark (fill 5.96 vs page) + // contrast white light (5.13 on solid) / #1C1F22 dark (5.96 on solid) + // subtle 50 #E8F5E9 light (text 900 -> 7.00) / + // 900 #1B5E20 dark (text 50 -> 7.00) + // border 700 #388E3C light (3.83) / 400 #66BB6A dark (7.00) + // focusRing 700 #388E3C light (3.27, worst on bg.muted) / + // 400 #66BB6A dark (5.71, worst on bg.muted) // - // fg 300 #81C784 8.23:1 on the body, 6.71:1 on the migration's - // #2A2F34 bg.muted, 5.19:1 on today's gray.700 - // bg.muted. Clears the 4.5:1 body-text floor on - // every surface a palette fg is allowed to sit - // on. (400 #66BB6A would also clear at 7.00, - // but 300 keeps a step of headroom for the - // hover/active darkening Chakra applies.) - // solid 500 #4CAF50 fill 5.96:1 vs the body. - // contrast #1C1F22 5.96:1 against that fill -- the body color - // used as button TEXT. - // - // The solid/contrast pair was computed both ways, because the - // obvious choice is wrong here. Dark fill + white text (800 + // The dark solid/contrast pair was computed both ways, because + // the obvious choice is wrong here. Dark fill + white text (800 // #2E7D32 + white) gives fill 3.23:1 and text 5.13:1. Bright fill // + dark text (500 #4CAF50 + #1C1F22) gives fill 5.96:1 and text // 5.96:1 -- better on both axes at once. On a dark page a dark - // green button is a hole; the bright chip reads as a control. This - // is the same flip DESIGN.md prescribes for dark-mode teal, and it - // is what retires the old solid: brandTeal.600 + white, which was - // 4.04:1 -- a live AA failure. - // - // border 400 #66BB6A 7.00:1 vs the body, 5.71:1 on #2A2F34, - // 4.42:1 on today's gray.700. WCAG 1.4.11 wants - // 3.0 for a non-text boundary; this clears it - // on every surface. - // focusRing 400 same value, same 4.42:1 worst case. A focus - // ring is a non-text boundary too, and it must - // stay visible where a control sits on a hover - // fill, not just on the page. - // subtle 900 #1B5E20 tinted fill, 2.10:1 vs the body -- visible as - // a region without competing with content. - // muted 800 / emphasized 700 hover and active steps above it. + // green button is a hole; the bright chip reads as a control. // // CAVEAT, and it is the one soft spot on this ramp: Chakra's // `subtle` and `surface` variants paint colorPalette.fg on - // colorPalette.subtle, and 300 on 900 is 3.91:1 -- under the + // colorPalette.subtle, and dark 300 on 900 is 3.91:1 -- under the // 4.5:1 body floor. Material Green 900 is a mid-dark green, not - // the near-black that the old hand-tuned teal 900 (#043216) was, - // and the ramp has nothing darker. So text placed on - // brandGreen.subtle must be named explicitly -- 50 #E8F5E9 gives - // 7.00:1 -- and `variant="subtle"`/`"surface"` is not approved for - // brandGreen body text until a semantic pairing exists. No call - // site uses either variant on this palette today; every brandGreen - // consumer is a solid button, a checkbox, or a spinner. + // a near-black, and the ramp has nothing darker. So text placed + // on brandGreen.subtle must be named explicitly -- 50 #E8F5E9 + // gives 7.00:1 on the dark subtle fill -- and + // `variant="subtle"`/`"surface"` is not approved for brandGreen + // body text until a semantic pairing exists. No call site uses + // either variant on this palette today; every brandGreen consumer + // is a solid button, a checkbox, or a spinner. + // + // `muted`/`emphasized` are not tabled in DESIGN.md; dark keeps + // this file's pre-existing 800/700 hover/active steps, and light + // follows the same one-step-per-slot pattern as `subtle` (50). brandGreen: { - contrast: { value: { _light: "{colors.greyBackground}", _dark: "{colors.greyBackground}" } }, - fg: { value: { _light: "{colors.brandGreen.300}", _dark: "{colors.brandGreen.300}" } }, - subtle: { value: { _light: "{colors.brandGreen.900}", _dark: "{colors.brandGreen.900}" } }, - muted: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.800}" } }, - emphasized: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.700}" } }, - solid: { value: { _light: "{colors.brandGreen.500}", _dark: "{colors.brandGreen.500}" } }, - focusRing: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, - border: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, + contrast: { value: { _light: "white", _dark: "{colors.greyBackground}" } }, + fg: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.300}" } }, + subtle: { value: { _light: "{colors.brandGreen.50}", _dark: "{colors.brandGreen.900}" } }, + muted: { value: { _light: "{colors.brandGreen.100}", _dark: "{colors.brandGreen.800}" } }, + emphasized: { value: { _light: "{colors.brandGreen.200}", _dark: "{colors.brandGreen.700}" } }, + solid: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.500}" } }, + focusRing: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.400}" } }, + border: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.400}" } }, }, // DEPRECATED ALIAS -- see the brandTeal ramp above. Every slot mirrors - // brandGreen exactly, so `colorPalette="brandTeal"` renders as - // `colorPalette="brandGreen"` down to the pixel. Transitional only: - // it exists so components/Navbar.js and the three in-progress pages - // can keep saying brandTeal while they are owned elsewhere, and it - // dies with them once every reference is renamed. + // brandGreen's new light/dark pair exactly, so `colorPalette="brandTeal"` + // renders as `colorPalette="brandGreen"` down to the pixel, in both + // modes. Transitional only: it exists so components/Navbar.js and the + // three in-progress pages can keep saying brandTeal while they are + // owned elsewhere, and it dies with them once every reference is + // renamed. brandTeal: { - contrast: { value: { _light: "{colors.greyBackground}", _dark: "{colors.greyBackground}" } }, - fg: { value: { _light: "{colors.brandGreen.300}", _dark: "{colors.brandGreen.300}" } }, - subtle: { value: { _light: "{colors.brandGreen.900}", _dark: "{colors.brandGreen.900}" } }, - muted: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.800}" } }, - emphasized: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.700}" } }, - solid: { value: { _light: "{colors.brandGreen.500}", _dark: "{colors.brandGreen.500}" } }, - focusRing: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, - border: { value: { _light: "{colors.brandGreen.400}", _dark: "{colors.brandGreen.400}" } }, + contrast: { value: { _light: "white", _dark: "{colors.greyBackground}" } }, + fg: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.300}" } }, + subtle: { value: { _light: "{colors.brandGreen.50}", _dark: "{colors.brandGreen.900}" } }, + muted: { value: { _light: "{colors.brandGreen.100}", _dark: "{colors.brandGreen.800}" } }, + emphasized: { value: { _light: "{colors.brandGreen.200}", _dark: "{colors.brandGreen.700}" } }, + solid: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.500}" } }, + focusRing: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.400}" } }, + border: { value: { _light: "{colors.brandGreen.700}", _dark: "{colors.brandGreen.400}" } }, }, + // brandLime -- legacy, not tabled in DESIGN.md §1 (flagged there for + // deletion once JsPsychIcon is confirmed as its only consumer). + // Left untouched: out of this migration's scope. brandLime: { contrast: { value: { _light: "white", _dark: "white" } }, fg: { value: { _light: "{colors.brandLime.500}", _dark: "{colors.brandLime.300}" } }, @@ -261,31 +317,34 @@ const config = defineConfig({ focusRing: { value: { _light: "{colors.brandLime.500}", _dark: "{colors.brandLime.500}" } }, border: { value: { _light: "{colors.brandLime.500}", _dark: "{colors.brandLime.400}" } }, }, + // brandRed -- exclusively for irreversible destruction (DESIGN.md + // §5). DESIGN.md §1 table: + // + // fg 700 #A82E16 light (5.92) / 300 #F17761 dark (4.86 on bg.muted) + // solid 700 light (fill 6.37) / 600 dark (fill 3.42) + // contrast white light (6.85) / white dark (4.85) + // subtle 50 #FDE8E4 light (text 800 -> 8.30) / + // 900 #4A1509 dark (text gray.200 -> 11.80) + // border 600 #D13A1B light (4.51) / 400 #EF5A3E dark (4.89) + // + // Every dark value above is unchanged from this file's pre-existing + // (until now dormant) `_dark` slots -- this migration activates them. + // `muted`/`emphasized` follow the brandOrange/brandGreen pattern + // (dark keeps the old 800/700 steps; light is subtle+1/+2 ramp + // steps). `focusRing` mirrors `border`. brandRed: { contrast: { value: { _light: "white", _dark: "white" } }, - fg: { value: { _light: "{colors.brandRed.500}", _dark: "{colors.brandRed.300}" } }, - subtle: { value: { _light: "{colors.brandRed.100}", _dark: "{colors.brandRed.900}" } }, - muted: { value: { _light: "{colors.brandRed.200}", _dark: "{colors.brandRed.800}" } }, - emphasized: { value: { _light: "{colors.brandRed.300}", _dark: "{colors.brandRed.700}" } }, - solid: { value: { _light: "{colors.brandRed.600}", _dark: "{colors.brandRed.600}" } }, - focusRing: { value: { _light: "{colors.brandRed.500}", _dark: "{colors.brandRed.500}" } }, - border: { value: { _light: "{colors.brandRed.500}", _dark: "{colors.brandRed.400}" } }, + fg: { value: { _light: "{colors.brandRed.700}", _dark: "{colors.brandRed.300}" } }, + subtle: { value: { _light: "{colors.brandRed.50}", _dark: "{colors.brandRed.900}" } }, + muted: { value: { _light: "{colors.brandRed.100}", _dark: "{colors.brandRed.800}" } }, + emphasized: { value: { _light: "{colors.brandRed.200}", _dark: "{colors.brandRed.700}" } }, + solid: { value: { _light: "{colors.brandRed.700}", _dark: "{colors.brandRed.600}" } }, + focusRing: { value: { _light: "{colors.brandRed.600}", _dark: "{colors.brandRed.400}" } }, + border: { value: { _light: "{colors.brandRed.600}", _dark: "{colors.brandRed.400}" } }, }, }, }, }, - globalCss: { - body: { - bg: "greyBackground", - color: "white", - }, - label: { - color: "white", - }, - input: { - color: "white", - }, - }, }); export const system = createSystem(defaultConfig, config); diff --git a/package-lock.json b/package-lock.json index d7b7337..1d9fead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "lucide-react": "^0.575.0", "nanoid": "^5.0.0", "next": "^16.1.6", + "next-themes": "^0.4.6", "prismjs": "^1.30.0", "react": "^19.2.4", "react-bootstrap": "^2.5.0", @@ -17769,6 +17770,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", diff --git a/package.json b/package.json index 6206c0d..ab038e3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lucide-react": "^0.575.0", "nanoid": "^5.0.0", "next": "^16.1.6", + "next-themes": "^0.4.6", "prismjs": "^1.30.0", "react": "^19.2.4", "react-bootstrap": "^2.5.0", diff --git a/pages/_app.js b/pages/_app.js index 843fa98..4d5de79 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -4,6 +4,7 @@ import Footer from "../components/Footer"; import { UserContext } from "../lib/context"; import { ChakraProvider } from "@chakra-ui/react"; import { Box, Center } from "@chakra-ui/react"; +import { ThemeProvider } from "next-themes"; import { auth } from "../lib/firebase"; import { useAuthState } from "react-firebase-hooks/auth"; @@ -40,25 +41,33 @@ function MyApp({ Component, pageProps }) { )); return ( - - - - DataPipe - - {/* Vector favicon first so modern browsers get the new mark at any - resolution; PNG/ICO fallbacks follow for browsers that don't - support type="image/svg+xml" icons. */} - - - - - - - - - {getLayout()} - - + + + + + DataPipe + + {/* Vector favicon first so modern browsers get the new mark at any + resolution; PNG/ICO fallbacks follow for browsers that don't + support type="image/svg+xml" icons. */} + + + + + + + + + {getLayout()} + + + ); } diff --git a/styles/Home.module.css b/styles/Home.module.css deleted file mode 100644 index 62c99cf..0000000 --- a/styles/Home.module.css +++ /dev/null @@ -1,117 +0,0 @@ -.container { - padding: 0 2rem; -} - -.main { - min-height: 100vh; - padding: 4rem 0; - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.footer { - display: flex; - flex: 1; - padding: 2rem 0; - border-top: 1px solid #eaeaea; - justify-content: center; - align-items: center; -} - -.footer a { - display: flex; - justify-content: center; - align-items: center; - flex-grow: 1; -} - -.title a { - color: #0070f3; - text-decoration: none; -} - -.title a:hover, -.title a:focus, -.title a:active { - text-decoration: underline; -} - -.title { - margin: 0; - line-height: 1.15; - font-size: 4rem; -} - -.title, -.description { - text-align: center; -} - -.description { - margin: 4rem 0; - line-height: 1.5; - font-size: 1.5rem; -} - -.code { - background: #fafafa; - border-radius: 5px; - padding: 0.75rem; - font-size: 1.1rem; - font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, - Bitstream Vera Sans Mono, Courier New, monospace; -} - -.grid { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - max-width: 800px; -} - -.card { - margin: 1rem; - padding: 1.5rem; - text-align: left; - color: inherit; - text-decoration: none; - border: 1px solid #eaeaea; - border-radius: 10px; - transition: color 0.15s ease, border-color 0.15s ease; - max-width: 300px; -} - -.card:hover, -.card:focus, -.card:active { - color: #0070f3; - border-color: #0070f3; -} - -.card h2 { - margin: 0 0 1rem 0; - font-size: 1.5rem; -} - -.card p { - margin: 0; - font-size: 1.25rem; - line-height: 1.5; -} - -.logo { - height: 1em; - margin-left: 0.5rem; -} - -@media (max-width: 600px) { - .grid { - width: 100%; - flex-direction: column; - } -} - diff --git a/styles/globals.css b/styles/globals.css index 79c9b46..101022e 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -6,8 +6,13 @@ body { Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; display: grid; height: 100%; - background-color: #1C1F22; - color: white; + /* DESIGN.md §1/§2: was hardcoded to the permanently-dark body (#1C1F22 / + white). Now reads the theme's `bg`/`fg` semantic tokens directly, so the + body flips with the `dark`/`light` class next-themes puts on -- + with the app defaulting dark (see pages/_app.js), this resolves to the + identical #1C1F22 / white(ish, via fg's gray.50) pixels today. */ + background-color: var(--chakra-colors-bg); + color: var(--chakra-colors-fg); } #__next { @@ -23,9 +28,12 @@ a { box-sizing: border-box; } +/* DESIGN.md §7: the ring was hardcoded white/darkblue, values that only ever + worked against the forced-dark body. `border` and `brandGreen.solid` are + theme-consistent in both modes. */ .loader { - border: 10px solid white; - border-top: 10px solid darkblue; + border: 10px solid var(--chakra-colors-border); + border-top: 10px solid var(--chakra-colors-brand-green-solid); border-radius: 50%; width: 50px; height: 50px; @@ -41,20 +49,33 @@ a { } } +/* DESIGN.md §7: every animation needs a prefers-reduced-motion story. The + spin had none. Reduced motion drops the rotation and leaves a static ring; + the required visible "Loading…" label lives in the component that renders + .loader (out of this file's scope) and is shown regardless of motion + preference. */ +@media (prefers-reduced-motion: reduce) { + .loader { + animation: none; + } +} + /* hide Firebase Emulator warning banner, we are using custom one */ .firebase-emulator-warning { display: none; } .sticky-alert { - position: fixed; + position: fixed; left: 0; right: 0; bottom: 0; - z-index: 1000; + /* DESIGN.md §5: the app's only arbitrary z-index. TestEnvironmentWarning + is the `banner` layer. */ + z-index: var(--chakra-z-index-banner); display: flex; - justify-content: center; - pointer-events: none; + justify-content: center; + pointer-events: none; } pre { From 38f2659559fbd8c123a64468aa02429b1527ccb5 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 22 Aug 2026 14:38:29 -0400 Subject: [PATCH 109/181] fix(theme): light-safe primitives; code.*, status.*, logo.mark tokens The dashboard critique measured StatusIndicator's hardcoded dark-palette icon colors at 2.39-2.61:1 on the light page; icons now ride the new semantic status.* aliases, SettingsSection's danger variant rides brandRed.fg/border, and ConfirmDialog surfaces on bg.panel with the mandatory panel border. Adds the mode-invariant code.* device tokens (computed against the real gray.950 #111111) and logo.mark. Fixes the loader track to border.subtle (arc-vs-track was 1.06:1 in light mode) and corrects DESIGN.md's ORCID-glyph note at the source. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 43 ++++++++++++++++++++----- components/ui/ConfirmDialog.js | 7 ++++- components/ui/SettingsSection.js | 17 ++++------ components/ui/StatusIndicator.js | 54 ++++++++++++++------------------ lib/theme.js | 46 +++++++++++++++++++++++++++ styles/globals.css | 2 +- 6 files changed, 117 insertions(+), 52 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a9ae27e..906c47d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -161,6 +161,23 @@ authoritative over the ramp, not the other way round. | Bar + chevron 1, dark bg | `#F2F5F1` | 15.06 on `#1C1F22`. Not `fg` — the mark keeps its own paper white | | Chevron 2 (echo) | `#8BC34A` | **Mark only.** Identical in both modes by design | +**The navbar is mode-aware, not a permanent dark slab.** In light mode it is a light +bar (`bg` + `border`-bottom) and the mark/wordmark render via `logo.mark` +(`#2E7D32` light / `#F2F5F1` dark — the mark's own colors, deliberately not `fg`). +The logo handoff specifies both grounds; keeping the bar dark on a light page would +read as an unconverted region, not a brand device. + +**Mode-invariant `code.*` tokens** carry the code-specimen "device" (landing terminal +mock, `CodeBlock`, `CodeHints`): `code.bg = gray.950 #111111` (17.57:1 against the +light page — a deliberate object), `code.bg.header`, `code.bg.active`, `code.border = +gray.500` (the seam: 4.50 light / 3.43 dark, one value both modes), `code.border.subtle`, +`code.fg 12.78:1`, `code.fg.strong`, `code.fg.muted 7.37:1`, `code.comment`, +`code.string 10.91:1`, `code.fn 9.38:1`. `_light` and `_dark` are identical *on +purpose*; the invariance is the design. + +**`status.*` aliases** exist as tokens (`status.ok/warning/error/neutral`) mirroring +each palette's `fg` slot in both modes — components never hand-pick status hues. + **The echo green `#8BC34A` is never a UI color.** It is 2.10:1 on white and off the Material Green ramp entirely — it exists because it is the one tone that holds against both `#FFFFFF` and the logo's `#101A14`, inside a mark where it carries no meaning on @@ -224,8 +241,9 @@ tokens only. Already clean, leave alone: `contact.js`, `redirect.js`, `admin/deleted-account.js`, `dashboard/ErrorPanel.js`, `AuthCheck.js`, `Loader.js`, `TestEnvironmentWarning.js`, `auth/AuthProviderButtons.js`, `account/OsfRelinkButton.js`. Third-party brand SVGs -keep their literal hexes — but `AuthProviderIcons.js:35` `fill="#FFF"` disappears on a -light background and needs a `currentColor` fix. `styles/Home.module.css` is imported +keep their literal hexes — including `AuthProviderIcons.js:35` `fill="#FFF"`, which is +the ORCID glyph *inside* the brand's `#A6CE39` circle, not on the page ground: it is +correct in both modes and must **not** get a `currentColor` "fix". `styles/Home.module.css` is imported nowhere; delete it rather than migrate it. **Phase 3 — ship the toggle.** Only after Phase 2 clears. Light mode must never @@ -235,11 +253,13 @@ render a half-converted page. Then flip the default to `system`. ## 3. Typography -One family. Body, headings, labels, buttons and data all run on the existing system -stack (`-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, …`). **Rubik stays -logo-only** — the wordmark in `Navbar.js` and the `index.js` hero. It is not -introduced anywhere else; a display face in UI labels is a product-register ban, and -a second webfont costs a load for no legibility gain. +One family for UI. Body, headings, labels, buttons and data all run on the existing +system stack (`-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, …`). **Rubik is +retired entirely.** The wordmark is **Space Grotesk, lockup-only** — the `LogoMark` + +wordmark pairing in `Navbar.js` (600 / 22px / -0.03em), owner-ratified with the |> +logo adoption. It appears nowhere else: not in headings, not in the hero, not in +labels — a display face in UI text is a product-register ban, and the single webfont +load is spent on the brand lockup alone. Fixed rem scale, tight ratio, four roles: @@ -368,7 +388,10 @@ orchestrated page-load sequences. (`globals.css:26–42`) has none. Its fallback: under `@media (prefers-reduced-motion: reduce)` drop the `spin` animation, leaving a static ring plus a visible `aria-live` "Loading…" label — the label is required regardless of - motion preference. Its `white` / `darkblue` borders become `border` / `brandGreen.solid`. + motion preference. Its `white` / `darkblue` borders become `border.subtle` (track) / + `brandGreen.solid` (arc) — the arc-vs-track pair computes 3.47:1 light / 3.54:1 dark; + a `border` track would sit at 1.06:1 against the light arc and the ring would appear + static. - Skeletons over centered spinners for content loading in place; spinners are for actions, not regions. @@ -399,5 +422,9 @@ orchestrated page-load sequences. 8. **`focusRing="none"`.** Four instances in `Navbar.js`. Keyboard operability is not optional. 9. **Validation errors before first input.** Gate on touched/dirty, not on value. +11. **Marketing copy that describes a retired product.** The landing page (and any + public surface) must describe what `lib/provider-config.js` actually ships. When + providers change, the landing copy converts in the same slice — stale "OSF" + claims on the trust-deciding surface are a P0, not a copy nit. 10. **Modal as first thought.** Exhaust inline and progressive disclosure first; dialogs are for confirming consequences, not for holding forms that fit on a page. diff --git a/components/ui/ConfirmDialog.js b/components/ui/ConfirmDialog.js index b592550..17972f8 100644 --- a/components/ui/ConfirmDialog.js +++ b/components/ui/ConfirmDialog.js @@ -66,7 +66,12 @@ export default function ConfirmDialog({ - + diff --git a/components/ui/SettingsSection.js b/components/ui/SettingsSection.js index 89a5f39..b96b9a5 100644 --- a/components/ui/SettingsSection.js +++ b/components/ui/SettingsSection.js @@ -36,18 +36,13 @@ import { Box, Heading, Text } from "@chakra-ui/react"; * 6.46:1. Also semantic. NEVER use `fg.subtle` here -- that semantic * token resolves to the literal gray.500 step (#71717a), which is the * same 3.43:1 failure this component exists to retire. - * - Danger heading tint: literal `red.400` (#f87171) -> 5.99:1, clears + * - Danger heading tint: `brandRed.fg` = brandRed.700 light (5.92:1) / + * brandRed.300 dark (4.86:1 worst case), clears * the 4.5:1 body-text floor. - * - Danger border: literal `red.500` (#ef4444) -> 4.40:1 against + * - Danger border: `brandRed.border` = brandRed.600 light (4.51:1) / + * brandRed.400 dark (4.89:1) against * #1C1F22, clears the 3:1 floor WCAG 1.4.11 sets for non-text UI * boundaries. - * NOTE (dark-surface assumption): `red.400`/`red.500` above are literal - * Chakra palette steps, not house semantic tokens -- there is no - * semantic "danger"/"error" text or border token in lib/theme.js yet - * (only the brand palettes and the re-pointed `gray` get that - * treatment). Revisit these two literal references when the light/dark - * mode migration lands; they were chosen and measured for the current - * permanently-dark surface only. * * @param {string} title - Required. Rendered as an

. Pass sentence * case ("Storage providers", not "STORAGE PROVIDERS" or "Storage @@ -80,7 +75,7 @@ export default function SettingsSection({ as="h2" size="md" fontWeight="semibold" - color={isDanger ? "red.400" : "fg"} + color={isDanger ? "brandRed.fg" : "fg"} mb={description ? 1 : 4} > {title} @@ -104,7 +99,7 @@ export default function SettingsSection({ diff --git a/components/ui/StatusIndicator.js b/components/ui/StatusIndicator.js index be83683..9dd29e6 100644 --- a/components/ui/StatusIndicator.js +++ b/components/ui/StatusIndicator.js @@ -33,30 +33,23 @@ import { CircleCheck, TriangleAlert, CircleX, Minus } from "lucide-react"; * call site that needs an announcement should wrap this in its own live * region at the point the status actually changes. * - * Contrast (measured against the app body #1C1F22, same method as - * lib/theme.js): - * - ok / CircleCheck: literal `brandGreen.500` (#4CAF50) -> 5.96:1. NOT - * Chakra's `green.500`: DESIGN.md §1 commits to one green ("ok" IS the - * brand green), and giving this primitive a second green at birth would - * re-create the two-greens drift it exists to end. This was the retired - * `brandTeal.500` (#13b24b, 5.91:1) until the logo green #2E7D32 became - * the primary; the ramp is Material Green, so `ok` moved with it. - * - warning / TriangleAlert: literal `orange.500` (#f97316) -> 5.91:1. - * - error / CircleX: literal `red.400` (#f87171) -> 5.99:1. - * - neutral / Minus: literal `gray.400` (#a1a1aa) -> 6.46:1. - * All four clear the 3:1 floor WCAG 1.4.11 sets for non-text UI (icons - * count as non-text), with headroom to spare. - * NOTE (dark-surface assumption): all four are literal Chakra palette - * steps passed as raw CSS color strings to lucide-react's `color` prop - * (lucide icons are not Chakra-token-aware), because lib/theme.js has no - * semantic success/warning/error/neutral color tokens yet -- only the - * brand palettes and the re-pointed `gray` get semantic treatment. These - * were chosen and measured for the current permanently-dark surface; - * revisit when the light/dark mode migration adds semantic status - * tokens. - * - Label: `color="fg"` (semantic, gray.50 / #fafafa) -> 15.86:1, well - * above the 4.5:1 body-text floor. Semantic, so it tracks the - * light/dark migration automatically. + * Contrast: icon colors are the semantic `status.*` tokens from + * lib/theme.js (DESIGN.md §1 status aliases -- one green, no blue), so + * every value is mode-aware and computed in both modes against the worst + * permitted surface: + * - ok / CircleCheck: `status.ok` = brandGreen.800 light (4.77:1) / + * brandGreen.300 dark (6.71:1). + * - warning / TriangleAlert: `status.warning` = brandOrange.800 light + * (6.63:1) / brandOrange.300 dark (7.80:1). + * - error / CircleX: `status.error` = brandRed.700 light (5.92:1) / + * brandRed.300 dark (4.86:1 on bg.muted, the worst case). + * - neutral / Minus: `status.neutral` = gray.700 light / gray.300 dark + * (8.30:1 / 9.14:1). + * All clear the 3:1 floor WCAG 1.4.11 sets for non-text UI (icons count + * as non-text) in BOTH modes -- an earlier revision hardcoded dark-mode + * palette steps here, which measured 2.39-2.61:1 on the light page. + * - Label: `color="fg"` -> 13.16:1 light / 12.94:1 dark (worst case + * bg.muted), well above the 4.5:1 body-text floor. * * @param {"ok"|"warning"|"error"|"neutral"} status * @param {string} label - REQUIRED. Always rendered as visible text beside @@ -73,14 +66,13 @@ const STATUS_ICONS = { }; // Raw CSS color strings (Chakra's generated custom properties), not Chakra -// style props -- lucide-react's `color` prop is not token-aware, so this is -// the same `var(--chakra-colors-...)` pattern already used for icon color -// elsewhere in the app (see components/account/ProviderConnections.js). +// style props -- lucide-react's `color` prop is not token-aware. These are +// the semantic `status.*` vars, so they resolve per color mode. const STATUS_COLORS = { - ok: "var(--chakra-colors-brand-green-500)", - warning: "var(--chakra-colors-orange-500)", - error: "var(--chakra-colors-red-400)", - neutral: "var(--chakra-colors-gray-400)", + ok: "var(--chakra-colors-status-ok)", + warning: "var(--chakra-colors-status-warning)", + error: "var(--chakra-colors-status-error)", + neutral: "var(--chakra-colors-status-neutral)", }; export default function StatusIndicator({ status, label, size = 16 }) { diff --git a/lib/theme.js b/lib/theme.js index 57a9cea..131e47e 100644 --- a/lib/theme.js +++ b/lib/theme.js @@ -165,6 +165,52 @@ const config = defineConfig({ // never the sole grouping device. 1.38 light / 1.68 dark vs `bg`. subtle: { value: { _light: "{colors.gray.300}", _dark: "#3F4449" } }, }, + // The code specimen "device" (landing terminal mock, CodeBlock, + // CodeHints). Deliberately MODE-INVARIANT: no _light/_dark split, + // on purpose. The dark panel reads as a distinct object against the + // light page (17.57:1 bg-vs-page), and every fg value below is + // computed against code.bg #111111 (Chakra gray.950 -- NOT tailwind + // zinc's #09090b) / code.bg.header #18181b, so one palette serves + // both modes. Do not "fix" the invariance. + code: { + bg: { + DEFAULT: { value: "{colors.gray.950}" }, // #111111 device surface + header: { value: "{colors.gray.900}" }, // #18181b chrome strip + active: { value: "{colors.gray.800}" }, // active tab fill; never the only cue + }, + border: { + // The seam where the invariant device meets the mode-aware page. + // gray.500: 4.50:1 vs #F5F7F8 and 3.43:1 vs #1C1F22 -- one value + // clears the 3:1 non-text floor in both modes. + DEFAULT: { value: "{colors.gray.500}" }, + subtle: { value: "{colors.gray.800}" }, // hairline inside the device + }, + fg: { + DEFAULT: { value: "{colors.gray.300}" }, // 12.78:1 on code.bg + strong: { value: "{colors.gray.50}" }, // 16.97:1 on header -- active tab label + muted: { value: "{colors.gray.400}" }, // 7.37:1 code.bg / 6.91:1 header + }, + comment: { value: "{colors.gray.400}" }, // 7.37:1 on code.bg + string: { value: "{colors.brandOrange.300}" }, // 10.91:1 -- string literals only + fn: { value: "{colors.brandGreen.300}" }, // 9.38:1 -- fn/plugin names, active-tab rule + }, + // DESIGN.md §1 status aliases -- one green, no blue. Values mirror + // each palette's fg slot so both modes stay computed: ok 4.77 light + // / 6.71 dark, warning 6.63 / 7.80, error 5.92 / 4.86 (worst case + // bg.muted), neutral 8.30 / 9.14. Non-text marks clear 3:1 with room. + // The |> mark and wordmark (docs/brand/logo/README.md): #2E7D32 on + // light grounds, the mark's own paper-white #F2F5F1 (15.06:1 on + // #1C1F22) on dark -- deliberately NOT fg. The echo chevron #8BC34A + // never gets a token: it is mark-internal and never a UI color. + logo: { + mark: { value: { _light: "#2E7D32", _dark: "#F2F5F1" } }, + }, + status: { + ok: { value: { _light: "{colors.brandGreen.800}", _dark: "{colors.brandGreen.300}" } }, + warning: { value: { _light: "{colors.brandOrange.800}", _dark: "{colors.brandOrange.300}" } }, + error: { value: { _light: "{colors.brandRed.700}", _dark: "{colors.brandRed.300}" } }, + neutral: { value: { _light: "{colors.gray.700}", _dark: "{colors.gray.300}" } }, + }, // HISTORICAL CONTEXT (the bug this originally fixed, now superseded // by the real light/dark split below): before this file had a // light/dark mode at all, DataPipe rendered on a permanently dark diff --git a/styles/globals.css b/styles/globals.css index 101022e..cb66269 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -32,7 +32,7 @@ a { worked against the forced-dark body. `border` and `brandGreen.solid` are theme-consistent in both modes. */ .loader { - border: 10px solid var(--chakra-colors-border); + border: 10px solid var(--chakra-colors-border-subtle); border-top: 10px solid var(--chakra-colors-brand-green-solid); border-radius: 50%; width: 50px; From 07fb142952a86092f1714695002ff9f11d877ae5 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 22 Aug 2026 14:46:18 -0400 Subject: [PATCH 110/181] fix(account): retire the last pre-rebuild surface remnants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialog panels move off greyBackground/white onto bg.panel + fg with the mandatory panel border; every brandGreen.fg link gains the persistent underline DESIGN.md §5 now spells out (no green/body pair clears 3:1 in either mode, so color alone can never mark a link); the add-sign-in banner rides brandOrange.subtle; the spinner rides brandGreen.solid. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 6 +++++- components/account/AddSignInMethodBanner.js | 6 +++--- components/account/ChangePassword.js | 7 ++++++- components/account/LinkedAccounts.js | 7 ++++++- components/account/OAuthTokenStatus.js | 1 + components/account/ProviderConnections.js | 2 +- components/account/SelectAuth.js | 12 ++++++++++-- pages/admin/account.js | 2 +- 8 files changed, 33 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 906c47d..dd1b115 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -308,7 +308,11 @@ cannot carry alone gets a bordered container (see `SettingsSection` danger varia - **`blue` is retired as an action color.** Five `colorPalette="blue"` and five raw `blue.500` links remain (`ProviderConnections.js:188,247`, `SelectAuth.js:120`, `OAuthTokenStatus.js:92`, both `oauth2/*` pages, `QueuePanel.js` status map). - Links become `brandGreen.fg`; secondary buttons become neutral outline. + Links become `brandGreen.fg` **with a persistent underline** — no green/body text + pair reaches the 3:1 color-difference floor in either mode (2.04 light / 1.36 dark), + so color alone can never mark a link. `globals.css` strips the default underline; + every prose link sets it back explicitly. + Secondary buttons become neutral outline. - **`brandRed` is exclusively for irreversible destruction** — account deletion, experiment deletion. Routine, reversible actions (disconnect a provider, unlink a sign-in method) are **neutral outline**. Red that means "routine" cannot also mean diff --git a/components/account/AddSignInMethodBanner.js b/components/account/AddSignInMethodBanner.js index 584f26b..1a97d95 100644 --- a/components/account/AddSignInMethodBanner.js +++ b/components/account/AddSignInMethodBanner.js @@ -41,9 +41,9 @@ export default function AddSignInMethodBanner() { return ( Add a way to sign in - + You currently sign in to DataPipe through OSF, which is being retired. Link another provider now and you will keep this account, your experiments, and your settings exactly as they are. diff --git a/components/account/ChangePassword.js b/components/account/ChangePassword.js index 2b5f1f0..4c8b696 100644 --- a/components/account/ChangePassword.js +++ b/components/account/ChangePassword.js @@ -102,7 +102,12 @@ export default function ChangePassword() { setOpen(e.open)}> - + diff --git a/components/account/LinkedAccounts.js b/components/account/LinkedAccounts.js index e3b483a..5394e77 100644 --- a/components/account/LinkedAccounts.js +++ b/components/account/LinkedAccounts.js @@ -258,7 +258,12 @@ function AddPasswordRow({ user, setAfterAction }) { setOpen(e.open)}> - + diff --git a/components/account/OAuthTokenStatus.js b/components/account/OAuthTokenStatus.js index f4c10b4..f35fe8f 100644 --- a/components/account/OAuthTokenStatus.js +++ b/components/account/OAuthTokenStatus.js @@ -54,6 +54,7 @@ export default function OAuthTokenStatus({ data }) { target="_blank" rel="noopener noreferrer" color="brandGreen.fg" + textDecoration="underline" fontSize="sm" fontWeight="medium" > diff --git a/components/account/ProviderConnections.js b/components/account/ProviderConnections.js index 9b20314..6b344a4 100644 --- a/components/account/ProviderConnections.js +++ b/components/account/ProviderConnections.js @@ -252,7 +252,7 @@ export default function ProviderConnections({ data }) { No storage connected yet — connect one to create your first experiment.{" "} - + How to choose a provider diff --git a/components/account/SelectAuth.js b/components/account/SelectAuth.js index f74c515..0bedb83 100644 --- a/components/account/SelectAuth.js +++ b/components/account/SelectAuth.js @@ -110,6 +110,7 @@ export default function SelectAuth({ data }) { setIsTokenOpen(e.open)}> - + @@ -165,7 +172,8 @@ export default function SelectAuth({ data }) { To generate an OSF token, go to{" "} - + ); } From 1c08cd64007f836125813042dd92a6819693f1d8 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 22 Aug 2026 14:47:14 -0400 Subject: [PATCH 111/181] feat(chrome): mode-aware navbar and footer, banner guard fix Navbar becomes a bordered bg bar whose mark and wordmark ride the logo.mark token (#2E7D32 light / #F2F5F1 dark); the four focusRing=none bans fall, the mobile menu gains the missing Settings item, double tab-stop link nesting collapses to asChild, and the last brandTeal ref renames (the theme alias stays until the docs-page WIP lands). Footer moves to bg.subtle with underlined brandGreen links and a real contentinfo landmark. The test-environment banner no longer renders when NEXT_PUBLIC_OSF_ENV is undefined, speaks in the product register, and rides brandOrange warning tokens (7.00:1 light / 8.39:1 dark). Co-Authored-By: Claude Fable 5 --- components/Footer.js | 50 ++++--- components/Navbar.js | 201 ++++++++++++++++++--------- components/TestEnvironmentWarning.js | 48 +++++-- pages/_app.js | 6 +- 4 files changed, 207 insertions(+), 98 deletions(-) diff --git a/components/Footer.js b/components/Footer.js index 43dd5df..be549df 100644 --- a/components/Footer.js +++ b/components/Footer.js @@ -1,12 +1,11 @@ import { Box, Container, - HStack, - VStack, Link, Stack, Text, Button, + VisuallyHidden, } from "@chakra-ui/react"; import { OpenCollectiveIcon } from "./OpenCollectiveIcon"; import { JsPsychIcon } from "./JsPsychIcon"; @@ -14,7 +13,10 @@ import NextLink from "next/link"; export default function Footer() { return ( - + // DESIGN.md §1: the footer is the app's second neutral layer, so it + // moves to `bg.subtle` rather than matching the page (`bg`) -- that + // reading survives the light/dark split instead of collapsing into it. + - Created by the developers of jsPsych + Created by the developers of jsPsych{" "} + Report an Issue + (opens in a new tab) GitHub + (opens in a new tab) - + Contact Us - - - + (opens in a new tab) + + diff --git a/components/Navbar.js b/components/Navbar.js index 1e76e2a..fc3477b 100644 --- a/components/Navbar.js +++ b/components/Navbar.js @@ -1,5 +1,6 @@ import NextLink from "next/link"; import { useContext, useSyncExternalStore } from "react"; +import { useRouter } from "next/router"; import { UserContext } from "../lib/context"; import { Box, @@ -45,54 +46,64 @@ export default function Navbar() { const { user } = useContext(UserContext); const hydrated = useHydrated(); const showUser = hydrated ? user : null; + const router = useRouter(); + const current = (href) => (router.pathname === href ? "page" : undefined); return ( - + - {/* Explicit dark-surface colorway (README.md's "Dark bg" - column): the app renders on a permanently dark surface - (see lib/theme.js), so this is not a light/dark toggle -- - hardcode it rather than lean on currentColor inheritance - across the font className boundary below. */} - + {/* The mark and wordmark render in the logo's own colors, not + the page `fg` -- DESIGN.md §1 "Logo" / "The navbar is + mode-aware": `logo.mark` resolves #2E7D32 light / #F2F5F1 + dark, deliberately distinct from `fg` in both modes. + LogoMark is a raw SVG component whose `color` prop lands + directly on a `fill` attribute, so it needs a real CSS + value rather than a Chakra token path -- the resolved CSS + custom property does that. The Text wordmark is a Chakra + component, so it can reference the token path directly. + The echo chevron keeps LogoMark's own #8BC34A default -- + mark-only, never themed, LogoMark internals untouched. */} + DataPipe - - + + Getting Started - + API Docs - + FAQ {showUser && ( - + My Experiments )} @@ -101,45 +112,39 @@ export default function Navbar() { {!showUser && ( <> - - - - - - + + )} {showUser && ( <> - - - + + + ); +} + +// The send half of the hero specimen: two snippets, user-controlled. +// +// This replaced a 6-second `setInterval` that flipped the panel underneath the +// reader and silently reverted their own click (WCAG 2.2.2 Pause/Stop/Hide, +// Level A). Chakra's Tabs supplies role="tablist", aria-selected, roving +// tabindex and arrow keys, and only the selected panel is in the accessibility +// tree -- the old version hid the inactive snippet with `opacity: 0`, so a +// screen reader read out both. Nothing here moves on its own, so there is no +// motion to give a reduced-motion story to. +export default function CodeSpecimen() { + // Controlled only so the Copy button copies the snippet the reader is + // actually looking at. Nothing but a click or an arrow key changes it. + const [value, setValue] = useState(snippets[0].id); + const active = snippets.find((s) => s.id === value) ?? snippets[0]; + + return ( + setValue(e.value)} + variant="plain" + bg="code.bg" + borderWidth="1px" + // The seam where the invariant device meets the mode-aware page: + // gray.500 is 4.50:1 on the light page and 3.43:1 on the dark one, both + // clear of the 3:1 non-text floor, with one value and no branch. + borderColor="code.border" + borderRadius={12} + overflow="hidden" + > + + + {snippets.map((s) => ( + + {s.label} + + ))} + + + + + {snippets.map((s) => ( + + + // inside an `overflow: hidden` parent silently loses the tail of a + // long line without this. + overflowX="auto" + > + {s.lines.map((line, i) => ( + + {line.text} + + ))} + + + ))} + + ); +} diff --git a/components/home/hero-snippets.js b/components/home/hero-snippets.js new file mode 100644 index 0000000..742a59f --- /dev/null +++ b/components/home/hero-snippets.js @@ -0,0 +1,87 @@ +// Landing-page code specimen data (rendered by components/home/CodeSpecimen.js). +// +// Every span carries a ROLE, never a color. The four roles below map onto the +// mode-invariant `code.*` semantic tokens in lib/theme.js (DESIGN.md §1), which +// turns ~46 hand-placed palette literals into four names. The device renders +// IDENTICALLY in light and dark mode and that invariance is deliberate: what is +// being shown is the code you are about to paste, so its identity should not be +// a function of the reader's OS theme. Do not "convert" it. +// +// Ratios below are computed against `code.bg` = Chakra `gray.950` = #111111 +// (NOT zinc's #09090b), which is what this page actually renders: +export const CODE_ROLE = { + comment: "code.comment", // gray.400 -- 7.37:1 on code.bg + fg: "code.fg", // gray.300 -- 12.78:1 on code.bg + string: "code.string", // brandOrange.300 -- 10.91:1. String literals ONLY. + fn: "code.fn", // brandGreen.300 -- 9.38:1. Function / plugin names. +}; + +// `string` is reserved for quoted literals. Bare identifiers (save_data, +// filename, dataAsString) are `fg`: a variable is not a string, and colouring +// them alike taught the reader something false about the language. +export const snippets = [ + { + id: "jspsych", + label: "jsPsych", + lines: [ + { role: "comment", text: "// Save data with the jsPsych pipe plugin\n" }, + { role: "fg", text: "const " }, + { role: "fg", text: "save_data" }, + { role: "fg", text: " = {\n" }, + { role: "fg", text: " type: " }, + { role: "fn", text: "jsPsychPipe" }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " action: " }, + { role: "string", text: '"save"' }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " experiment_id: " }, + { role: "string", text: '"your_id"' }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " filename: " }, + { role: "fg", text: "filename" }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " data_string: " }, + { role: "fg", text: "() =>\n" }, + { role: "fg", text: " jsPsych.data.get()." }, + { role: "fn", text: "csv" }, + { role: "fg", text: "()\n" }, + { role: "fg", text: "};" }, + ], + }, + { + id: "javascript", + label: "JavaScript", + lines: [ + { role: "comment", text: "// Send data with a fetch request\n" }, + { role: "fn", text: "fetch" }, + { role: "fg", text: "(url, {\n" }, + { role: "fg", text: " method: " }, + { role: "string", text: '"POST"' }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " headers: {\n" }, + { role: "fg", text: " " }, + { role: "string", text: '"Content-Type"' }, + { role: "fg", text: ": " }, + { role: "string", text: '"application/json"' }, + { role: "fg", text: "\n },\n" }, + { role: "fg", text: " body: JSON." }, + { role: "fn", text: "stringify" }, + { role: "fg", text: "({\n" }, + { role: "fg", text: " experimentID: " }, + { role: "string", text: '"your_id"' }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " filename: " }, + { role: "string", text: '"subject01.csv"' }, + { role: "fg", text: ",\n" }, + { role: "fg", text: " data: " }, + { role: "fg", text: "dataAsString" }, + { role: "fg", text: "\n })\n});" }, + ], + }, +]; + +// What the Copy button puts on the clipboard: the same spans, concatenated, so +// the copied text can never drift from the rendered text. +export function snippetText(snippet) { + return snippet.lines.map((line) => line.text).join(""); +} diff --git a/pages/index.js b/pages/index.js index f6015ba..b802e6c 100644 --- a/pages/index.js +++ b/pages/index.js @@ -1,5 +1,5 @@ -import Link from "next/link"; -import { useContext, useState, useEffect } from "react"; +import NextLink from "next/link"; +import { useContext } from "react"; import { UserContext } from "../lib/context"; import { Box, @@ -9,443 +9,288 @@ import { Text, Button, Stack, + Link, } from "@chakra-ui/react"; -import { ArrowRight, Database, Shield, Zap, BookOpen } from "lucide-react"; +import { ArrowRight } from "lucide-react"; import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import TestEnvironmentWarning from "../components/TestEnvironmentWarning"; +import CodeSpecimen from "../components/home/CodeSpecimen"; +import ArrivalsPanel from "../components/home/ArrivalsPanel"; +import { osfSunsetLabel } from "../lib/osf-sunset"; +// Prose links carry a PERSISTENT underline. styles/globals.css strips +// underlines globally, and no color on this palette can carry a link by color +// alone in both modes -- against body text, light brandGreen.800 vs gray.700 is +// 2.04:1 and dark brandGreen.300 vs gray.300 is 1.36:1, both under WCAG F73's +// 3:1. The underline is what makes a link a link here; the color is secondary. +function ProseLink({ href, external, children }) { + const style = { + color: "brandGreen.fg", + textDecoration: "underline", + textUnderlineOffset: "2px", + _hover: { textDecorationThickness: "2px" }, + }; -function FeatureCard({ icon, title, children }) { - return ( - - - {icon} - - - {title} - - + if (external) { + return ( + {children} - - + + ); + } + + return ( + + {children} + ); } function StepItem({ number, children }) { return ( - + {number}. - - {children} - + {/* The three steps are the argument for the product, not a hint under a + field: body size, body color (DESIGN.md §3). */} + {children} ); } -const snippets = [ - { - label: "jsPsych", - filename: "experiment.html", - caption: "Using jsPsych? One trial saves all your data.", - lines: [ - { color: "gray.500", text: "// Save data with the jsPsych pipe plugin\n" }, - { color: "gray.300", text: "const " }, - { color: "brandOrange.300", text: "save_data" }, - { color: "gray.300", text: " = {\n" }, - { color: "gray.300", text: " type: " }, - { color: "brandGreen.300", text: "jsPsychPipe" }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: ' action: ' }, - { color: "brandOrange.300", text: '"save"' }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: " experiment_id: " }, - { color: "brandOrange.300", text: '"your_id"' }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: " filename: " }, - { color: "brandOrange.300", text: "filename" }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: " data_string: " }, - { color: "gray.400", text: "() =>\n" }, - { color: "gray.300", text: " jsPsych.data.get()." }, - { color: "brandGreen.300", text: "csv" }, - { color: "gray.300", text: "()\n" }, - { color: "gray.300", text: "};" }, - ], - }, - { - label: "JavaScript", - filename: "experiment.js", - caption: "Not using jsPsych? A single fetch call is all you need.", - lines: [ - { color: "gray.500", text: "// Send data with a fetch request\n" }, - { color: "brandGreen.300", text: "fetch" }, - { color: "gray.300", text: "(url, {\n" }, - { color: "gray.300", text: " method: " }, - { color: "brandOrange.300", text: '"POST"' }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: " headers: {\n" }, - { color: "gray.300", text: " " }, - { color: "brandOrange.300", text: '"Content-Type"' }, - { color: "gray.300", text: ": " }, - { color: "brandOrange.300", text: '"application/json"' }, - { color: "gray.300", text: "\n },\n" }, - { color: "gray.300", text: " body: JSON." }, - { color: "brandGreen.300", text: "stringify" }, - { color: "gray.300", text: "({\n" }, - { color: "gray.300", text: " experimentID: " }, - { color: "brandOrange.300", text: '"your_id"' }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: " filename: " }, - { color: "brandOrange.300", text: '"subject01.csv"' }, - { color: "gray.300", text: ",\n" }, - { color: "gray.300", text: " data: " }, - { color: "brandOrange.300", text: "dataAsString" }, - { color: "gray.300", text: "\n })\n});" }, - ], - }, -]; - -function HeroCodeSnippet() { - const [activeIndex, setActiveIndex] = useState(0); - const [fading, setFading] = useState(false); - - useEffect(() => { - const interval = setInterval(() => { - setFading(true); - setTimeout(() => { - setActiveIndex((i) => (i + 1) % snippets.length); - setFading(false); - }, 300); - }, 6000); - return () => clearInterval(interval); - }, []); - - const snippet = snippets[activeIndex]; - +function Feature({ title, children }) { return ( - - - - - - - - {snippet.filename} - - - {snippets.map((s, i) => ( - - ))} - - - - {snippets.map((s, idx) => ( - - {s.lines.map((line, i) => ( - - {line.text} - - ))} - - ))} - - - - {snippet.caption} + + + {title} + + + {children} - + ); } export default function Home() { const { user } = useContext(UserContext); + // Read from lib/osf-sunset.js, like the FAQ and the getting-started guide, so + // the three surfaces can never disagree about the date. Tolerates a null date + // the same way they do. + const osfDeadline = osfSunsetLabel(); + + const primaryHref = user ? "/admin" : "/signup"; + const primaryLabel = user ? "Go to dashboard" : "Get started"; return ( {/* Hero */} - + - + + {/* No accent word. The only saturated pixels above the fold are the + primary button and the code specimen, which is what "plain and + trustworthy" looks like as a decision rather than an absence. + The retired orange accent was also 1.81:1 on the light page. */} - Experiment data,{" "} - - straight to OSF. - + Experiment data, straight to storage you control. - - A free, open-source service that sends data from any online - experiment directly to the Open Science Framework. No server - setup, no download step. + + DataPipe is a free, open-source service that sends data from any + online experiment to your own Google Drive, Dataverse, or Zenodo + account. No server to set up, no download step. + + The account stays yours throughout: DataPipe only ever asks for + permission to add files. + + {/* TODO(owner): retention claim -- needs a decision on what DataPipe + retains and for how long */} - {user ? ( - - - - ) : ( - - - - )} - - - + {/* asChild, not + {/* One primary action per screen (DESIGN.md §5). The secondary + action is a neutral ghost and takes its colors from the recipe + rather than naming them. */} + - + Built by the{" "} - - jsPsych - - {" "}team + + jsPsych + {" "} + team - {/* Code snippet */} - + {/* The specimen: what you send, then what you get. It renders at every + breakpoint now -- it is the page's proof, and hiding it below 768px + meant most first visits (a link in a paper, a message from a + labmate) never saw it. */} + + + + + The code is the same whichever provider you chose. Full details + are in the{" "} + API reference. + + + + + + Illustration. Each session arrives as its own file in the + folder, dataset, or deposition you connected. + + + - {/* How it works */} - + {/* How it works. No band: #000 was 1.27:1 against the page and + unsalvageable in light mode, and spacing carries the grouping + (DESIGN.md §4). No eyebrow: §8 ban 1. */} + - - - - How it works - - + + + Three steps to start collecting data - - + + - Create an OSF project and link your OSF account to DataPipe. + Connect a storage provider — Google Drive, Dataverse, or Zenodo + — to your DataPipe account. - Set up an experiment on DataPipe and add a few lines of code - to your study to send data through the API. + Create an experiment on DataPipe and add a few lines of code to + your study to send data through the API. - Activate data collection. Participant data goes straight to - your OSF project as files — no downloads, no manual transfers. + Turn on data collection. Each participant's data goes + straight to your folder, dataset, or deposition — no downloads, + no manual transfers. - {/* Features */} - + {/* What DataPipe does */} + - - Features - + + What DataPipe does + - {/* Born-open data */} - - - - - - - Born-open data collection - - - DataPipe sends experiment data directly to a public repository - as it is collected, making openness the default rather than an - afterthought. Read more about the rationale and design in{" "} - - - Behavior Research Methods - - - . If you use DataPipe in your research, we'd appreciate a - citation! - - - + + + Born-open data collection + + + DataPipe sends experiment data to your storage as it is collected, + so openness is the default rather than an afterthought. The + rationale and design are described in{" "} + + Behavior Research Methods + + . If you use DataPipe in your research, we'd appreciate a + citation. + + - - } title="Multiple data formats"> - Send CSV, JSON, or base64-encoded files like audio and video - recordings. DataPipe handles decoding and storage automatically. - - } title="Built-in safeguards"> - Data validation, session limits, and required field checks - protect your OSF project from malformed or malicious submissions. - - } title="Condition assignment"> - Automatically cycle through experimental conditions with - balanced assignment, no server-side code required. - + {/* No icons above the cards. Database / Shield / Zap were decoration + at the same size as the headings they sat on, and one of them meant + nothing at all. */} + + + Send CSV, JSON, or base64-encoded files such as audio and video + recordings. DataPipe decodes them and stores them for you. + + + Data validation, session limits, and required-field checks protect + your storage from malformed or malicious submissions. + + + DataPipe hands out condition numbers in sequence, so assignment + stays balanced as data arrives. No server-side code required. + + {/* A door at the end, for the reader who got this far. Same action as the + hero, not a second competing one. */} + + + + Set up your first experiment + + + Pick a provider, connect it, and paste a few lines of code into your + study. The{" "} + getting started guide{" "} + walks through all of it, end to end. + + + + The FAQ covers what DataPipe + stores, what it costs to run, and what happens when an upload fails. + + + Already collecting on OSF?{" "} + {osfDeadline + ? `DataPipe will stop writing to OSF after ${osfDeadline}.` + : "DataPipe is winding down its support for OSF."}{" "} + Data already there stays in your OSF account —{" "} + what to do next. + + + ); } Home.getLayout = function getLayout(page) { return ( - + - - {page} - + {page}