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` + + `; + } + + private renderCreateForm() { + return html` +
+ { + this.newKeyName = (e.target as HTMLInputElement).value; + }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') { + this.handleCreateKey(); + } + }} + > +
+ + Create Key + + { + this.showCreateForm = false; + this.newKeyName = ''; + }} + > + Cancel + +
+
+
+ `; + } + + 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';