diff --git a/docs/_data/nav.yml b/docs/_data/nav.yml
index 02eedd790..975898153 100644
--- a/docs/_data/nav.yml
+++ b/docs/_data/nav.yml
@@ -24,6 +24,8 @@
url: /developers/add-stage
- title: Deploying
url: /developers/deploy
+ - title: API Reference
+ url: /developers/api
- title: Feature Docs
pages:
- title: Variables
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
new file mode 100644
index 000000000..283ffb307
--- /dev/null
+++ b/docs/api/openapi.yaml
@@ -0,0 +1,664 @@
+openapi: 3.0.3
+info:
+ title: Deliberate Lab API
+ description: |
+ REST API for programmatic access to Deliberate Lab experiment management.
+
+ ## Authentication
+
+ All API requests require an API key in the Authorization header:
+ ```
+ Authorization: Bearer YOUR_API_KEY
+ ```
+
+ ### Creating an API Key
+
+ 1. Log into the Deliberate Lab web interface
+ 2. Navigate to **Settings** (top right menu)
+ 3. Scroll to the **API Keys** section
+ 4. Click **Create New API Key**
+ 5. Enter a descriptive name for your key
+ 6. Copy the generated key immediately - it will only be shown once
+
+ **Important:** Store your API key securely. You cannot retrieve it after closing the creation dialog.
+
+ ### Managing API Keys
+
+ In the Settings page API Keys section, you can:
+ - View all your API keys
+ - See when each key was created and last used
+ - Revoke keys you no longer need
+
+ ## Rate Limiting
+
+ - **Limit:** 100 requests per 15-minute window per API key
+ - **Response:** HTTP 429 when exceeded
+
+ version: 1.0.0
+servers:
+ - url: https://{project}.cloudfunctions.net/api/v1
+ description: Firebase Cloud Functions API
+ variables:
+ project:
+ default: your-project
+ description: Your Firebase project ID
+
+tags:
+ - name: experiments
+ description: Experiment management operations
+ - name: health
+ description: API health check
+
+security:
+ - BearerAuth: []
+
+paths:
+ /health:
+ get:
+ tags:
+ - health
+ summary: Health check
+ description: Check API health status (does not require authentication)
+ operationId: healthCheck
+ security: []
+ responses:
+ '200':
+ description: API is healthy
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: healthy
+ version:
+ type: string
+ example: 1.0.0
+ timestamp:
+ type: string
+ format: date-time
+ example: 2024-01-01T00:00:00.000Z
+
+ /experiments:
+ get:
+ tags:
+ - experiments
+ summary: List experiments
+ description: Retrieve a list of all experiments you have access to
+ operationId: listExperiments
+ responses:
+ '200':
+ description: Successful operation
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ experiments:
+ type: array
+ description: Array of full experiment objects (without stages subcollection)
+ items:
+ $ref: '#/components/schemas/Experiment'
+ total:
+ type: integer
+ description: Total number of experiments
+ example: 1
+ '401':
+ $ref: '#/components/responses/UnauthorizedError'
+ '403':
+ $ref: '#/components/responses/ForbiddenError'
+ '429':
+ $ref: '#/components/responses/RateLimitError'
+
+ post:
+ tags:
+ - experiments
+ summary: Create experiment
+ description: Create a new experiment with specified configuration
+ operationId: createExperiment
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - name
+ properties:
+ name:
+ type: string
+ description: Experiment name
+ example: My Experiment
+ description:
+ type: string
+ description: Experiment description
+ example: Research on decision making
+ prolificRedirectCode:
+ type: string
+ description: Prolific completion redirect code (optional)
+ example: C1234ABC
+ stages:
+ type: array
+ description: Array of stage configurations (optional)
+ items:
+ $ref: '#/components/schemas/Stage'
+ default: []
+ responses:
+ '201':
+ description: Experiment created successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Experiment'
+ '400':
+ $ref: '#/components/responses/BadRequestError'
+ '401':
+ $ref: '#/components/responses/UnauthorizedError'
+ '403':
+ $ref: '#/components/responses/ForbiddenError'
+ '429':
+ $ref: '#/components/responses/RateLimitError'
+
+ /experiments/{id}:
+ get:
+ tags:
+ - experiments
+ summary: Get experiment
+ description: Retrieve detailed information about a specific experiment
+ operationId: getExperiment
+ parameters:
+ - $ref: '#/components/parameters/ExperimentId'
+ responses:
+ '200':
+ description: Successful operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Experiment'
+ '401':
+ $ref: '#/components/responses/UnauthorizedError'
+ '403':
+ $ref: '#/components/responses/ForbiddenError'
+ '404':
+ $ref: '#/components/responses/NotFoundError'
+ '429':
+ $ref: '#/components/responses/RateLimitError'
+
+ put:
+ tags:
+ - experiments
+ summary: Update experiment
+ description: Update an existing experiment's configuration
+ operationId: updateExperiment
+ parameters:
+ - $ref: '#/components/parameters/ExperimentId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Updated experiment name
+ description:
+ type: string
+ description: Updated description
+ prolificRedirectCode:
+ type: string
+ description: Updated Prolific completion redirect code
+ stages:
+ type: array
+ description: Updated array of stage configurations (optional)
+ items:
+ $ref: '#/components/schemas/Stage'
+ responses:
+ '200':
+ description: Experiment updated successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ updated:
+ type: boolean
+ example: true
+ id:
+ type: string
+ example: exp123
+ '400':
+ $ref: '#/components/responses/BadRequestError'
+ '401':
+ $ref: '#/components/responses/UnauthorizedError'
+ '403':
+ $ref: '#/components/responses/ForbiddenError'
+ '404':
+ $ref: '#/components/responses/NotFoundError'
+ '429':
+ $ref: '#/components/responses/RateLimitError'
+
+ delete:
+ tags:
+ - experiments
+ summary: Delete experiment
+ description: |
+ Delete an experiment and all associated data.
+
+ **Warning:** This operation is permanent and will delete all experiment data including participant responses.
+ operationId: deleteExperiment
+ parameters:
+ - $ref: '#/components/parameters/ExperimentId'
+ responses:
+ '200':
+ description: Experiment deleted successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: string
+ example: exp123
+ deleted:
+ type: boolean
+ example: true
+ '401':
+ $ref: '#/components/responses/UnauthorizedError'
+ '403':
+ $ref: '#/components/responses/ForbiddenError'
+ '404':
+ $ref: '#/components/responses/NotFoundError'
+ '429':
+ $ref: '#/components/responses/RateLimitError'
+
+ /experiments/{id}/export:
+ get:
+ tags:
+ - experiments
+ summary: Export experiment data
+ description: Export all data from an experiment, including participant responses and stage results
+ operationId: exportExperiment
+ parameters:
+ - $ref: '#/components/parameters/ExperimentId'
+ - name: format
+ in: query
+ description: Export format (only 'json' is currently supported)
+ required: false
+ schema:
+ type: string
+ enum: [json]
+ default: json
+ responses:
+ '200':
+ description: Successful export
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ExperimentExport'
+ '401':
+ $ref: '#/components/responses/UnauthorizedError'
+ '403':
+ $ref: '#/components/responses/ForbiddenError'
+ '404':
+ $ref: '#/components/responses/NotFoundError'
+ '429':
+ $ref: '#/components/responses/RateLimitError'
+
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: API Key
+ description: |
+ API key authentication. Create an API key in the web app Settings page, then include it in the Authorization header:
+ ```
+ Authorization: Bearer dlb_live_YOUR_API_KEY
+ ```
+
+ parameters:
+ ExperimentId:
+ name: id
+ in: path
+ description: Experiment ID
+ required: true
+ schema:
+ type: string
+ example: exp123
+
+ schemas:
+ Metadata:
+ type: object
+ description: Experiment metadata
+ properties:
+ name:
+ type: string
+ description: Experiment name
+ example: Decision Making Study
+ publicName:
+ type: string
+ description: Public-facing experiment name
+ example: Decision Study
+ description:
+ type: string
+ description: Experiment description
+ example: Research on group decision making
+ tags:
+ type: array
+ description: Tags for categorization
+ items:
+ type: string
+ example: ["research", "decision-making"]
+ creator:
+ type: string
+ description: Experimenter ID who created the experiment
+ example: experimenter123
+ starred:
+ type: object
+ description: Maps experimenter IDs to starred status
+ additionalProperties:
+ type: boolean
+ dateCreated:
+ type: object
+ description: Firestore timestamp
+ additionalProperties: true
+ dateModified:
+ type: object
+ description: Firestore timestamp
+ additionalProperties: true
+
+ Experiment:
+ type: object
+ properties:
+ id:
+ type: string
+ description: Unique experiment identifier
+ example: exp123
+ versionId:
+ type: integer
+ description: Experiment version ID for backwards compatibility
+ example: 18
+ metadata:
+ $ref: '#/components/schemas/Metadata'
+ permissions:
+ type: object
+ description: Experiment permissions
+ properties:
+ visibility:
+ type: string
+ enum: [public, private]
+ description: Experiment visibility
+ example: private
+ readers:
+ type: array
+ description: List of experimenter IDs with read access
+ items:
+ type: string
+ example: ["experimenter123"]
+ stageIds:
+ type: array
+ description: Ordered list of stage IDs (stages are stored in a subcollection, not returned in this response)
+ items:
+ type: string
+ example: ["stage1", "stage2"]
+ defaultCohortConfig:
+ type: object
+ description: Default cohort configuration
+ additionalProperties: true
+ prolificConfig:
+ type: object
+ description: Prolific integration configuration
+ additionalProperties: true
+ cohortLockMap:
+ type: object
+ description: Maps cohort ID to lock status
+ additionalProperties:
+ type: boolean
+
+ Stage:
+ type: object
+ properties:
+ id:
+ type: string
+ description: Stage identifier
+ example: stage1
+ kind:
+ type: string
+ description: Stage type
+ example: survey
+ enum: [info, tos, profile, chat, chip, comprehension, flipcard, ranking, payout, privateChat, reveal, salesperson, stockinfo, assetAllocation, multiAssetAllocation, role, survey, surveyPerParticipant, transfer]
+ name:
+ type: string
+ description: Stage name
+ example: Pre-study Survey
+ descriptions:
+ type: object
+ description: Stage text configuration
+ properties:
+ primaryText:
+ type: string
+ description: Text shown at top of screen under header
+ infoText:
+ type: string
+ description: Text for info popup
+ helpText:
+ type: string
+ description: Text for help popup
+ progress:
+ type: object
+ description: Stage progress configuration
+ properties:
+ minParticipants:
+ type: integer
+ description: Minimum participants required for stage
+ waitForAllParticipants:
+ type: boolean
+ description: Wait for all participants to reach stage
+ showParticipantProgress:
+ type: boolean
+ description: Show participants who completed stage
+ description: Stage configuration (extends BaseStageConfig with kind-specific fields)
+
+ ParticipantProfile:
+ type: object
+ description: Participant profile (extended version includes privateId and agentConfig)
+ properties:
+ type:
+ type: string
+ enum: [participant]
+ example: participant
+ publicId:
+ type: string
+ description: Public participant identifier
+ example: participant1
+ privateId:
+ type: string
+ description: Private participant identifier (only in extended profile)
+ example: private123
+ prolificId:
+ type: string
+ nullable: true
+ description: Prolific participant ID (if applicable)
+ example: abc123
+ currentStageId:
+ type: string
+ description: Current stage the participant is on
+ example: stage3
+ currentCohortId:
+ type: string
+ description: Current cohort the participant is in
+ example: cohort1
+ transferCohortId:
+ type: string
+ nullable: true
+ description: Cohort ID if pending transfer
+ currentStatus:
+ type: string
+ enum: [PAUSED, ATTENTION_CHECK, IN_PROGRESS, SUCCESS, BOOTED_OUT]
+ description: Current participant status
+ example: IN_PROGRESS
+ pronouns:
+ type: string
+ nullable: true
+ description: Participant's pronouns
+ avatar:
+ type: string
+ nullable: true
+ description: Emoji used as avatar
+ name:
+ type: string
+ nullable: true
+ description: Participant's name
+ timestamps:
+ type: object
+ description: Progress timestamps
+ additionalProperties: true
+ anonymousProfiles:
+ type: object
+ description: Anonymous profile metadata by profile set ID
+ additionalProperties: true
+ connected:
+ type: boolean
+ nullable: true
+ description: Connection status
+ agentConfig:
+ type: object
+ nullable: true
+ description: Agent configuration if participant is AI-controlled
+ additionalProperties: true
+
+ ParticipantDownload:
+ type: object
+ description: Complete participant data including profile and all stage answers
+ properties:
+ profile:
+ $ref: '#/components/schemas/ParticipantProfile'
+ answerMap:
+ type: object
+ description: Maps stage ID to participant's stage answer
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ example:
+ stage1:
+ question1: "Answer text"
+ question2: "5"
+
+ CohortDownload:
+ type: object
+ description: Complete cohort data including config, stage data, and chat messages
+ properties:
+ cohort:
+ type: object
+ description: Cohort configuration
+ additionalProperties: true
+ dataMap:
+ type: object
+ description: Maps stage ID to stage public data
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ chatMap:
+ type: object
+ description: Maps stage ID to ordered list of chat messages
+ additionalProperties:
+ type: array
+ items:
+ type: object
+ additionalProperties: true
+
+ ExperimentExport:
+ type: object
+ description: Complete experiment data export
+ properties:
+ experiment:
+ $ref: '#/components/schemas/Experiment'
+ stageMap:
+ type: object
+ description: Maps stage ID to stage config
+ additionalProperties:
+ $ref: '#/components/schemas/Stage'
+ participantMap:
+ type: object
+ description: Maps participant public ID to participant download
+ additionalProperties:
+ $ref: '#/components/schemas/ParticipantDownload'
+ cohortMap:
+ type: object
+ description: Maps cohort ID to cohort download
+ additionalProperties:
+ $ref: '#/components/schemas/CohortDownload'
+ agentMediatorMap:
+ type: object
+ description: Maps agent mediator persona ID to agent template
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ agentParticipantMap:
+ type: object
+ description: Maps agent participant ID to agent template
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ alerts:
+ type: array
+ description: List of alerts sent during experiment
+ items:
+ type: object
+ additionalProperties: true
+
+ Error:
+ type: object
+ properties:
+ error:
+ type: string
+ description: Error message
+ example: Invalid Authorization header format
+
+ responses:
+ BadRequestError:
+ description: Bad request - Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: "Missing required field 'name'"
+
+ UnauthorizedError:
+ description: Unauthorized - Missing or invalid API key
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: "Invalid Authorization header format. Use: Authorization Bearer YOUR_API_KEY"
+
+ ForbiddenError:
+ description: Forbidden - Insufficient permissions or browser access attempted
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: "Browser access not allowed. Use API keys from server-side applications only."
+
+ NotFoundError:
+ description: Not found - Resource doesn't exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Experiment not found
+
+ RateLimitError:
+ description: Too many requests - Rate limit exceeded
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ example:
+ error: Rate limit exceeded. Please try again later.
diff --git a/docs/developers/api.html b/docs/developers/api.html
new file mode 100644
index 000000000..56d165c77
--- /dev/null
+++ b/docs/developers/api.html
@@ -0,0 +1,36 @@
+---
+layout: default
+---
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/api_key_manager.scss b/frontend/src/components/settings/api_key_manager.scss
new file mode 100644
index 000000000..54ae4af76
--- /dev/null
+++ b/frontend/src/components/settings/api_key_manager.scss
@@ -0,0 +1,111 @@
+@use '../../sass/common';
+@use '../../sass/typescale';
+
+:host {
+ @include common.flex-column;
+ gap: common.$spacing-medium;
+ width: 100%;
+}
+
+.section {
+ @include common.flex-column;
+ gap: common.$spacing-medium;
+}
+
+.divider {
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
+}
+
+.action-buttons {
+ @include common.flex-row;
+ flex-wrap: wrap;
+ gap: common.$spacing-medium;
+}
+
+.banner {
+ @include common.chip;
+ @include common.flex-column;
+ gap: common.$spacing-medium;
+ padding: common.$spacing-medium;
+
+ &.success {
+ background: var(--md-sys-color-tertiary-container);
+ color: var(--md-sys-color-on-tertiary-container);
+
+ strong,
+ p {
+ @include typescale.body-medium;
+ }
+ }
+
+ &.error {
+ @include typescale.label-small;
+ }
+}
+
+.banner-header {
+ @include common.flex-row-align-center;
+ gap: common.$spacing-small;
+ @include typescale.title-small;
+}
+
+.key-display {
+ @include common.flex-row;
+ align-items: center;
+ gap: common.$spacing-medium;
+ padding: common.$spacing-medium;
+ background-color: var(--md-sys-color-surface-variant);
+ border-radius: common.$spacing-small;
+ flex-wrap: wrap;
+
+ code {
+ @include typescale.label-large;
+ font-family: monospace;
+ flex: 1;
+ min-width: 200px;
+ word-break: break-all;
+ color: var(--md-sys-color-on-surface-variant);
+ }
+}
+
+.empty-message {
+ @include typescale.body-medium;
+ color: var(--md-sys-color-outline);
+ font-style: italic;
+ padding: common.$spacing-medium;
+}
+
+.list {
+ @include common.flex-column;
+}
+
+.key-item {
+ @include common.flex-row;
+ align-items: center;
+ justify-content: space-between;
+ gap: common.$spacing-medium;
+ padding: common.$spacing-medium;
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
+ flex-wrap: wrap;
+}
+
+.key-info {
+ @include common.flex-column;
+ gap: common.$spacing-small;
+ flex: 1;
+}
+
+.title {
+ @include typescale.title-medium;
+ color: var(--md-sys-color-on-surface);
+ font-weight: 600;
+}
+
+.subtitle {
+ @include typescale.body-small;
+ color: var(--md-sys-color-outline);
+
+ div {
+ margin: 0;
+ }
+}
diff --git a/frontend/src/components/settings/api_key_manager.ts b/frontend/src/components/settings/api_key_manager.ts
new file mode 100644
index 000000000..20b0e339c
--- /dev/null
+++ b/frontend/src/components/settings/api_key_manager.ts
@@ -0,0 +1,298 @@
+import '../../pair-components/button';
+import '../../pair-components/icon';
+
+import {MobxLitElement} from '@adobe/lit-mobx';
+import {CSSResultGroup, html, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+
+import '@material/web/textfield/filled-text-field.js';
+
+import {core} from '../../core/core';
+import {FirebaseService} from '../../services/firebase.service';
+
+import {
+ createAPIKeyCallable,
+ listAPIKeysCallable,
+ revokeAPIKeyCallable,
+} from '../../shared/callables';
+
+import {styles} from './api_key_manager.scss';
+
+interface APIKey {
+ keyId: string;
+ name: string;
+ createdAt: number;
+ lastUsed: number | null;
+ permissions: string[];
+}
+
+/** API Key Management component */
+@customElement('api-key-manager')
+export class APIKeyManager extends MobxLitElement {
+ static override styles: CSSResultGroup = [styles];
+
+ private readonly firebaseService = core.getService(FirebaseService);
+
+ @state() apiKeys: APIKey[] = [];
+ @state() isLoading = false;
+ @state() newKeyName = '';
+ @state() showCreateForm = false;
+ @state() newlyCreatedKey: string | null = null;
+ @state() error: string | null = null;
+ @state() copied = false;
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.loadAPIKeys();
+ }
+
+ private async loadAPIKeys() {
+ this.isLoading = true;
+ this.error = null;
+ try {
+ const result = await listAPIKeysCallable(this.firebaseService.functions);
+ this.apiKeys = result.keys;
+ } catch (e) {
+ console.error('Error loading API keys:', e);
+ this.error = 'Failed to load API keys';
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ private async handleCreateKey() {
+ if (!this.newKeyName.trim()) {
+ this.error = 'Please enter a name for the API key';
+ return;
+ }
+
+ this.isLoading = true;
+ this.error = null;
+ try {
+ const result = await createAPIKeyCallable(
+ this.firebaseService.functions,
+ this.newKeyName.trim(),
+ );
+ this.newlyCreatedKey = result.apiKey;
+ this.newKeyName = '';
+ this.showCreateForm = false;
+ await this.loadAPIKeys();
+ } catch (e) {
+ console.error('Error creating API key:', e);
+ this.error = 'Failed to create API key';
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ private async handleRevokeKey(keyId: string, keyName: string) {
+ if (
+ !confirm(
+ `Are you sure you want to revoke the API key "${keyName}"? This action cannot be undone.`,
+ )
+ ) {
+ return;
+ }
+
+ this.isLoading = true;
+ this.error = null;
+ try {
+ await revokeAPIKeyCallable(this.firebaseService.functions, keyId);
+ await this.loadAPIKeys();
+ } catch (e) {
+ console.error('Error revoking API key:', e);
+ this.error = 'Failed to revoke API key';
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ private handleCopyKey() {
+ if (this.newlyCreatedKey) {
+ navigator.clipboard.writeText(this.newlyCreatedKey);
+ this.copied = true;
+ setTimeout(() => {
+ this.copied = false;
+ }, 2000);
+ }
+ }
+
+ private handleDismissNewKey() {
+ this.newlyCreatedKey = null;
+ this.copied = false;
+ }
+
+ private formatDate(timestamp: number): string {
+ return new Date(timestamp).toLocaleString();
+ }
+
+ private formatPermissions(permissions: string[]): string {
+ return permissions.join(', ');
+ }
+
+ override render() {
+ return html`
+
+ ${this.error ? this.renderError() : nothing}
+ ${this.newlyCreatedKey ? this.renderNewKeyAlert() : nothing}
+ ${this.showCreateForm ? this.renderCreateForm() : nothing}
+ ${!this.showCreateForm ? this.renderCreateButton() : nothing}
+ ${this.renderKeyList()}
+
+ `;
+ }
+
+ private renderCreateButton() {
+ return html`
+ {
+ this.showCreateForm = true;
+ }}
+ >
+ + Create API Key
+
+ `;
+ }
+
+ private renderError() {
+ return html`
+
+ `;
+ }
+
+ private renderNewKeyAlert() {
+ return html`
+
+
+
+ Save this API key now. You won't be able to see it again!
+
+
+
${this.newlyCreatedKey}
+
+ ${this.copied ? 'Copied!' : 'Copy'}
+
+
+
+ I've saved the key
+
+
+ `;
+ }
+
+ private renderCreateForm() {
+ return html`
+
+
{
+ this.newKeyName = (e.target as HTMLInputElement).value;
+ }}
+ @keydown=${(e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ this.handleCreateKey();
+ }
+ }}
+ >
+
+
+
+ `;
+ }
+
+ private renderKeyList() {
+ if (this.isLoading && this.apiKeys.length === 0) {
+ return html`Loading API keys...
`;
+ }
+
+ if (this.apiKeys.length === 0) {
+ return html`
+
+ No API keys yet. Create one to get started.
+
+ `;
+ }
+
+ return html`
+
+ ${this.apiKeys.map((key) => this.renderKeyItem(key))}
+
+ `;
+ }
+
+ private renderKeyItem(key: APIKey) {
+ return html`
+
+
+
${key.name}
+
+
Key ID: ${key.keyId}
+
Created: ${this.formatDate(key.createdAt)}
+
+ Last Used:
+ ${key.lastUsed ? this.formatDate(key.lastUsed) : 'Never'}
+
+
+ Permissions: ${this.formatPermissions(key.permissions)}
+
+
+
+
this.handleRevokeKey(key.keyId, key.name)}
+ >
+ Revoke
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'api-key-manager': APIKeyManager;
+ }
+}
diff --git a/frontend/src/components/settings/settings.ts b/frontend/src/components/settings/settings.ts
index 2dd3c5bae..5b5260541 100644
--- a/frontend/src/components/settings/settings.ts
+++ b/frontend/src/components/settings/settings.ts
@@ -1,6 +1,7 @@
import '../../pair-components/button';
import '../experimenter/experimenter_data_editor';
+import './api_key_manager';
import {MobxLitElement} from '@adobe/lit-mobx';
import {CSSResultGroup, html, nothing} from 'lit';
@@ -31,6 +32,9 @@ export class Settings extends MobxLitElement {
${this.authService.isExperimenter
? this.renderExperimenterData()
: nothing}
+ ${this.authService.isExperimenter
+ ? this.renderAPIKeySection()
+ : nothing}
${this.authService.isExperimenter
? this.renderAppVersionSection()
: nothing}
@@ -142,6 +146,16 @@ export class Settings extends MobxLitElement {
`;
}
+ private renderAPIKeySection() {
+ return html`
+
+
API Access
+
Manage API keys for programmatic access to your experiments.
+
+
+ `;
+ }
+
private renderReferenceSection() {
return html`
diff --git a/frontend/src/services/experiment.manager.ts b/frontend/src/services/experiment.manager.ts
index c107cafb3..d3afdaa14 100644
--- a/frontend/src/services/experiment.manager.ts
+++ b/frontend/src/services/experiment.manager.ts
@@ -1,12 +1,10 @@
import {computed, makeObservable, observable} from 'mobx';
import {
collection,
- doc,
getDocs,
onSnapshot,
orderBy,
query,
- Timestamp,
Unsubscribe,
where,
} from 'firebase/firestore';
@@ -21,7 +19,6 @@ import {Service} from './service';
import JSZip from 'jszip';
import {
- DEFAULT_AGENT_MODEL_SETTINGS,
AlertMessage,
AlertStatus,
AgentPersonaConfig,
@@ -31,8 +28,6 @@ import {
CohortConfig,
CohortParticipantConfig,
CreateChatMessageData,
- Experiment,
- ExperimentDownload,
LogEntry,
MediatorProfileExtended,
MediatorStatus,
@@ -40,11 +35,11 @@ import {
ParticipantProfileExtended,
ParticipantStatus,
ProfileAgentConfig,
- StageConfig,
StageKind,
createCohortConfig,
createExperimenterChatMessage,
generateId,
+ getExperimentDownload,
} from '@deliberation-lab/utils';
import {
ackAlertMessageCallable,
@@ -70,14 +65,11 @@ import {
hasMaxParticipantsInCohort,
} from '../shared/cohort.utils';
import {
- downloadCSV,
- downloadJSON,
getAlertData,
getChatHistoryData,
getChipNegotiationCSV,
getChipNegotiationData,
getChipNegotiationPlayerMapCSV,
- getExperimentDownload,
getParticipantDataCSV,
} from '../shared/file.utils';
import {
diff --git a/frontend/src/shared/callables.ts b/frontend/src/shared/callables.ts
index f5b6c78a1..5c553fd7f 100644
--- a/frontend/src/shared/callables.ts
+++ b/frontend/src/shared/callables.ts
@@ -1,6 +1,7 @@
import {
AckAlertMessageData,
AgentConfigTestData,
+ APIKeyPermission,
BaseParticipantData,
CreateChatMessageData,
CohortCreationData,
@@ -632,3 +633,52 @@ export const ackAlertMessageCallable = async (
)(config);
return data;
};
+
+/** Generic endpoint to create an API key. */
+export const createAPIKeyCallable = async (
+ functions: Functions,
+ keyName: string,
+ permissions?: APIKeyPermission[],
+) => {
+ const {data} = await httpsCallable<
+ {keyName: string; permissions?: APIKeyPermission[]},
+ {success: boolean; apiKey: string; keyId: string; message: string}
+ >(
+ functions,
+ 'createAPIKey',
+ )({keyName, permissions});
+ return data;
+};
+
+/** Generic endpoint to list API keys. */
+export const listAPIKeysCallable = async (functions: Functions) => {
+ const {data} = await httpsCallable<
+ undefined,
+ {
+ success: boolean;
+ keys: Array<{
+ keyId: string;
+ name: string;
+ createdAt: number;
+ lastUsed: number | null;
+ permissions: APIKeyPermission[];
+ }>;
+ }
+ >(functions, 'listAPIKeys')();
+ return data;
+};
+
+/** Generic endpoint to revoke an API key. */
+export const revokeAPIKeyCallable = async (
+ functions: Functions,
+ keyId: string,
+) => {
+ const {data} = await httpsCallable<
+ {keyId: string},
+ {success: boolean; message: string}
+ >(
+ functions,
+ 'revokeAPIKey',
+ )({keyId});
+ return data;
+};
diff --git a/frontend/src/shared/file.utils.ts b/frontend/src/shared/file.utils.ts
index d2f4efbc8..eebde1087 100644
--- a/frontend/src/shared/file.utils.ts
+++ b/frontend/src/shared/file.utils.ts
@@ -2,23 +2,8 @@
* Functions for data downloads.
*/
-import {
- collection,
- doc,
- getDoc,
- getDocs,
- Firestore,
- orderBy,
- query,
-} from 'firebase/firestore';
import {stringify as csvStringify} from 'csv-stringify/sync';
import {
- AgentDataObject,
- AgentMediatorPersonaConfig,
- AgentMediatorTemplate,
- AgentParticipantPersonaConfig,
- AgentParticipantTemplate,
- AgentPersonaConfig,
AlertMessage,
ChatMessage,
ChipItem,
@@ -26,34 +11,21 @@ import {
ChipStagePublicData,
ChipTransaction,
ChipTransactionStatus,
- CohortConfig,
CohortDownload,
- Experiment,
ExperimentDownload,
- MediatorPromptConfig,
- ParticipantPromptConfig,
ParticipantDownload,
ParticipantProfileExtended,
PayoutItemType,
PayoutStageConfig,
PayoutStageParticipantAnswer,
RankingStageConfig,
- RankingStagePublicData,
- StageConfig,
StageKind,
- StageParticipantAnswer,
- StagePublicData,
- SurveyAnswer,
SurveyPerParticipantStageConfig,
- SurveyStageConfig,
- SurveyQuestion,
SurveyQuestionKind,
+ SurveyStageConfig,
UnifiedTimestamp,
calculatePayoutResult,
calculatePayoutTotal,
- createCohortDownload,
- createExperimentDownload,
- createParticipantDownload,
} from '@deliberation-lab/utils';
import {convertUnifiedTimestampToISO} from './utils';
@@ -108,191 +80,6 @@ export function toCSV(text: string | null) {
return text.replaceAll(',', '').replaceAll('\n', '');
}
-// ****************************************************************************
-// EXPERIMENT DOWNLOAD JSON
-// ****************************************************************************
-export async function getExperimentDownload(
- firestore: Firestore,
- experimentId: string,
-) {
- // Get experiment config from experimentId
- const experimentConfig = (
- await getDoc(doc(firestore, 'experiments', experimentId))
- ).data() as Experiment;
-
- // Create experiment download using experiment config
- const experimentDownload = createExperimentDownload(experimentConfig);
-
- // For each experiment stage config, add to ExperimentDownload
- const stageConfigs = (
- await getDocs(collection(firestore, 'experiments', experimentId, 'stages'))
- ).docs.map((doc) => doc.data() as StageConfig);
- for (const stage of stageConfigs) {
- experimentDownload.stageMap[stage.id] = stage;
- }
-
- // For each participant, add ParticipantDownload
- const profiles = (
- await getDocs(
- collection(firestore, 'experiments', experimentId, 'participants'),
- )
- ).docs.map((doc) => doc.data() as ParticipantProfileExtended);
- for (const profile of profiles) {
- // Create new ParticipantDownload
- const participantDownload = createParticipantDownload(profile);
-
- // For each stage answer, add to ParticipantDownload map
- const stageAnswers = (
- await getDocs(
- collection(
- firestore,
- 'experiments',
- experimentId,
- 'participants',
- profile.privateId,
- 'stageData',
- ),
- )
- ).docs.map((doc) => doc.data() as StageParticipantAnswer);
- for (const stage of stageAnswers) {
- participantDownload.answerMap[stage.id] = stage;
- }
- // Add ParticipantDownload to ExperimentDownload
- experimentDownload.participantMap[profile.publicId] = participantDownload;
- }
-
- // For each agent mediator, add template
- const agentMediatorCollection = collection(
- firestore,
- 'experiments',
- experimentId,
- 'agentMediators',
- );
- const mediatorAgents = (await getDocs(agentMediatorCollection)).docs.map(
- (agent) => agent.data() as AgentMediatorPersonaConfig,
- );
- for (const persona of mediatorAgents) {
- const mediatorPrompts = (
- await getDocs(
- collection(
- firestore,
- 'experiments',
- experimentId,
- 'agentMediators',
- persona.id,
- 'prompts',
- ),
- )
- ).docs.map((doc) => doc.data() as MediatorPromptConfig);
- const mediatorTemplate: AgentMediatorTemplate = {
- persona,
- promptMap: {},
- };
- mediatorPrompts.forEach((prompt) => {
- mediatorTemplate.promptMap[prompt.id] = prompt;
- });
- // Add to ExperimentDownload
- experimentDownload.agentMediatorMap[persona.id] = mediatorTemplate;
- }
-
- // For each agent participant, add template
- const agentParticipantCollection = collection(
- firestore,
- 'experiments',
- experimentId,
- 'agentParticipants',
- );
- const participantAgents = (
- await getDocs(agentParticipantCollection)
- ).docs.map((agent) => agent.data() as AgentParticipantPersonaConfig);
- for (const persona of participantAgents) {
- const participantPrompts = (
- await getDocs(
- collection(
- firestore,
- 'experiments',
- experimentId,
- 'agentParticipants',
- persona.id,
- 'prompts',
- ),
- )
- ).docs.map((doc) => doc.data() as ParticipantPromptConfig);
- const participantTemplate: AgentParticipantTemplate = {
- persona,
- promptMap: {},
- };
- participantPrompts.forEach((prompt) => {
- participantTemplate.promptMap[prompt.id] = prompt;
- });
- // Add to ExperimentDownload
- experimentDownload.agentParticipantMap[persona.id] = participantTemplate;
- }
-
- // For each cohort, add CohortDownload
- const cohorts = (
- await getDocs(collection(firestore, 'experiments', experimentId, 'cohorts'))
- ).docs.map((cohort) => cohort.data() as CohortConfig);
- for (const cohort of cohorts) {
- // Create new CohortDownload
- const cohortDownload = createCohortDownload(cohort);
-
- // For each public stage data, add to CohortDownload
- const publicStageData = (
- await getDocs(
- collection(
- firestore,
- 'experiments',
- experimentId,
- 'cohorts',
- cohort.id,
- 'publicStageData',
- ),
- )
- ).docs.map((doc) => doc.data() as StagePublicData);
- for (const data of publicStageData) {
- cohortDownload.dataMap[data.id] = data;
- // If chat stage, add list of chat messages to CohortDownload
- if (data.kind === StageKind.CHAT) {
- const chatList = (
- await getDocs(
- query(
- collection(
- firestore,
- 'experiments',
- experimentId,
- 'cohorts',
- cohort.id,
- 'publicStageData',
- data.id,
- 'chats',
- ),
- orderBy('timestamp', 'asc'),
- ),
- )
- ).docs.map((doc) => doc.data() as ChatMessage);
- cohortDownload.chatMap[data.id] = chatList;
- }
- }
-
- // Add CohortDownload to ExperimentDownload
- experimentDownload.cohortMap[cohort.id] = cohortDownload;
-
- // Add alerts to ExperimentDownload
- const alertList = (
- await getDocs(
- query(
- collection(firestore, 'experiments', experimentId, 'alerts'),
- orderBy('timestamp', 'asc'),
- ),
- )
- ).docs.map((doc) => doc.data() as AlertMessage);
- experimentDownload.alerts = alertList;
- }
-
- return experimentDownload;
-}
-
// ****************************************************************************
// JSON DATA TYPES
// ****************************************************************************
diff --git a/functions/package-lock.json b/functions/package-lock.json
index 0ff9092fa..5aa15cb87 100644
--- a/functions/package-lock.json
+++ b/functions/package-lock.json
@@ -9,6 +9,8 @@
"@deliberation-lab/utils": "file:../utils",
"@google/genai": "^1.11.0",
"@sinclair/typebox": "^0.32.30",
+ "express": "^5.1.0",
+ "express-rate-limit": "^8.2.0",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0",
"openai": "^4.77.0",
@@ -19,7 +21,9 @@
"@babel/preset-env": "^7.26.0",
"@babel/preset-typescript": "^7.26.0",
"@firebase/rules-unit-testing": "^5.0.0",
+ "@types/express": "^5.0.5",
"@types/jest": "^29.5.14",
+ "@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
@@ -34,6 +38,7 @@
"firebase": "^12.1.0",
"firebase-functions-test": "^3.1.0",
"nock": "^14.0.1",
+ "supertest": "^7.1.4",
"ts-jest": "^29.2.5",
"typescript": "^4.9.0"
},
@@ -4491,6 +4496,19 @@
"node": ">=18"
}
},
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
@@ -4548,6 +4566,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@paralleldrive/cuid2": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
+ "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^1.1.5"
+ }
+ },
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
@@ -4698,6 +4726,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/cookiejar": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
+ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/cors": {
"version": "2.8.17",
"license": "MIT",
@@ -4706,12 +4741,15 @@
}
},
"node_modules/@types/express": {
- "version": "4.17.3",
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
+ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
- "@types/express-serve-static-core": "*",
- "@types/serve-static": "*"
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^1"
}
},
"node_modules/@types/express-serve-static-core": {
@@ -4724,6 +4762,19 @@
"@types/send": "*"
}
},
+ "node_modules/@types/express/node_modules/@types/express-serve-static-core": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
+ "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -4798,6 +4849,13 @@
"license": "MIT",
"optional": true
},
+ "node_modules/@types/methods": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
+ "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/mime": {
"version": "1.3.5",
"license": "MIT"
@@ -4882,6 +4940,47 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"dev": true
},
+ "node_modules/@types/superagent": {
+ "version": "8.1.9",
+ "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
+ "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/cookiejar": "^2.1.5",
+ "@types/methods": "^1.1.4",
+ "@types/node": "*",
+ "form-data": "^4.0.0"
+ }
+ },
+ "node_modules/@types/superagent/node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "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/@types/supertest": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
+ "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/methods": "^1.1.4",
+ "@types/superagent": "^8.1.0"
+ }
+ },
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"license": "MIT",
@@ -5103,11 +5202,34 @@
}
},
"node_modules/accepts": {
- "version": "1.3.8",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
- "mime-types": "~2.1.34",
- "negotiator": "0.6.3"
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@@ -5255,6 +5377,8 @@
},
"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-includes": {
@@ -5366,6 +5490,13 @@
"node": ">=8"
}
},
+ "node_modules/asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -5605,41 +5736,25 @@
}
},
"node_modules/body-parser": {
- "version": "1.20.3",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
- "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "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.0",
- "iconv-lite": "0.4.24",
- "on-finished": "2.4.1",
- "qs": "6.13.0",
- "raw-body": "2.5.2",
- "type-is": "~1.6.18",
- "unpipe": "1.0.0"
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
},
"engines": {
- "node": ">= 0.8",
- "npm": "1.2.8000 || >= 1.4.16"
- }
- },
- "node_modules/body-parser/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
+ "node": ">=18"
}
},
- "node_modules/body-parser/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
"node_modules/brace-expansion": {
"version": "1.1.11",
"dev": true,
@@ -5751,12 +5866,14 @@
"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": {
"version": "1.0.7",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -5785,6 +5902,22 @@
"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",
"dev": true,
@@ -5930,13 +6063,25 @@
"node": ">= 0.8"
}
},
+ "node_modules/component-emitter": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
+ "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
- "version": "0.5.4",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+ "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -5949,6 +6094,7 @@
"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"
}
@@ -5968,7 +6114,19 @@
}
},
"node_modules/cookie-signature": {
- "version": "1.0.6",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cookiejar": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
+ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
+ "dev": true,
"license": "MIT"
},
"node_modules/core-js-compat": {
@@ -6079,10 +6237,12 @@
}
},
"node_modules/debug": {
- "version": "4.3.4",
+ "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.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -6145,6 +6305,7 @@
},
"node_modules/define-data-property": {
"version": "1.1.4",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -6185,6 +6346,7 @@
"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"
}
@@ -6193,6 +6355,7 @@
"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"
@@ -6215,6 +6378,17 @@
"node": ">=8"
}
},
+ "node_modules/dezalgo": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
+ "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "asap": "^2.0.0",
+ "wrappy": "1"
+ }
+ },
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@@ -6281,7 +6455,8 @@
"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=="
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
@@ -6326,6 +6501,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -6531,7 +6707,8 @@
"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=="
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
@@ -6892,6 +7069,7 @@
"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"
}
@@ -6961,60 +7139,85 @@
}
},
"node_modules/express": {
- "version": "4.21.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
- "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
- "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.13.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"
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
- "node_modules/express/node_modules/debug": {
- "version": "2.6.9",
+ "node_modules/express-rate-limit": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.0.tgz",
+ "integrity": "sha512-zDLb8RsXoA09dui1mvm/bAqSYeUh/bj3+fcDeiNBebSbSjl9IEK5mbCSYSRk52Lrco9sj9Xjuzkot3TXuXEw0A==",
"license": "MIT",
"dependencies": {
- "ms": "2.0.0"
+ "ip-address": "10.0.1"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
}
},
- "node_modules/express/node_modules/ms": {
- "version": "2.0.0",
- "license": "MIT"
+ "node_modules/express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
},
"node_modules/extend": {
"version": "3.0.2",
@@ -7079,6 +7282,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-safe-stringify": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-xml-parser": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
@@ -7182,35 +7392,22 @@
}
},
"node_modules/finalhandler": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
- "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+ "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+ "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.1",
- "unpipe": "~1.0.0"
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
- "node_modules/finalhandler/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/finalhandler/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
"node_modules/find-up": {
"version": "5.0.0",
"dev": true,
@@ -7325,14 +7522,325 @@
"jest": ">=28.0.0"
}
},
- "node_modules/firebase/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==",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/firebase/node_modules/@firebase/app-types": {
+ "node_modules/firebase-functions/node_modules/@types/express": {
+ "version": "4.17.3",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.3.tgz",
+ "integrity": "sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "*",
+ "@types/serve-static": "*"
+ }
+ },
+ "node_modules/firebase-functions/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/firebase-functions/node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "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.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/firebase-functions/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/firebase-functions/node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/firebase-functions/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/firebase-functions/node_modules/debug/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/firebase-functions/node_modules/express": {
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "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.13.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/firebase-functions/node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "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.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/firebase-functions/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/firebase-functions/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",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/firebase-functions/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": ">= 0.6"
+ }
+ },
+ "node_modules/firebase-functions/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/sindresorhus"
+ }
+ },
+ "node_modules/firebase-functions/node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/firebase-functions/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"
+ }
+ },
+ "node_modules/firebase-functions/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/firebase-functions/node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/firebase-functions/node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/firebase-functions/node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/firebase-functions/node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/firebase-functions/node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/firebase-functions/node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/firebase-functions/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/firebase/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==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/firebase/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==",
@@ -7498,6 +8006,24 @@
"node": ">= 12.20"
}
},
+ "node_modules/formidable": {
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
+ "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@paralleldrive/cuid2": "^2.2.2",
+ "dezalgo": "^1.0.4",
+ "once": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/tunnckoCore/commissions"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
@@ -7506,11 +8032,12 @@
}
},
"node_modules/fresh": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
"engines": {
- "node": ">= 0.6"
+ "node": ">= 0.8"
}
},
"node_modules/fs-constants": {
@@ -7848,6 +8375,7 @@
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -7913,6 +8441,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
@@ -7924,6 +8453,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/http-errors/node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/http-parser-js": {
"version": "0.5.8",
"license": "MIT"
@@ -7983,11 +8521,12 @@
}
},
"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==",
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
"dependencies": {
- "safer-buffer": ">= 2.1.2 < 3"
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
@@ -8099,6 +8638,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ip-address": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
@@ -8290,6 +8838,12 @@
"node": ">=8"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.1.4",
"dev": true,
@@ -9405,17 +9959,22 @@
}
},
"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==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
"engines": {
- "node": ">= 0.6"
+ "node": ">= 0.8"
}
},
"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==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
@@ -9436,6 +9995,8 @@
},
"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": ">= 0.6"
@@ -9524,7 +10085,9 @@
"license": "MIT"
},
"node_modules/ms": {
- "version": "2.1.2",
+ "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/napi-build-utils": {
@@ -9542,7 +10105,9 @@
"license": "MIT"
},
"node_modules/negotiator": {
- "version": "0.6.3",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -9671,8 +10236,13 @@
}
},
"node_modules/object-inspect": {
- "version": "1.13.1",
+ "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"
}
@@ -9752,6 +10322,7 @@
"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": {
"ee-first": "1.1.1"
},
@@ -9911,6 +10482,7 @@
"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"
}
@@ -9945,9 +10517,14 @@
"license": "MIT"
},
"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=="
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
},
"node_modules/path-type": {
"version": "4.0.0",
@@ -10252,11 +10829,12 @@
"peer": true
},
"node_modules/qs": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "license": "BSD-3-Clause",
"dependencies": {
- "side-channel": "^1.0.6"
+ "side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -10288,22 +10866,40 @@
"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": ">= 0.6"
}
},
"node_modules/raw-body": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
- "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
+ "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
+ "license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
+ "iconv-lite": "0.7.0",
"unpipe": "1.0.0"
},
"engines": {
- "node": ">= 0.8"
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+ "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/rc": {
@@ -10538,6 +11134,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"dev": true,
@@ -10614,7 +11226,8 @@
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
},
"node_modules/semver": {
"version": "7.6.3",
@@ -10628,81 +11241,66 @@
}
},
"node_modules/send": {
- "version": "0.19.0",
- "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
- "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+ "license": "MIT",
"dependencies": {
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "mime": "1.6.0",
- "ms": "2.1.3",
- "on-finished": "2.4.1",
- "range-parser": "~1.2.1",
- "statuses": "2.0.1"
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
},
"engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/send/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
+ "node": ">= 18"
}
},
- "node_modules/send/node_modules/debug/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "node_modules/send/node_modules/encodeurl": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
- "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "node_modules/send/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
"engines": {
- "node": ">= 0.8"
+ "node": ">= 0.6"
}
},
- "node_modules/send/node_modules/mime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
- "bin": {
- "mime": "cli.js"
+ "node_modules/send/node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
},
"engines": {
- "node": ">=4"
+ "node": ">= 0.6"
}
},
- "node_modules/send/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/serve-static": {
- "version": "1.16.2",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
- "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+ "license": "MIT",
"dependencies": {
- "encodeurl": "~2.0.0",
- "escape-html": "~1.0.3",
- "parseurl": "~1.3.3",
- "send": "0.19.0"
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
},
"engines": {
- "node": ">= 0.8.0"
+ "node": ">= 18"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
+ "dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -10733,7 +11331,8 @@
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -10755,13 +11354,69 @@
}
},
"node_modules/side-channel": {
- "version": "1.0.6",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
- "call-bind": "^1.0.7",
"es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.4",
- "object-inspect": "^1.13.1"
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
@@ -10881,9 +11536,10 @@
}
},
"node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -11040,6 +11696,71 @@
"license": "MIT",
"optional": true
},
+ "node_modules/superagent": {
+ "version": "10.2.3",
+ "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
+ "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "component-emitter": "^1.3.1",
+ "cookiejar": "^2.1.4",
+ "debug": "^4.3.7",
+ "fast-safe-stringify": "^2.1.1",
+ "form-data": "^4.0.4",
+ "formidable": "^3.5.4",
+ "methods": "^1.1.2",
+ "mime": "2.6.0",
+ "qs": "^6.11.2"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
+ "node_modules/superagent/node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "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/superagent/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/supertest": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz",
+ "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "methods": "^1.1.2",
+ "superagent": "^10.2.3"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"dev": true,
@@ -11183,6 +11904,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
"engines": {
"node": ">=0.6"
}
@@ -11340,12 +12062,35 @@
}
},
"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==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
"dependencies": {
- "media-typer": "0.3.0",
- "mime-types": "~2.1.24"
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@@ -11501,6 +12246,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -11549,6 +12295,8 @@
},
"node_modules/utils-merge": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
diff --git a/functions/package.json b/functions/package.json
index 289a15308..835621ed2 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -18,13 +18,15 @@
"node": "18"
},
"config": {
- "firestore_tests": "src/log.utils.test.ts"
+ "firestore_tests": "src/log.utils.test.ts src/dl_api/experiments.api.integration.test.ts"
},
"main": "lib/index.js",
"dependencies": {
"@deliberation-lab/utils": "file:../utils",
"@google/genai": "^1.11.0",
"@sinclair/typebox": "^0.32.30",
+ "express": "^5.1.0",
+ "express-rate-limit": "^8.2.0",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0",
"openai": "^4.77.0",
@@ -37,13 +39,18 @@
"transform": {
"^.+\\.[t|j]sx?$": "babel-jest"
},
- "testTimeout": 15000
+ "testTimeout": 15000,
+ "moduleNameMapper": {
+ "^@deliberation-lab/utils$": "/../utils/src"
+ }
},
"devDependencies": {
"@babel/preset-env": "^7.26.0",
"@babel/preset-typescript": "^7.26.0",
"@firebase/rules-unit-testing": "^5.0.0",
+ "@types/express": "^5.0.5",
"@types/jest": "^29.5.14",
+ "@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
@@ -58,6 +65,7 @@
"firebase": "^12.1.0",
"firebase-functions-test": "^3.1.0",
"nock": "^14.0.1",
+ "supertest": "^7.1.4",
"ts-jest": "^29.2.5",
"typescript": "^4.9.0"
},
diff --git a/functions/src/dl_api/api.endpoints.ts b/functions/src/dl_api/api.endpoints.ts
new file mode 100644
index 000000000..d6d6a3041
--- /dev/null
+++ b/functions/src/dl_api/api.endpoints.ts
@@ -0,0 +1,228 @@
+/**
+ * Main REST API endpoint for Deliberate Lab (Express version)
+ * Server-to-server API with API key authentication
+ */
+
+import {onRequest, onCall} from 'firebase-functions/v2/https';
+import * as functions from 'firebase-functions';
+import express from 'express';
+import rateLimit, {ipKeyGenerator} from 'express-rate-limit';
+import {authenticateAPIKey, rejectBrowserRequests} from './api.utils';
+import {AuthGuard} from '../utils/auth-guard';
+import {APIKeyPermission} from '@deliberation-lab/utils';
+import * as apiKeyService from './api_key.utils';
+import {
+ listExperiments,
+ createExperiment,
+ getExperiment,
+ updateExperiment,
+ deleteExperiment,
+ exportExperimentData,
+} from './experiments.api';
+
+// Create Express app
+const app = express();
+
+// Middleware
+app.use(express.json()); // Parse JSON bodies
+
+// Rate limiting
+const limiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 100, // limit each IP/API key to 100 requests per windowMs
+ message: 'Too many requests from this API key, please try again later.',
+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
+ legacyHeaders: false, // Disable `X-RateLimit-*` headers
+ // Use API key as identifier for rate limiting
+ keyGenerator: (req) => {
+ // Use shared utility to extract API key
+ const apiKey = apiKeyService.extractBearerToken(req.headers.authorization);
+ // Use API key if found, otherwise fall back to properly normalized IP
+ return apiKey || ipKeyGenerator(req.ip || 'unknown');
+ },
+});
+
+// Apply middleware in order:
+// 1. Rate limiting
+app.use(limiter);
+
+// 2. Reject browser requests (server-to-server only)
+app.use(rejectBrowserRequests);
+
+// 3. Authenticate API key
+app.use(authenticateAPIKey);
+
+// API Routes
+app.get('/v1/experiments', listExperiments);
+app.post('/v1/experiments', createExperiment);
+app.get('/v1/experiments/:id', getExperiment);
+app.put('/v1/experiments/:id', updateExperiment);
+app.delete('/v1/experiments/:id', deleteExperiment);
+app.get('/v1/experiments/:id/export', exportExperimentData);
+
+// Health check endpoint (doesn't require auth)
+app.get('/v1/health', (_req, res) => {
+ res.status(200).json({
+ status: 'healthy',
+ version: '1.0.0',
+ timestamp: new Date().toISOString(),
+ });
+});
+
+// 404 handler
+app.use((req, res) => {
+ res.status(404).json({
+ error: `Cannot ${req.method} ${req.path}`,
+ });
+});
+
+// Error handler
+app.use(
+ (
+ err: Error & {status?: number},
+ _req: express.Request,
+ res: express.Response,
+ _next: express.NextFunction,
+ ) => {
+ console.error('API Error:', err);
+ res.status(err.status || 500).json({
+ error: err.message || 'Internal server error',
+ });
+ },
+);
+
+/**
+ * Main REST API endpoint
+ * Deploy as a Firebase Function
+ */
+export const api = onRequest(
+ {
+ timeoutSeconds: 60,
+ maxInstances: 100,
+ },
+ app,
+);
+
+/**
+ * Create API key endpoint (uses Firebase Auth)
+ * This is a callable function that requires Firebase authentication
+ */
+export const createAPIKey = onCall(
+ {
+ timeoutSeconds: 30,
+ },
+ async (request) => {
+ // Check if user is authenticated
+ await AuthGuard.isExperimenter(request);
+
+ const {keyName, permissions} = request.data;
+
+ if (!keyName || typeof keyName !== 'string') {
+ throw new functions.https.HttpsError(
+ 'invalid-argument',
+ 'Key name is required',
+ );
+ }
+
+ const experimenterId = request.auth!.uid;
+
+ try {
+ // Create the API key with optional permissions
+ const {apiKey, keyId} = await apiKeyService.createAPIKey(
+ experimenterId,
+ keyName,
+ permissions || [APIKeyPermission.READ, APIKeyPermission.WRITE],
+ );
+
+ return {
+ success: true,
+ apiKey,
+ keyId,
+ message: 'Save this API key securely. It will not be shown again.',
+ };
+ } catch (error) {
+ console.error('Error generating API key:', error);
+ throw new functions.https.HttpsError(
+ 'internal',
+ 'Failed to generate API key',
+ );
+ }
+ },
+);
+
+/**
+ * List API keys for the authenticated user
+ * Returns metadata only (no actual keys)
+ */
+export const listAPIKeys = onCall(
+ {
+ timeoutSeconds: 30,
+ },
+ async (request) => {
+ // Check if user is authenticated
+ await AuthGuard.isExperimenter(request);
+
+ const experimenterId = request.auth!.uid;
+
+ try {
+ const keys = await apiKeyService.listAPIKeys(experimenterId);
+
+ return {
+ success: true,
+ keys,
+ };
+ } catch (error) {
+ console.error('Error listing API keys:', error);
+ throw new functions.https.HttpsError(
+ 'internal',
+ 'Failed to list API keys',
+ );
+ }
+ },
+);
+
+/**
+ * Revoke an API key
+ */
+export const revokeAPIKey = onCall(
+ {
+ timeoutSeconds: 30,
+ },
+ async (request) => {
+ // Check if user is authenticated
+ await AuthGuard.isExperimenter(request);
+
+ const {keyId} = request.data;
+
+ if (!keyId || typeof keyId !== 'string') {
+ throw new functions.https.HttpsError(
+ 'invalid-argument',
+ 'Key ID is required',
+ );
+ }
+
+ const experimenterId = request.auth!.uid;
+
+ try {
+ const success = await apiKeyService.revokeAPIKey(keyId, experimenterId);
+
+ if (!success) {
+ throw new functions.https.HttpsError(
+ 'not-found',
+ 'API key not found or you do not have permission to revoke it',
+ );
+ }
+
+ return {
+ success: true,
+ message: 'API key revoked successfully',
+ };
+ } catch (error) {
+ console.error('Error revoking API key:', error);
+ throw new functions.https.HttpsError(
+ 'internal',
+ 'Failed to revoke API key',
+ );
+ }
+ },
+);
diff --git a/functions/src/dl_api/api.utils.ts b/functions/src/dl_api/api.utils.ts
new file mode 100644
index 000000000..cfa50593b
--- /dev/null
+++ b/functions/src/dl_api/api.utils.ts
@@ -0,0 +1,166 @@
+/**
+ * Shared utilities for REST API endpoints
+ * Includes authentication middleware and validation helpers
+ */
+
+import {Request, Response, NextFunction} from 'express';
+import {verifyAPIKey, extractBearerToken} from './api_key.utils';
+
+// ************************************************************************* //
+// TYPES //
+// ************************************************************************* //
+
+export interface AuthenticatedRequest extends Request {
+ apiKeyData?: {
+ experimenterId: string;
+ permissions: string[];
+ name: string;
+ };
+}
+
+// ************************************************************************* //
+// AUTHENTICATION MIDDLEWARE //
+// ************************************************************************* //
+
+/**
+ * Middleware to reject browser requests (server-to-server only)
+ */
+export function rejectBrowserRequests(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): void {
+ const origin = req.headers.origin || req.headers.referer;
+ if (origin) {
+ res.status(403).json({
+ error:
+ 'Browser access not allowed. Use API keys from server-side applications only.',
+ });
+ return;
+ }
+ next();
+}
+
+/**
+ * Async middleware wrapper for cleaner error handling
+ */
+function asyncHandler(
+ fn: (req: Request, res: Response, next: NextFunction) => Promise,
+) {
+ return (req: Request, res: Response, next: NextFunction) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+ };
+}
+
+/**
+ * Express middleware to authenticate API key
+ * Simplified async version using Express patterns
+ */
+export const authenticateAPIKey = asyncHandler(
+ async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
+ // Extract and validate Bearer token
+ const apiKey = extractBearerToken(req.headers.authorization);
+
+ if (!apiKey) {
+ const error = req.headers.authorization
+ ? 'Invalid Authorization header format. Use: Authorization: Bearer YOUR_API_KEY'
+ : 'Missing Authorization header. Use: Authorization: Bearer YOUR_API_KEY';
+
+ return res.status(401).json({error});
+ }
+
+ try {
+ // Verify API key
+ const {valid, data} = await verifyAPIKey(apiKey);
+
+ if (!valid || !data) {
+ return res.status(401).json({error: 'Invalid or expired API key'});
+ }
+
+ // Attach API key data to request
+ req.apiKeyData = {
+ experimenterId: data.experimenterId,
+ permissions: data.permissions,
+ name: data.name,
+ };
+
+ next();
+ } catch (error) {
+ console.error('Error verifying API key:', error);
+ return res
+ .status(500)
+ .json({error: 'Internal server error during authentication'});
+ }
+ },
+);
+
+/**
+ * Check if the API key has a specific permission
+ */
+export function hasPermission(
+ req: AuthenticatedRequest,
+ permission: string,
+): boolean {
+ return req.apiKeyData?.permissions?.includes(permission) ?? false;
+}
+
+/**
+ * Express middleware to check for a specific permission
+ */
+export function requirePermission(permission: string) {
+ return (
+ req: AuthenticatedRequest,
+ res: Response,
+ next: NextFunction,
+ ): void => {
+ if (!hasPermission(req, permission)) {
+ res.status(403).json({
+ error: `Insufficient permissions. Required: ${permission}`,
+ });
+ return;
+ }
+ next();
+ };
+}
+
+// ************************************************************************* //
+// VALIDATION HELPERS //
+// ************************************************************************* //
+
+/**
+ * Generic validation helper that validates data and sends error response if invalid
+ *
+ * This helper simplifies validation in Express endpoints by handling both validation
+ * and error response in a single call.
+ *
+ * @param data - Data to validate (can be undefined)
+ * @param validator - Validation function that returns error message string or null if valid
+ * @param res - Express response object
+ * @returns true if data is valid or undefined, false if validation failed (response already sent)
+ *
+ * @example
+ * ```typescript
+ * // In an Express endpoint
+ * if (!validateOrRespond(body.stages, validateStages, res)) return;
+ *
+ * // With custom validator
+ * const validateEmail = (email: string) => {
+ * return email.includes('@') ? null : 'Invalid email format';
+ * };
+ * if (!validateOrRespond(body.email, validateEmail, res)) return;
+ * ```
+ */
+export function validateOrRespond(
+ data: T | undefined,
+ validator: (data: T) => string | null,
+ res: Response,
+): boolean {
+ if (!data) return true;
+
+ const validationError = validator(data);
+ if (validationError) {
+ res.status(400).json({error: validationError});
+ return false;
+ }
+ return true;
+}
diff --git a/functions/src/dl_api/api_key.utils.ts b/functions/src/dl_api/api_key.utils.ts
new file mode 100644
index 000000000..b89b6d4b5
--- /dev/null
+++ b/functions/src/dl_api/api_key.utils.ts
@@ -0,0 +1,184 @@
+/**
+ * API Key Management Module
+ * Handles generation, hashing, storage, and verification of API keys
+ */
+
+import * as admin from 'firebase-admin';
+import {randomBytes, scrypt, createHash} from 'crypto';
+import {promisify} from 'util';
+import {APIKeyPermission, APIKeyData} from '@deliberation-lab/utils';
+
+const scryptAsync = promisify(scrypt);
+
+/**
+ * Extract API key from Bearer token in Authorization header
+ * @param authHeader - The Authorization header value
+ * @returns The extracted API key or null if not found/invalid
+ */
+export function extractBearerToken(
+ authHeader: string | undefined,
+): string | null {
+ const match = authHeader?.match(/^Bearer\s+(.+)$/i);
+ return match?.[1]?.trim() || null;
+}
+
+/**
+ * Generate a cryptographically secure API key
+ */
+export function generateAPIKey(prefix = 'dlb_live_'): string {
+ // Generate 32 random bytes and encode as base64url
+ const key = randomBytes(32).toString('base64url');
+ return `${prefix}${key}`;
+}
+
+/**
+ * Hash an API key with salt for secure storage
+ */
+export async function hashAPIKey(
+ apiKey: string,
+): Promise<{hash: string; salt: string}> {
+ const salt = randomBytes(16).toString('hex');
+ const hashBuffer = (await scryptAsync(apiKey, salt, 64)) as Buffer;
+ return {
+ hash: hashBuffer.toString('hex'),
+ salt,
+ };
+}
+
+/**
+ * Get a key ID from an API key (first 8 chars of SHA-256 hash)
+ */
+export function getKeyId(apiKey: string): string {
+ const hash = createHash('sha256').update(apiKey).digest('hex');
+ return hash.substring(0, 8);
+}
+
+/**
+ * Create and store a new API key
+ */
+export async function createAPIKey(
+ experimenterId: string,
+ keyName: string,
+ permissions: APIKeyPermission[] = [
+ APIKeyPermission.READ,
+ APIKeyPermission.WRITE,
+ ],
+): Promise<{apiKey: string; keyId: string}> {
+ const app = admin.app();
+ const firestore = app.firestore();
+
+ // Generate the API key
+ const apiKey = generateAPIKey();
+ const keyId = getKeyId(apiKey);
+
+ // Hash it for storage
+ const {hash, salt} = await hashAPIKey(apiKey);
+
+ // Store in Firestore
+ await firestore.collection('apiKeys').doc(keyId).set({
+ hash,
+ salt,
+ experimenterId,
+ name: keyName,
+ permissions,
+ createdAt: Date.now(),
+ lastUsed: null,
+ });
+
+ return {apiKey, keyId};
+}
+
+/**
+ * Verify an API key
+ */
+export async function verifyAPIKey(
+ apiKey: string,
+): Promise<{valid: boolean; data?: APIKeyData}> {
+ const app = admin.app();
+ const firestore = app.firestore();
+
+ // Get key ID to look up the document
+ const keyId = getKeyId(apiKey);
+
+ // Look up in Firestore
+ const doc = await firestore.collection('apiKeys').doc(keyId).get();
+ if (!doc.exists) {
+ return {valid: false};
+ }
+
+ const data = doc.data() as APIKeyData;
+
+ // Check expiration
+ if (data.expiresAt && data.expiresAt < Date.now()) {
+ return {valid: false};
+ }
+
+ // Verify the hash
+ const hashBuffer = (await scryptAsync(apiKey, data.salt, 64)) as Buffer;
+ const isValid = hashBuffer.toString('hex') === data.hash;
+
+ if (isValid) {
+ // Update last used timestamp
+ await firestore.collection('apiKeys').doc(keyId).update({
+ lastUsed: Date.now(),
+ });
+ }
+
+ return {valid: isValid, data: isValid ? data : undefined};
+}
+
+/**
+ * Revoke an API key
+ */
+export async function revokeAPIKey(
+ keyId: string,
+ experimenterId: string,
+): Promise {
+ const app = admin.app();
+ const firestore = app.firestore();
+
+ const doc = await firestore.collection('apiKeys').doc(keyId).get();
+ if (!doc.exists) {
+ return false;
+ }
+
+ const data = doc.data() as APIKeyData;
+
+ // Check ownership
+ if (data.experimenterId !== experimenterId) {
+ return false;
+ }
+
+ // Delete the key
+ await firestore.collection('apiKeys').doc(keyId).delete();
+ return true;
+}
+
+/**
+ * List API keys for an experimenter (returns metadata only, no actual keys)
+ */
+export async function listAPIKeys(experimenterId: string): Promise<
+ Array<{
+ keyId: string;
+ name: string;
+ createdAt: number;
+ lastUsed: number | null;
+ permissions: APIKeyPermission[];
+ }>
+> {
+ const app = admin.app();
+ const firestore = app.firestore();
+
+ const snapshot = await firestore
+ .collection('apiKeys')
+ .where('experimenterId', '==', experimenterId)
+ .get();
+
+ return snapshot.docs.map((doc) => ({
+ keyId: doc.id,
+ name: doc.data().name,
+ createdAt: doc.data().createdAt,
+ lastUsed: doc.data().lastUsed,
+ permissions: doc.data().permissions,
+ }));
+}
diff --git a/functions/src/dl_api/experiments.api.integration.test.ts b/functions/src/dl_api/experiments.api.integration.test.ts
new file mode 100644
index 000000000..4dc8fdeac
--- /dev/null
+++ b/functions/src/dl_api/experiments.api.integration.test.ts
@@ -0,0 +1,492 @@
+/**
+ * Integration tests for API experiment creation vs traditional template-based creation
+ *
+ * These tests verify that experiments created via the REST API are equivalent
+ * to experiments created using the traditional template system.
+ *
+ * This test assumes that a Firestore emulator is running.
+ * Run using: npm run test:firestore experiments.api.integration.test.ts
+ * Or: firebase emulators:exec --only firestore "npx jest experiments.api.integration.test.ts"
+ */
+
+// Don't override GCLOUD_PROJECT - let firebase emulators:exec set it
+// If not set, use a demo project ID that the emulator will accept
+if (!process.env.GCLOUD_PROJECT) {
+ process.env.GCLOUD_PROJECT = 'demo-deliberate-lab';
+}
+
+import {
+ initializeTestEnvironment,
+ RulesTestEnvironment,
+} from '@firebase/rules-unit-testing';
+import * as admin from 'firebase-admin';
+import request from 'supertest';
+import express from 'express';
+import {
+ createExperimentConfig,
+ StageConfig,
+ ExperimentTemplate,
+ Experiment,
+ APIKeyPermission,
+} from '@deliberation-lab/utils';
+import {createAPIKey, verifyAPIKey} from './api_key.utils';
+
+// Import actual experiment templates from frontend
+import {getFlipCardExperimentTemplate} from '../../../frontend/src/shared/templates/flipcard';
+import {getQuickstartGroupChatTemplate} from '../../../frontend/src/shared/templates/quickstart_group_chat';
+import {getPolicyExperimentTemplate} from '../../../frontend/src/shared/templates/policy';
+
+// Import Express app (we'll need to extract it from the API module)
+import {authenticateAPIKey} from './api.utils';
+import {
+ listExperiments,
+ createExperiment,
+ getExperiment,
+ updateExperiment,
+ deleteExperiment,
+ exportExperimentData,
+} from './experiments.api';
+
+let testEnv: RulesTestEnvironment;
+// Use any for firestore as the RulesTestEnvironment type is complex
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let firestore: any;
+let testAPIKey: string;
+let testApp: express.Application;
+
+// Test configuration
+const TEST_EXPERIMENTER_ID = 'test-experimenter@example.com';
+const EXPERIMENTS_COLLECTION = 'experiments';
+
+// Create Express app for testing (mimics api.endpoints.ts)
+function createTestAPIApp(): express.Application {
+ const app = express();
+ app.use(express.json());
+
+ // Skip rate limiting in tests
+ // Skip browser rejection in tests for supertest compatibility
+
+ // Add authentication
+ app.use(authenticateAPIKey);
+
+ // API Routes
+ app.post('/v1/experiments', createExperiment);
+ app.get('/v1/experiments/:id', getExperiment);
+ app.get('/v1/experiments', listExperiments);
+ app.put('/v1/experiments/:id', updateExperiment);
+ app.delete('/v1/experiments/:id', deleteExperiment);
+ app.get('/v1/experiments/:id/export', exportExperimentData);
+
+ return app;
+}
+
+// Note: We rely on FIRESTORE_EMULATOR_HOST environment variable to connect
+// both the admin SDK and the API code to the emulator
+
+describe('API Experiment Creation Integration Tests', () => {
+ // Store created experiment IDs for cleanup
+ const createdExperimentIds: string[] = [];
+
+ beforeAll(async () => {
+ // Use the same project ID that the emulator will use
+ const projectId = process.env.GCLOUD_PROJECT || 'demo-deliberate-lab';
+
+ testEnv = await initializeTestEnvironment({
+ projectId,
+ firestore: {
+ host: 'localhost',
+ port: 8081,
+ },
+ });
+ firestore = testEnv.unauthenticatedContext().firestore();
+ firestore.settings({ignoreUndefinedProperties: true, merge: true});
+
+ // Initialize Firebase Admin SDK (will use emulator via FIRESTORE_EMULATOR_HOST environment variable)
+ if (!admin.apps.length) {
+ admin.initializeApp({
+ projectId,
+ });
+ }
+
+ // Create test API key (this will be stored in the emulator)
+ console.log('Creating API key...');
+ const {apiKey, keyId} = await createAPIKey(
+ TEST_EXPERIMENTER_ID,
+ 'Test API Key',
+ [APIKeyPermission.READ, APIKeyPermission.WRITE],
+ );
+ testAPIKey = apiKey;
+ // Verify the key can be validated (this uses admin SDK internally)
+ console.log('Verifying API key...');
+ const {valid, data} = await verifyAPIKey(apiKey);
+ console.log('API key valid:', valid);
+ if (!valid || !data) {
+ throw new Error('API key verification failed');
+ }
+
+ // Create test Express app
+ testApp = createTestAPIApp();
+ });
+
+ afterAll(async () => {
+ // Cleanup test environment
+ await testEnv.cleanup();
+ });
+
+ beforeEach(async () => {
+ // Clear experiments but keep API keys
+ // We need to keep the API key that was created in beforeAll
+ for (const expId of createdExperimentIds) {
+ try {
+ const expRef = firestore.collection(EXPERIMENTS_COLLECTION).doc(expId);
+ await expRef.delete();
+ // Also delete subcollections
+ const stages = await expRef.collection('stages').get();
+ for (const stage of stages.docs) {
+ await stage.ref.delete();
+ }
+ } catch (error) {
+ console.error('Error cleaning up experiment:', expId, error);
+ }
+ }
+ createdExperimentIds.length = 0;
+ });
+
+ /**
+ * Helper function to serialize data for Firestore
+ * Converts Timestamp objects to plain objects
+ */
+ function serializeForFirestore(data: T): T {
+ return JSON.parse(JSON.stringify(data)) as T;
+ }
+
+ /**
+ * Helper function to create an experiment using the template system
+ * (mimics the writeExperiment endpoint behavior)
+ */
+ async function createExperimentViaTemplate(
+ template: ExperimentTemplate,
+ ): Promise {
+ const experimentConfig = createExperimentConfig(
+ template.stageConfigs,
+ template.experiment,
+ );
+
+ // Override creator to test user
+ experimentConfig.metadata.creator = TEST_EXPERIMENTER_ID;
+
+ const document = firestore
+ .collection(EXPERIMENTS_COLLECTION)
+ .doc(experimentConfig.id);
+
+ // Write experiment directly (serialize to remove Timestamp objects)
+ await document.set(serializeForFirestore(experimentConfig));
+
+ // Add stages subcollection
+ for (const stage of template.stageConfigs) {
+ await document
+ .collection('stages')
+ .doc(stage.id)
+ .set(serializeForFirestore(stage));
+ }
+
+ // Add agent mediators if any
+ for (const agent of template.agentMediators) {
+ const doc = document.collection('agentMediators').doc(agent.persona.id);
+ await doc.set(serializeForFirestore(agent.persona));
+ for (const prompt of Object.values(agent.promptMap)) {
+ await doc
+ .collection('prompts')
+ .doc(prompt.id)
+ .set(serializeForFirestore(prompt));
+ }
+ }
+
+ // Add agent participants if any
+ for (const agent of template.agentParticipants) {
+ const doc = document
+ .collection('agentParticipants')
+ .doc(agent.persona.id);
+ await doc.set(serializeForFirestore(agent.persona));
+ for (const prompt of Object.values(agent.promptMap)) {
+ await doc
+ .collection('prompts')
+ .doc(prompt.id)
+ .set(serializeForFirestore(prompt));
+ }
+ }
+
+ createdExperimentIds.push(experimentConfig.id);
+ return experimentConfig.id;
+ }
+
+ /**
+ * Helper function to create an experiment using the API
+ * Makes actual HTTP POST request to the REST API endpoint
+ */
+ async function createExperimentViaAPI(
+ name: string,
+ description: string,
+ stages: StageConfig[],
+ prolificRedirectCode?: string,
+ ): Promise {
+ // Prepare request body matching API schema
+ // Serialize stages to JSON (removes Timestamps, like a real API client would send)
+ const requestBody: {
+ name: string;
+ description: string;
+ stages: StageConfig[];
+ prolificRedirectCode?: string;
+ } = {
+ name,
+ description,
+ stages: JSON.parse(JSON.stringify(stages)) as StageConfig[],
+ };
+
+ if (prolificRedirectCode) {
+ requestBody.prolificRedirectCode = prolificRedirectCode;
+ }
+
+ // Make actual HTTP POST request to API
+ const response = await request(testApp)
+ .post('/v1/experiments')
+ .set('Authorization', `Bearer ${testAPIKey}`)
+ .send(requestBody);
+
+ // Assert successful creation
+ if (response.status !== 201) {
+ console.error('API request failed:', response.status, response.body);
+ }
+ expect(response.status).toBe(201);
+
+ // Extract experiment ID from response
+ const experimentId = response.body.id;
+ createdExperimentIds.push(experimentId);
+ return experimentId;
+ }
+
+ /**
+ * Helper function to fetch experiment from Firestore including stages
+ */
+ async function getExperimentWithStages(
+ experimentId: string,
+ ): Promise<{experiment: Experiment; stages: StageConfig[]}> {
+ const experimentDoc = await firestore
+ .collection(EXPERIMENTS_COLLECTION)
+ .doc(experimentId)
+ .get();
+
+ const experiment = experimentDoc.data() as Experiment;
+
+ const stagesSnapshot = await firestore
+ .collection(EXPERIMENTS_COLLECTION)
+ .doc(experimentId)
+ .collection('stages')
+ .get();
+
+ const stages = stagesSnapshot.docs.map(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (doc: any) => doc.data() as StageConfig,
+ );
+
+ return {experiment, stages};
+ }
+
+ /**
+ * Helper function to normalize experiment for comparison
+ * Removes ONLY fields that are expected to differ:
+ * - Timestamps (dateCreated, dateModified)
+ * - Auto-generated document IDs (experiment ID itself)
+ * Everything else MUST match exactly, including explicitly set stage IDs
+ */
+ function normalizeExperimentForComparison(
+ experiment: Experiment,
+ ): Partial {
+ const normalized = JSON.parse(JSON.stringify(experiment)) as Experiment;
+
+ // Remove timestamp fields (these will naturally differ)
+ if (normalized.metadata) {
+ delete (normalized.metadata as Partial)
+ .dateCreated;
+ delete (normalized.metadata as Partial)
+ .dateModified;
+ }
+
+ // Remove the experiment document ID (auto-generated, will differ)
+ delete (normalized as Partial).id;
+
+ return normalized;
+ }
+
+ /**
+ * Normalize stage for comparison - timestamps only
+ */
+ function normalizeStageForComparison(stage: StageConfig): StageConfig {
+ const normalized = JSON.parse(JSON.stringify(stage)) as StageConfig;
+ // Stages have explicit IDs from templates, so we keep those
+ // No timestamps in stage configs currently
+ return normalized;
+ }
+
+ /**
+ * Helper function to compare two experiments for structural equivalence
+ * Compares everything EXCEPT timestamps and auto-generated document IDs
+ */
+ function compareExperiments(
+ exp1: Experiment,
+ stages1: StageConfig[],
+ exp2: Experiment,
+ stages2: StageConfig[],
+ ): {
+ equivalent: boolean;
+ differences: string[];
+ } {
+ const differences: string[] = [];
+
+ // Normalize for comparison (removes timestamps and doc IDs)
+ const norm1 = normalizeExperimentForComparison(exp1);
+ const norm2 = normalizeExperimentForComparison(exp2);
+
+ // Deep comparison of experiment config (excluding normalized fields)
+ const exp1Str = JSON.stringify(norm1, Object.keys(norm1).sort());
+ const exp2Str = JSON.stringify(norm2, Object.keys(norm2).sort());
+
+ if (exp1Str !== exp2Str) {
+ // Provide more detailed differences
+ if (norm1.metadata?.name !== norm2.metadata?.name) {
+ differences.push(
+ `Metadata name differs: "${norm1.metadata?.name}" vs "${norm2.metadata?.name}"`,
+ );
+ }
+ if (norm1.metadata?.description !== norm2.metadata?.description) {
+ differences.push(
+ `Metadata description differs: "${norm1.metadata?.description}" vs "${norm2.metadata?.description}"`,
+ );
+ }
+ if (norm1.metadata?.creator !== norm2.metadata?.creator) {
+ differences.push(
+ `Creator differs: "${norm1.metadata?.creator}" vs "${norm2.metadata?.creator}"`,
+ );
+ }
+ if (norm1.versionId !== norm2.versionId) {
+ differences.push(
+ `Version ID differs: ${norm1.versionId} vs ${norm2.versionId}`,
+ );
+ }
+ if (JSON.stringify(norm1.stageIds) !== JSON.stringify(norm2.stageIds)) {
+ differences.push(
+ `Stage IDs order differs: ${JSON.stringify(norm1.stageIds)} vs ${JSON.stringify(norm2.stageIds)}`,
+ );
+ }
+ if (
+ JSON.stringify(norm1.permissions) !== JSON.stringify(norm2.permissions)
+ ) {
+ differences.push('Permissions config differs');
+ }
+ if (
+ JSON.stringify(norm1.defaultCohortConfig) !==
+ JSON.stringify(norm2.defaultCohortConfig)
+ ) {
+ differences.push('Default cohort config differs');
+ }
+ }
+
+ // Compare stages
+ if (stages1.length !== stages2.length) {
+ differences.push(
+ `Stage count differs: ${stages1.length} vs ${stages2.length}`,
+ );
+ } else {
+ // Stages should have explicit IDs from templates, so we compare by ID
+ const stagesById1 = new Map(stages1.map((s) => [s.id, s]));
+ const stagesById2 = new Map(stages2.map((s) => [s.id, s]));
+
+ for (const [id, stage1] of stagesById1) {
+ const stage2 = stagesById2.get(id);
+ if (!stage2) {
+ differences.push(`Stage ${id} missing in second experiment`);
+ continue;
+ }
+
+ const normStage1 = normalizeStageForComparison(stage1);
+ const normStage2 = normalizeStageForComparison(stage2);
+
+ const stage1Str = JSON.stringify(
+ normStage1,
+ Object.keys(normStage1).sort(),
+ );
+ const stage2Str = JSON.stringify(
+ normStage2,
+ Object.keys(normStage2).sort(),
+ );
+
+ if (stage1Str !== stage2Str) {
+ differences.push(
+ `Stage ${id} ("${stage1.name}") configuration differs`,
+ );
+ }
+ }
+ }
+
+ return {
+ equivalent: differences.length === 0,
+ differences,
+ };
+ }
+
+ // ============================================================================
+ // Template Test Configuration
+ // ============================================================================
+
+ const TEMPLATES_TO_TEST = [
+ getFlipCardExperimentTemplate,
+ getQuickstartGroupChatTemplate,
+ getPolicyExperimentTemplate,
+ // Add more templates here as needed.
+ ];
+
+ describe.each(
+ TEMPLATES_TO_TEST.map((getTemplate) => {
+ const template = getTemplate();
+ return {
+ name: template.experiment.metadata.name,
+ description: template.experiment.metadata.description,
+ getTemplate,
+ };
+ }),
+ )('$name Template Comparison', ({name, description, getTemplate}) => {
+ it(`should create equivalent experiments via template and API (${description})`, async () => {
+ // Get fresh template instance for this test
+ const template = getTemplate();
+
+ // Create experiment via template system
+ const templateExperimentId = await createExperimentViaTemplate(template);
+
+ // Create experiment via API with same configuration
+ const apiExperimentId = await createExperimentViaAPI(
+ template.experiment.metadata.name,
+ template.experiment.metadata.description,
+ template.stageConfigs,
+ );
+
+ // Fetch both experiments from Firestore
+ const templateData = await getExperimentWithStages(templateExperimentId);
+ const apiData = await getExperimentWithStages(apiExperimentId);
+
+ // Compare experiments
+ const comparison = compareExperiments(
+ templateData.experiment,
+ templateData.stages,
+ apiData.experiment,
+ apiData.stages,
+ );
+
+ // Assert equivalence
+ expect(comparison.equivalent).toBe(true);
+ if (!comparison.equivalent) {
+ console.error(`[${name}] Differences found:`, comparison.differences);
+ }
+ expect(comparison.differences).toEqual([]);
+ }, 30000); // 30 second timeout for Firestore operations
+ });
+});
diff --git a/functions/src/dl_api/experiments.api.ts b/functions/src/dl_api/experiments.api.ts
new file mode 100644
index 000000000..768b7cf40
--- /dev/null
+++ b/functions/src/dl_api/experiments.api.ts
@@ -0,0 +1,424 @@
+/**
+ * API endpoints for experiment management (Express version)
+ */
+
+import * as admin from 'firebase-admin';
+import {Response} from 'express';
+import {
+ AuthenticatedRequest,
+ hasPermission,
+ validateOrRespond,
+} from './api.utils';
+import {
+ createExperimentConfig,
+ getExperimentDownload,
+ StageConfig,
+ MetadataConfig,
+ UnifiedTimestamp,
+} from '@deliberation-lab/utils';
+import {
+ getFirestoreExperiment,
+ getFirestoreExperimentRef,
+} from '../utils/firestore';
+import {validateStages} from '../utils/validation';
+
+// Use simplified schemas for the REST API
+// The full ExperimentCreationData schema is for the internal endpoints
+interface CreateExperimentRequest {
+ name: string;
+ description?: string;
+ stages?: StageConfig[];
+ prolificRedirectCode?: string;
+}
+
+interface UpdateExperimentRequest {
+ name?: string;
+ description?: string;
+ stages?: StageConfig[];
+ prolificRedirectCode?: string;
+}
+
+/**
+ * List experiments for the authenticated user
+ */
+export async function listExperiments(
+ req: AuthenticatedRequest,
+ res: Response,
+): Promise {
+ if (!hasPermission(req, 'read')) {
+ res.status(403).json({error: 'Insufficient permissions'});
+ return;
+ }
+
+ const app = admin.app();
+ const firestore = app.firestore();
+ const experimenterId = req.apiKeyData!.experimenterId;
+
+ try {
+ // Get experiments where user is creator or has read access
+ const snapshot = await firestore
+ .collection('experiments')
+ .where('metadata.creator', '==', experimenterId)
+ .get();
+
+ const experiments = snapshot.docs.map((doc) => ({
+ id: doc.id,
+ ...doc.data(),
+ }));
+
+ res.status(200).json({
+ experiments,
+ total: experiments.length,
+ });
+ } catch (error) {
+ console.error('Error listing experiments:', error);
+ res.status(500).json({error: 'Failed to list experiments'});
+ }
+}
+
+/**
+ * Create a new experiment
+ */
+export async function createExperiment(
+ req: AuthenticatedRequest,
+ res: Response,
+): Promise {
+ if (!hasPermission(req, 'write')) {
+ res.status(403).json({error: 'Insufficient permissions'});
+ return;
+ }
+
+ const app = admin.app();
+ const firestore = app.firestore();
+ const experimenterId = req.apiKeyData!.experimenterId;
+
+ try {
+ const body = req.body as CreateExperimentRequest;
+
+ // Basic validation
+ if (!body.name) {
+ res.status(400).json({
+ error: 'Invalid request body: name is required',
+ });
+ return;
+ }
+
+ const timestamp = admin.firestore.Timestamp.now() as UnifiedTimestamp;
+
+ // Use existing utility functions to create proper config
+ const metadata: MetadataConfig & {prolificRedirectCode?: string} = {
+ name: body.name,
+ description: body.description || '',
+ publicName: '',
+ tags: [],
+ creator: experimenterId,
+ starred: {},
+ dateCreated: timestamp,
+ dateModified: timestamp,
+ };
+
+ // Add prolific redirect code if provided
+ if (body.prolificRedirectCode) {
+ metadata.prolificRedirectCode = body.prolificRedirectCode;
+ }
+
+ // Create experiment config with stages (if provided)
+ const stageConfigs = body.stages || [];
+ const experimentConfig = createExperimentConfig(stageConfigs, {
+ metadata,
+ });
+
+ // Use transaction for consistency (similar to writeExperiment)
+ await firestore.runTransaction(async (transaction) => {
+ const experimentRef = firestore
+ .collection('experiments')
+ .doc(experimentConfig.id);
+
+ // Check if experiment already exists
+ const existingDoc = await transaction.get(experimentRef);
+ if (existingDoc.exists) {
+ throw new Error('Experiment with this ID already exists');
+ }
+
+ // Set the experiment document
+ transaction.set(experimentRef, experimentConfig);
+
+ // Add stages subcollection if stages provided
+ for (const stage of stageConfigs) {
+ transaction.set(
+ experimentRef.collection('stages').doc(stage.id),
+ stage,
+ );
+ }
+ });
+
+ res.status(201).json({
+ ...experimentConfig,
+ id: experimentConfig.id,
+ });
+ } catch (error) {
+ console.error('Error creating experiment:', error);
+ res.status(500).json({error: 'Failed to create experiment'});
+ }
+}
+
+/**
+ * Get a specific experiment
+ */
+export async function getExperiment(
+ req: AuthenticatedRequest,
+ res: Response,
+): Promise {
+ if (!hasPermission(req, 'read')) {
+ res.status(403).json({error: 'Insufficient permissions'});
+ return;
+ }
+
+ const experimentId = req.params.id;
+ const experimenterId = req.apiKeyData!.experimenterId;
+
+ if (!experimentId) {
+ res.status(400).json({error: 'Experiment ID required'});
+ return;
+ }
+
+ try {
+ // Use existing utility function
+ const experiment = await getFirestoreExperiment(experimentId);
+
+ if (!experiment) {
+ res.status(404).json({error: 'Experiment not found'});
+ return;
+ }
+
+ // Check access permissions
+ if (
+ experiment.metadata.creator !== experimenterId &&
+ !experiment.permissions?.readers?.includes(experimenterId)
+ ) {
+ res.status(403).json({error: 'Access denied'});
+ return;
+ }
+
+ res.status(200).json({
+ ...experiment,
+ id: experimentId,
+ });
+ } catch (error) {
+ console.error('Error getting experiment:', error);
+ res.status(500).json({error: 'Failed to get experiment'});
+ }
+}
+
+/**
+ * Update an experiment
+ */
+export async function updateExperiment(
+ req: AuthenticatedRequest,
+ res: Response,
+): Promise {
+ if (!hasPermission(req, 'write')) {
+ res.status(403).json({error: 'Insufficient permissions'});
+ return;
+ }
+
+ const app = admin.app();
+ const firestore = app.firestore();
+ const experimentId = req.params.id;
+ const experimenterId = req.apiKeyData!.experimenterId;
+
+ if (!experimentId) {
+ res.status(400).json({error: 'Experiment ID required'});
+ return;
+ }
+
+ try {
+ const body = req.body as UpdateExperimentRequest;
+
+ // Use existing utility to get experiment
+ const experiment = await getFirestoreExperiment(experimentId);
+ if (!experiment) {
+ res.status(404).json({error: 'Experiment not found'});
+ return;
+ }
+
+ // Check ownership
+ if (experiment.metadata.creator !== experimenterId) {
+ res
+ .status(403)
+ .json({error: 'Only the creator can update the experiment'});
+ return;
+ }
+
+ // Build update object for metadata fields
+ const updates: Record = {};
+ if (body.name !== undefined) updates['metadata.name'] = body.name;
+ if (body.description !== undefined)
+ updates['metadata.description'] = body.description;
+ if (body.prolificRedirectCode !== undefined) {
+ updates['metadata.prolificRedirectCode'] = body.prolificRedirectCode;
+ }
+
+ // Update timestamp
+ updates['metadata.dateModified'] = admin.firestore.Timestamp.now();
+
+ // Validate stages if provided
+ if (!validateOrRespond(body.stages, validateStages, res)) return;
+
+ // Use transaction if stages need updating
+ if (body.stages !== undefined) {
+ await firestore.runTransaction(async (transaction) => {
+ const experimentRef = getFirestoreExperimentRef(experimentId);
+
+ // Update experiment metadata
+ transaction.update(experimentRef, updates);
+
+ // Clean up old stages
+ const oldStageCollection = experimentRef.collection('stages');
+ const oldStages = await oldStageCollection.get();
+ oldStages.forEach((doc) => transaction.delete(doc.ref));
+
+ // Add new stages and update stageIds
+ const stageIds: string[] = [];
+ for (const stage of body.stages || []) {
+ const stageRef = experimentRef
+ .collection('stages')
+ .doc(stage.id || experimentRef.collection('stages').doc().id);
+ transaction.set(stageRef, stage);
+ stageIds.push(stageRef.id);
+ }
+
+ transaction.update(experimentRef, {stageIds});
+ });
+ } else {
+ // Simple update without stages
+ await getFirestoreExperimentRef(experimentId).update(updates);
+ }
+
+ res.status(200).json({
+ updated: true,
+ id: experimentId,
+ });
+ } catch (error) {
+ console.error('Error updating experiment:', error);
+ res.status(500).json({error: 'Failed to update experiment'});
+ }
+}
+
+/**
+ * Delete an experiment
+ */
+export async function deleteExperiment(
+ req: AuthenticatedRequest,
+ res: Response,
+): Promise {
+ if (!hasPermission(req, 'write')) {
+ res.status(403).json({error: 'Insufficient permissions'});
+ return;
+ }
+
+ const app = admin.app();
+ const firestore = app.firestore();
+ const experimentId = req.params.id;
+ const experimenterId = req.apiKeyData!.experimenterId;
+
+ if (!experimentId) {
+ res.status(400).json({error: 'Experiment ID required'});
+ return;
+ }
+
+ try {
+ // Use existing utility to get experiment
+ const experiment = await getFirestoreExperiment(experimentId);
+ if (!experiment) {
+ res.status(404).json({error: 'Experiment not found'});
+ return;
+ }
+
+ // Check ownership
+ if (experiment.metadata.creator !== experimenterId) {
+ res
+ .status(403)
+ .json({error: 'Only the creator can delete the experiment'});
+ return;
+ }
+
+ // Use Firebase's recursive delete to properly clean up all subcollections
+ // This handles stages, cohorts, participants, and all nested data
+ const experimentRef = getFirestoreExperimentRef(experimentId);
+ await firestore.recursiveDelete(experimentRef);
+
+ res.status(200).json({
+ id: experimentId,
+ deleted: true,
+ });
+ } catch (error) {
+ console.error('Error deleting experiment:', error);
+ res.status(500).json({error: 'Failed to delete experiment'});
+ }
+}
+
+/**
+ * Export experiment data
+ * Returns comprehensive ExperimentDownload structure with all related data
+ */
+export async function exportExperimentData(
+ req: AuthenticatedRequest,
+ res: Response,
+): Promise {
+ if (!hasPermission(req, 'read')) {
+ res.status(403).json({error: 'Insufficient permissions'});
+ return;
+ }
+
+ const app = admin.app();
+ const firestore = app.firestore();
+ const experimentId = req.params.id;
+ const experimenterId = req.apiKeyData!.experimenterId;
+
+ if (!experimentId) {
+ res.status(400).json({error: 'Experiment ID required'});
+ return;
+ }
+
+ try {
+ // First check permissions using existing utility
+ const experiment = await getFirestoreExperiment(experimentId);
+ if (!experiment) {
+ res.status(404).json({error: 'Experiment not found'});
+ return;
+ }
+
+ // Check access permissions
+ if (
+ experiment.metadata.creator !== experimenterId &&
+ !experiment.permissions?.readers?.includes(experimenterId)
+ ) {
+ res.status(403).json({error: 'Access denied'});
+ return;
+ }
+
+ // Use the shared function directly from utils
+ const experimentDownload = await getExperimentDownload(
+ firestore,
+ experimentId,
+ );
+
+ if (!experimentDownload) {
+ res.status(404).json({error: 'Failed to build experiment download'});
+ return;
+ }
+
+ // Format response based on query parameter
+ const format = req.query.format || 'json';
+
+ if (format === 'json') {
+ res.status(200).json(experimentDownload);
+ } else {
+ res.status(400).json({error: 'Unsupported format. Use format=json'});
+ }
+ } catch (error) {
+ console.error('Error exporting experiment data:', error);
+ res.status(500).json({error: 'Failed to export experiment data'});
+ }
+}
diff --git a/functions/src/index.ts b/functions/src/index.ts
index a24411dc3..b47aa5207 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -12,6 +12,8 @@ export * from './experiment.endpoints';
export * from './mediator.endpoints';
export * from './participant.endpoints';
+export * from './dl_api/api.endpoints';
+
export * from './stages/asset_allocation.endpoints';
export * from './stages/chat.endpoints';
export * from './stages/chip.endpoints';
diff --git a/functions/src/utils/validation.ts b/functions/src/utils/validation.ts
index 77bc05eae..7b2c6dfd1 100644
--- a/functions/src/utils/validation.ts
+++ b/functions/src/utils/validation.ts
@@ -1,6 +1,6 @@
/** Pretty printing and analysis utils for typebox validation */
-import {CONFIG_DATA, Index} from '@deliberation-lab/utils';
+import {CONFIG_DATA, Index, StageConfigData} from '@deliberation-lab/utils';
import {TObject} from '@sinclair/typebox';
import {
ValueError,
@@ -76,3 +76,55 @@ export const checkConfigDataUnionOnPath = (data: unknown, path: string) =>
checkUnionErrorOnPath(data, path, CONFIG_DATA);
// TODO: add more union validation variants if needed when something goes wrong
+
+// ************************************************************************* //
+// STAGE VALIDATION //
+// ************************************************************************* //
+
+/**
+ * Validate an array of stage configurations using TypeBox runtime validation
+ * Uses existing validation utilities to handle union errors properly
+ * @param stages - Array of stage objects to validate
+ * @returns Error message string if invalid, null if all stages are valid
+ */
+export function validateStages(stages: unknown[]): string | null {
+ if (!Array.isArray(stages)) {
+ return 'Invalid stages: must be an array';
+ }
+
+ const errorMessages: string[] = [];
+
+ for (let i = 0; i < stages.length; i++) {
+ const stage = stages[i];
+ const isValid = Value.Check(StageConfigData, stage);
+
+ if (!isValid) {
+ // Extract stage metadata for context
+ const stageObj = stage as Record;
+ const stageName = stageObj?.name || 'unnamed';
+ const stageKind = stageObj?.kind || 'unknown';
+
+ errorMessages.push(
+ `Stage ${i} (name: "${stageName}", kind: "${stageKind}"):`,
+ );
+
+ // Iterate through errors and handle union errors specially
+ for (const error of Value.Errors(StageConfigData, stage)) {
+ if (isUnionError(error)) {
+ // For union errors (like StageConfig which is a union of many stage types),
+ // drill down to get the specific validation error for this stage kind
+ const nested = checkConfigDataUnionOnPath(stage, error.path);
+ for (const nestedError of nested) {
+ errorMessages.push(
+ ` - ${nestedError.path}: ${nestedError.message}`,
+ );
+ }
+ } else {
+ errorMessages.push(` - ${error.path}: ${error.message}`);
+ }
+ }
+ }
+ }
+
+ return errorMessages.length > 0 ? errorMessages.join('\n') : null;
+}
diff --git a/utils/src/api_key.ts b/utils/src/api_key.ts
new file mode 100644
index 000000000..7378843f2
--- /dev/null
+++ b/utils/src/api_key.ts
@@ -0,0 +1,19 @@
+/**
+ * API Key types and enums shared between frontend and backend
+ */
+
+export enum APIKeyPermission {
+ READ = 'read',
+ WRITE = 'write',
+}
+
+export interface APIKeyData {
+ hash: string;
+ salt: string;
+ experimenterId: string;
+ name: string;
+ permissions: APIKeyPermission[];
+ createdAt: number;
+ lastUsed: number | null;
+ expiresAt?: number;
+}
diff --git a/utils/src/api_key.validation.ts b/utils/src/api_key.validation.ts
new file mode 100644
index 000000000..c2f005c9f
--- /dev/null
+++ b/utils/src/api_key.validation.ts
@@ -0,0 +1,23 @@
+/**
+ * Runtime validation schemas for API key types
+ */
+
+import {Type, Static} from '@sinclair/typebox';
+import {APIKeyPermission} from './api_key';
+
+/** Schema for APIKeyPermission enum */
+export const APIKeyPermissionSchema = Type.Enum(APIKeyPermission);
+
+/** Schema for APIKeyData */
+export const APIKeyDataSchema = Type.Object({
+ hash: Type.String(),
+ salt: Type.String(),
+ experimenterId: Type.String(),
+ name: Type.String(),
+ permissions: Type.Array(APIKeyPermissionSchema),
+ createdAt: Type.Number(),
+ lastUsed: Type.Union([Type.Number(), Type.Null()]),
+ expiresAt: Type.Optional(Type.Number()),
+});
+
+export type APIKeyDataType = Static;
diff --git a/utils/src/data.ts b/utils/src/data.ts
index edb3c1e28..6ff63253a 100644
--- a/utils/src/data.ts
+++ b/utils/src/data.ts
@@ -1,4 +1,13 @@
-import {AgentMediatorTemplate, AgentParticipantTemplate} from './agent';
+import {
+ AgentMediatorTemplate,
+ AgentParticipantTemplate,
+ AgentMediatorPersonaConfig,
+ AgentParticipantPersonaConfig,
+} from './agent';
+import {
+ MediatorPromptConfig,
+ ParticipantPromptConfig,
+} from './structured_prompt';
import {AlertMessage} from './alert';
import {CohortConfig} from './cohort';
import {Experiment} from './experiment';
@@ -6,9 +15,18 @@ import {ParticipantProfileExtended} from './participant';
import {ChatMessage} from './chat_message';
import {
StageConfig,
+ StageKind,
StageParticipantAnswer,
StagePublicData,
} from './stages/stage';
+import {
+ collection,
+ doc,
+ getDocs,
+ getDoc,
+ query,
+ orderBy,
+} from 'firebase/firestore';
/** Experiment data download types and functions. */
@@ -81,3 +99,197 @@ export function createCohortDownload(cohort: CohortConfig): CohortDownload {
chatMap: {},
};
}
+
+/**
+ * Build a complete ExperimentDownload structure.
+ * Uses Firebase modular SDK functions that work with both client and admin SDKs.
+ *
+ * @param firestore - Firestore instance (client or admin SDK)
+ * Note: Uses 'any' type because this function accepts both
+ * firebase/firestore (client) and firebase-admin/firestore (admin)
+ * Firestore types, which are incompatible at the type level.
+ * @param experimentId - ID of the experiment to download
+ * @returns Complete experiment download data
+ */
+export async function getExperimentDownload(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ firestore: any,
+ experimentId: string,
+) {
+ // Get experiment config from experimentId
+ const experimentConfig = (
+ await getDoc(doc(firestore, 'experiments', experimentId))
+ ).data() as Experiment;
+
+ // Create experiment download using experiment config
+ const experimentDownload = createExperimentDownload(experimentConfig);
+
+ // For each experiment stage config, add to ExperimentDownload
+ const stageConfigs = (
+ await getDocs(collection(firestore, 'experiments', experimentId, 'stages'))
+ ).docs.map((doc) => doc.data() as StageConfig);
+ for (const stage of stageConfigs) {
+ experimentDownload.stageMap[stage.id] = stage;
+ }
+
+ // For each participant, add ParticipantDownload
+ const profiles = (
+ await getDocs(
+ collection(firestore, 'experiments', experimentId, 'participants'),
+ )
+ ).docs.map((doc) => doc.data() as ParticipantProfileExtended);
+ for (const profile of profiles) {
+ // Create new ParticipantDownload
+ const participantDownload = createParticipantDownload(profile);
+
+ // For each stage answer, add to ParticipantDownload map
+ const stageAnswers = (
+ await getDocs(
+ collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'participants',
+ profile.privateId,
+ 'stageData',
+ ),
+ )
+ ).docs.map((doc) => doc.data() as StageParticipantAnswer);
+ for (const stage of stageAnswers) {
+ participantDownload.answerMap[stage.id] = stage;
+ }
+ // Add ParticipantDownload to ExperimentDownload
+ experimentDownload.participantMap[profile.publicId] = participantDownload;
+ }
+
+ // For each agent mediator, add template
+ const agentMediatorCollection = collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'agentMediators',
+ );
+ const mediatorAgents = (await getDocs(agentMediatorCollection)).docs.map(
+ (agent) => agent.data() as AgentMediatorPersonaConfig,
+ );
+ for (const persona of mediatorAgents) {
+ const mediatorPrompts = (
+ await getDocs(
+ collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'agentMediators',
+ persona.id,
+ 'prompts',
+ ),
+ )
+ ).docs.map((doc) => doc.data() as MediatorPromptConfig);
+ const mediatorTemplate: AgentMediatorTemplate = {
+ persona,
+ promptMap: {},
+ };
+ mediatorPrompts.forEach((prompt) => {
+ mediatorTemplate.promptMap[prompt.id] = prompt;
+ });
+ // Add to ExperimentDownload
+ experimentDownload.agentMediatorMap[persona.id] = mediatorTemplate;
+ }
+
+ // For each agent participant, add template
+ const agentParticipantCollection = collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'agentParticipants',
+ );
+ const participantAgents = (
+ await getDocs(agentParticipantCollection)
+ ).docs.map((agent) => agent.data() as AgentParticipantPersonaConfig);
+ for (const persona of participantAgents) {
+ const participantPrompts = (
+ await getDocs(
+ collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'agentParticipants',
+ persona.id,
+ 'prompts',
+ ),
+ )
+ ).docs.map((doc) => doc.data() as ParticipantPromptConfig);
+ const participantTemplate: AgentParticipantTemplate = {
+ persona,
+ promptMap: {},
+ };
+ participantPrompts.forEach((prompt) => {
+ participantTemplate.promptMap[prompt.id] = prompt;
+ });
+ // Add to ExperimentDownload
+ experimentDownload.agentParticipantMap[persona.id] = participantTemplate;
+ }
+
+ // For each cohort, add CohortDownload
+ const cohorts = (
+ await getDocs(collection(firestore, 'experiments', experimentId, 'cohorts'))
+ ).docs.map((cohort) => cohort.data() as CohortConfig);
+ for (const cohort of cohorts) {
+ // Create new CohortDownload
+ const cohortDownload = createCohortDownload(cohort);
+
+ // For each public stage data, add to CohortDownload
+ const publicStageData = (
+ await getDocs(
+ collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'cohorts',
+ cohort.id,
+ 'publicStageData',
+ ),
+ )
+ ).docs.map((doc) => doc.data() as StagePublicData);
+ for (const data of publicStageData) {
+ cohortDownload.dataMap[data.id] = data;
+ // If chat stage, add list of chat messages to CohortDownload
+ if (data.kind === StageKind.CHAT) {
+ const chatList = (
+ await getDocs(
+ query(
+ collection(
+ firestore,
+ 'experiments',
+ experimentId,
+ 'cohorts',
+ cohort.id,
+ 'publicStageData',
+ data.id,
+ 'chats',
+ ),
+ orderBy('timestamp', 'asc'),
+ ),
+ )
+ ).docs.map((doc) => doc.data() as ChatMessage);
+ cohortDownload.chatMap[data.id] = chatList;
+ }
+ }
+
+ // Add CohortDownload to ExperimentDownload
+ experimentDownload.cohortMap[cohort.id] = cohortDownload;
+ }
+
+ // Add alerts to ExperimentDownload
+ const alertList = (
+ await getDocs(
+ query(
+ collection(firestore, 'experiments', experimentId, 'alerts'),
+ orderBy('timestamp', 'asc'),
+ ),
+ )
+ ).docs.map((doc) => doc.data() as AlertMessage);
+ experimentDownload.alerts = alertList;
+
+ return experimentDownload;
+}
diff --git a/utils/src/index.ts b/utils/src/index.ts
index 16a4aca94..0d920974a 100644
--- a/utils/src/index.ts
+++ b/utils/src/index.ts
@@ -4,6 +4,10 @@
export * from './alert';
export * from './alert.validation';
+// API Key
+export * from './api_key';
+export * from './api_key.validation';
+
// Experimenter
export * from './experimenter';