Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions apps/fiona-slack/scripts/setup-cosmos-emulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { readFileSync } from 'node:fs';
import { CosmosClient } from '@azure/cosmos';
import { getCosmosConfig, createCosmosClient } from '../src/agent/cosmos-utils.js';

// Load .env if present so COSMOS_CONNECTION_STRING etc. are available
try {
Expand All @@ -35,24 +35,27 @@ const CONVERSATIONS_CONTAINER = process.env.COSMOS_CONVERSATIONS_CONTAINER || 'c

// Build CosmosClient from connection string if available, otherwise fall back
// to the well-known emulator endpoint + key.
let client;

if (process.env.COSMOS_CONNECTION_STRING) {
client = new CosmosClient({
connectionString: process.env.COSMOS_CONNECTION_STRING,
});
console.log('Using COSMOS_CONNECTION_STRING from environment.');
} else {
const endpoint = process.env.COSMOS_ENDPOINT || 'https://localhost:8081';
const config = getCosmosConfig({
endpoint: process.env.COSMOS_ENDPOINT || 'https://localhost:8081',
// Well-known fixed key used by every default Cosmos DB Emulator install.
// If your emulator was reset or reconfigured, copy the key from the emulator's
// system tray icon → "Copy Connection String", then set COSMOS_CONNECTION_STRING
// in your .env.
const key =
key:
process.env.COSMOS_KEY ||
'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b5n5MBLzPU1z+OhS8OyX8+tU9J1A==';
client = new CosmosClient({ endpoint, key });
console.log(`Using endpoint ${endpoint} (set COSMOS_CONNECTION_STRING in .env to override).`);
'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b5n5MBLzPU1z+OhS8OyX8+tU9J1A==',
});

if (config.connectionString) {
console.log('Using COSMOS_CONNECTION_STRING from environment.');
} else {
console.log(`Using endpoint ${config.endpoint} (set COSMOS_CONNECTION_STRING in .env to override).`);
}

const client = createCosmosClient(config);
if (!client) {
console.error('Failed to create Cosmos DB client. Set COSMOS_CONNECTION_STRING or COSMOS_ENDPOINT.');
process.exit(1);
}

async function main() {
Expand Down
43 changes: 9 additions & 34 deletions apps/fiona-slack/src/agent/conversation-capture-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,9 @@
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

import { CosmosClient } from '@azure/cosmos';
import { DefaultAzureCredential } from '@azure/identity';
import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js';

const CAPTURE_ALL_CONVERSATIONS = process.env.CAPTURE_ALL_CONVERSATIONS === 'true';
const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const COSMOS_CONNECTION_STRING = process.env.COSMOS_CONNECTION_STRING;
const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'chatbot';
const COSMOS_CONVERSATIONS_CONTAINER = process.env.COSMOS_CONVERSATIONS_CONTAINER || 'conversations';
const DEPLOYMENT_TYPE = process.env.DEPLOYMENT_TYPE || 'local';

Expand All @@ -21,7 +16,6 @@ const CONVERSATION_TTL_SECONDS = 15_552_000;
let container = null;

let warnedMissingConfig = false;
let warnedInsecureProductionCosmosKey = false;
const RETRYABLE_CODES = new Set([410, 429, 449, 500, 503]);
const RECONNECT_CODES = new Set([410, 503]);

Expand Down Expand Up @@ -64,16 +58,12 @@ function toNumericCode(error) {
return null;
}

function isEmulatorTarget() {
const target = `${COSMOS_CONNECTION_STRING ?? ''} ${COSMOS_ENDPOINT ?? ''}`.toLowerCase();
return target.includes('localhost') || target.includes('127.0.0.1');
}

function getRetryPolicy() {
if (process.env.NODE_ENV === 'test') {
return { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 };
}
if (isEmulatorTarget()) {
const config = getCosmosConfig();
if (isEmulatorTarget(config.connectionString, config.endpoint)) {
return { maxAttempts: 2, baseDelayMs: 200, maxDelayMs: 1000 };
}
return { maxAttempts: 3, baseDelayMs: 150, maxDelayMs: 800 };
Expand All @@ -92,26 +82,8 @@ function getDelayMs(policy, attempt) {
async function getContainer(logger) {
if (container) return container;

let client;
if (COSMOS_CONNECTION_STRING) {
client = new CosmosClient(COSMOS_CONNECTION_STRING);
} else if (COSMOS_ENDPOINT && COSMOS_KEY) {
if (DEPLOYMENT_TYPE === 'production') {
if (!warnedInsecureProductionCosmosKey) {
warnedInsecureProductionCosmosKey = true;
logger?.warn?.(
'Conversation capture does not support COSMOS_KEY auth in production. Use COSMOS_CONNECTION_STRING or managed identity (COSMOS_ENDPOINT only).',
);
}
return null;
}
client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
} else if (COSMOS_ENDPOINT) {
client = new CosmosClient({
endpoint: COSMOS_ENDPOINT,
aadCredentials: new DefaultAzureCredential(),
});
} else {
const config = getCosmosConfig();
if (!config.connectionString && !config.endpoint) {
if (!warnedMissingConfig) {
warnedMissingConfig = true;
logger?.warn?.(
Expand All @@ -121,7 +93,10 @@ async function getContainer(logger) {
return null;
}

container = client.database(COSMOS_DATABASE).container(COSMOS_CONVERSATIONS_CONTAINER);
const client = createCosmosClient(config, logger);
if (!client) return null;

container = client.database(config.database).container(COSMOS_CONVERSATIONS_CONTAINER);
return container;
}

Expand Down
79 changes: 79 additions & 0 deletions apps/fiona-slack/src/agent/cosmos-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

import { CosmosClient } from '@azure/cosmos';
import { DefaultAzureCredential } from '@azure/identity';

/**
* Detect if the target Cosmos endpoint is a local emulator.
* @param {string} [connectionString]
Expand All @@ -13,3 +16,79 @@ export function isEmulatorTarget(connectionString, endpoint) {
const target = `${connectionString ?? ''} ${endpoint ?? ''}`.toLowerCase();
return target.includes('localhost') || target.includes('127.0.0.1');
}

/**
* @typedef {Object} CosmosConfig
* @property {string | undefined} connectionString - Full Cosmos DB connection string (highest priority)
* @property {string | undefined} endpoint - Cosmos DB account endpoint URL
* @property {string | undefined} key - Cosmos DB account key (not supported in production)
* @property {string} database - Database name (defaults to 'chatbot')
*/

/**
* Read and normalize Cosmos DB connection configuration from environment variables.
*
* Pass `overrides` to substitute specific values — for example, to supply default
* emulator credentials when the env vars are absent, or to force a different database.
*
* @param {Partial<CosmosConfig>} [overrides]
* @returns {CosmosConfig}
*/
export function getCosmosConfig(overrides = {}) {
return {
connectionString: process.env.COSMOS_CONNECTION_STRING,
endpoint: process.env.COSMOS_ENDPOINT,
key: process.env.COSMOS_KEY,
database: process.env.COSMOS_DATABASE || 'chatbot',
...overrides,
};
}

let warnedInsecureProductionCosmosKey = false;

/**
* Instantiate a {@link CosmosClient} from the provided configuration using the
* appropriate authentication strategy.
*
* **Authentication precedence:**
* 1. **Connection string** — used when `config.connectionString` is set.
* 2. **Endpoint + key** — used when both `config.endpoint` and `config.key` are set.
* Blocked in production deployments (`DEPLOYMENT_TYPE=production`): logs a warning
* and returns `null`.
* 3. **Managed identity** — used when only `config.endpoint` is set (no `config.key`),
* authenticating via `DefaultAzureCredential`.
*
* Returns `null` without a warning when neither `connectionString` nor `endpoint` is
* present. Callers are responsible for logging a "not configured" warning in that case.
*
* @param {CosmosConfig} config
* @param {{ warn?: (msg: string) => void }} [logger]
* @returns {import('@azure/cosmos').CosmosClient | null}
*/
export function createCosmosClient(config, logger) {
const { connectionString, endpoint, key } = config;

if (connectionString) {
return new CosmosClient(connectionString);
}

if (endpoint && key) {
const deploymentType = process.env.DEPLOYMENT_TYPE || 'local';
if (deploymentType === 'production') {
if (!warnedInsecureProductionCosmosKey) {
warnedInsecureProductionCosmosKey = true;
logger?.warn?.(
'COSMOS_KEY auth is not supported in production. Use COSMOS_CONNECTION_STRING or managed identity (COSMOS_ENDPOINT only).',
);
}
return null;
}
return new CosmosClient({ endpoint, key });
}

if (endpoint) {
return new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
}

return null;
}
37 changes: 10 additions & 27 deletions apps/fiona-slack/src/agent/feedback-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

import { CosmosClient } from '@azure/cosmos';
import { DefaultAzureCredential } from '@azure/identity';
import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const COSMOS_CONNECTION_STRING = process.env.COSMOS_CONNECTION_STRING;
const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'chatbot';
const COSMOS_CONTAINER = process.env.COSMOS_CONTAINER || 'feedback';

let warnedMissingConfig = false;
Expand Down Expand Up @@ -58,16 +53,12 @@ function toNumericCode(error) {
return null;
}

function isEmulatorTarget() {
const target = `${COSMOS_CONNECTION_STRING ?? ''} ${COSMOS_ENDPOINT ?? ''}`.toLowerCase();
return target.includes('localhost') || target.includes('127.0.0.1');
}

function getRetryPolicy() {
if (process.env.NODE_ENV === 'test') {
return { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 };
}
if (isEmulatorTarget()) {
const config = getCosmosConfig();
if (isEmulatorTarget(config.connectionString, config.endpoint)) {
return { maxAttempts: 2, baseDelayMs: 200, maxDelayMs: 1000 };
}
return { maxAttempts: 3, baseDelayMs: 150, maxDelayMs: 800 };
Expand All @@ -85,19 +76,8 @@ function getDelayMs(policy, attempt) {
async function getContainer(logger) {
if (container) return container;

const database = COSMOS_DATABASE;
const cosmosContainer = COSMOS_CONTAINER;
let client;
if (COSMOS_CONNECTION_STRING) {
client = new CosmosClient(COSMOS_CONNECTION_STRING);
} else if (COSMOS_ENDPOINT && COSMOS_KEY) {
client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
} else if (COSMOS_ENDPOINT) {
client = new CosmosClient({
endpoint: COSMOS_ENDPOINT,
aadCredentials: new DefaultAzureCredential(),
});
} else {
const config = getCosmosConfig();
if (!config.connectionString && !config.endpoint) {
if (!warnedMissingConfig) {
warnedMissingConfig = true;
logger?.warn?.(
Expand All @@ -107,10 +87,13 @@ async function getContainer(logger) {
return null;
}

const client = createCosmosClient(config, logger);
if (!client) return null;

try {
const { database: db } = await client.databases.createIfNotExists({ id: database });
const { database: db } = await client.databases.createIfNotExists({ id: config.database });
const { container: c } = await db.containers.createIfNotExists({
id: cosmosContainer,
id: COSMOS_CONTAINER,
partitionKey: { paths: ['/deploymentType', '/feedbackId'], kind: 'MultiHash', version: 2 },
});
container = c;
Expand Down
41 changes: 10 additions & 31 deletions apps/fiona-slack/src/agent/interaction-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

import { CosmosClient } from '@azure/cosmos';
import { DefaultAzureCredential } from '@azure/identity';
import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const COSMOS_CONNECTION_STRING = process.env.COSMOS_CONNECTION_STRING;
const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'chatbot';
const COSMOS_CONTAINER = process.env.COSMOS_INTERACTIONS_CONTAINER || 'interactions';

let warnedMissingConfig = false;
Expand Down Expand Up @@ -58,16 +53,12 @@ function toNumericCode(error) {
return null;
}

function isEmulatorTarget() {
const target = `${COSMOS_CONNECTION_STRING ?? ''} ${COSMOS_ENDPOINT ?? ''}`.toLowerCase();
return target.includes('localhost') || target.includes('127.0.0.1');
}

function getRetryPolicy() {
if (process.env.NODE_ENV === 'test') {
return { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 };
}
if (isEmulatorTarget()) {
const config = getCosmosConfig();
if (isEmulatorTarget(config.connectionString, config.endpoint)) {
return { maxAttempts: 2, baseDelayMs: 200, maxDelayMs: 1000 };
}
return { maxAttempts: 3, baseDelayMs: 150, maxDelayMs: 800 };
Expand All @@ -87,23 +78,8 @@ function getDelayMs(policy, attempt) {
export async function getContainer(logger) {
if (container) return container;

const connectionString = COSMOS_CONNECTION_STRING;
const endpoint = COSMOS_ENDPOINT;
const key = COSMOS_KEY;
const database = COSMOS_DATABASE;
const cosmosContainer = COSMOS_CONTAINER;

let client;
if (connectionString) {
client = new CosmosClient({ connectionString });
} else if (endpoint && key) {
client = new CosmosClient({ endpoint, key });
} else if (endpoint) {
client = new CosmosClient({
endpoint,
aadCredentials: new DefaultAzureCredential(),
});
} else {
const config = getCosmosConfig();
if (!config.connectionString && !config.endpoint) {
if (!warnedMissingConfig) {
warnedMissingConfig = true;
logger?.warn?.(
Expand All @@ -113,10 +89,13 @@ export async function getContainer(logger) {
return null;
}

const client = createCosmosClient(config, logger);
if (!client) return null;

try {
const { database: db } = await client.databases.createIfNotExists({ id: database });
const { database: db } = await client.databases.createIfNotExists({ id: config.database });
const { container: c } = await db.containers.createIfNotExists({
id: cosmosContainer,
id: COSMOS_CONTAINER,
partitionKey: { paths: ['/deploymentType', '/userId'], kind: 'MultiHash', version: 2 },
});
container = c;
Expand Down
Loading
Loading