mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
chore: Remove Published Credential Defaults (#14680)
This commit is contained in:
parent
0db511fee8
commit
1596df724a
19 changed files with 841 additions and 39 deletions
18
.env.example
18
.env.example
|
|
@ -500,8 +500,10 @@ ASSISTANTS_API_KEY=user_provided
|
|||
# More info, including how to enable use of Assistants with Azure here:
|
||||
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
|
||||
|
||||
CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
|
||||
CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
|
||||
# Leave these blank to let LibreChat generate and persist temporary credentials in .env.temp.
|
||||
# Configure unique, persistent values before using a production instance.
|
||||
CREDS_KEY=
|
||||
CREDS_IV=
|
||||
|
||||
# Azure AI Search
|
||||
#-----------------
|
||||
|
|
@ -581,10 +583,12 @@ ZAPIER_NLA_API_KEY=
|
|||
# Search #
|
||||
#==================================================#
|
||||
|
||||
SEARCH=true
|
||||
# Set both SEARCH=true and a unique MEILI_MASTER_KEY to enable search.
|
||||
SEARCH=false
|
||||
MEILI_NO_ANALYTICS=true
|
||||
MEILI_HOST=http://0.0.0.0:7700
|
||||
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
|
||||
# Set a unique value when Meilisearch is enabled; do not reuse a published default.
|
||||
MEILI_MASTER_KEY=
|
||||
|
||||
# Optional: Disable indexing, useful in a multi-node setup
|
||||
# where only one instance should perform an index sync.
|
||||
|
|
@ -684,8 +688,10 @@ REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
|
|||
# Set to false only for HTTP-only deployments where browsers drop Secure cookies.
|
||||
# SESSION_COOKIE_SECURE=false
|
||||
|
||||
JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
|
||||
JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
|
||||
# Leave these blank to use generated temporary secrets from .env.temp.
|
||||
# Configure unique, persistent values before using a production instance.
|
||||
JWT_SECRET=
|
||||
JWT_REFRESH_SECRET=
|
||||
|
||||
# Discord
|
||||
DISCORD_CLIENT_ID=
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ RUN \
|
|||
# Allow mounting of these files, which have no default
|
||||
touch .env ; \
|
||||
# Create directories for the volumes to inherit the correct permissions
|
||||
mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill ; \
|
||||
mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill /app/data ; \
|
||||
chmod 1777 /app/data ; \
|
||||
npm config set fetch-retry-maxtimeout 600000 ; \
|
||||
npm config set fetch-retries 5 ; \
|
||||
npm config set fetch-retry-mintimeout 15000 ; \
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ RUN npm run build
|
|||
FROM base-min AS api-build
|
||||
ARG NPM_CI_TIMEOUT_SECONDS=1500
|
||||
ARG NPM_CI_ATTEMPTS=2
|
||||
RUN mkdir -p /app/data && chmod 1777 /app/data
|
||||
# Add `uv` for extended MCP support
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
|
||||
RUN uv --version
|
||||
|
|
|
|||
5
api/config/credentials.js
Normal file
5
api/config/credentials.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const { bootstrapCredentials } = require('@librechat/api/credentials');
|
||||
|
||||
module.exports = bootstrapCredentials();
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
require('dotenv').config();
|
||||
require('../config/credentials');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
||||
|
|
@ -300,7 +300,7 @@ if (cluster.isMaster) {
|
|||
app.set('trust proxy', trusted_proxy);
|
||||
|
||||
/** Seed database (idempotent) */
|
||||
await seedDatabase();
|
||||
await runAsSystem(seedDatabase);
|
||||
|
||||
/* Mirrors `server/index.js`; `runAsSystem` for tenant-isolated File. */
|
||||
runAsSystem(sweepOrphanedPreviews).catch((err) => {
|
||||
|
|
@ -323,8 +323,10 @@ if (cluster.isMaster) {
|
|||
});
|
||||
expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig };
|
||||
startExpiredFileSweepOnce();
|
||||
await performStartupChecks(appConfig);
|
||||
await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions });
|
||||
await runAsSystem(async () => {
|
||||
await performStartupChecks(appConfig);
|
||||
await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions });
|
||||
});
|
||||
|
||||
/** Load index.html for SPA serving */
|
||||
const indexPath = path.join(appConfig.paths.dist, 'index.html');
|
||||
|
|
|
|||
|
|
@ -12,4 +12,11 @@ describe('Experimental server configuration', () => {
|
|||
expect(timeoutConfigIndex).toBeGreaterThan(-1);
|
||||
expect(listenIndex).toBeLessThan(timeoutConfigIndex);
|
||||
});
|
||||
|
||||
it('runs cross-tenant startup work in the system context', () => {
|
||||
expect(source).toContain('await runAsSystem(seedDatabase);');
|
||||
expect(source).toMatch(
|
||||
/await runAsSystem\(async \(\) => \{\s+await performStartupChecks\(appConfig\);\s+await updateInterfacePerms/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
require('../config/credentials');
|
||||
|
||||
const telemetry = require('./telemetry');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
|
|
|||
|
|
@ -50,13 +50,17 @@ jest.mock(
|
|||
describe('Telemetry wiring', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
|
||||
|
||||
it('loads telemetry before other server imports', () => {
|
||||
const firstStatement = source
|
||||
it('loads credentials before telemetry and other server imports', () => {
|
||||
const firstStatements = source
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
|
||||
expect(firstStatement).toBe("const telemetry = require('./telemetry');");
|
||||
expect(firstStatements).toEqual([
|
||||
"require('../config/credentials');",
|
||||
"const telemetry = require('./telemetry');",
|
||||
]);
|
||||
});
|
||||
|
||||
it('mounts telemetry middleware after static assets and before routes', () => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ services:
|
|||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://mongodb:27017/LibreChat
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- LIBRECHAT_TEMP_CREDENTIALS_PATH=/app/data/.env.temp
|
||||
- RAG_PORT=${RAG_PORT:-8000}
|
||||
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
|
||||
- PROXY=${PROXY:-}
|
||||
|
|
@ -39,6 +40,7 @@ services:
|
|||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/api/logs
|
||||
- ./skill:/app/skill
|
||||
- librechat-data:/app/data
|
||||
|
||||
admin-panel:
|
||||
image: registry.librechat.ai/clickhouse/librechat-admin-panel:latest
|
||||
|
|
@ -111,3 +113,4 @@ services:
|
|||
|
||||
volumes:
|
||||
pgdata2:
|
||||
librechat-data:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ services:
|
|||
- HOST=0.0.0.0
|
||||
- MONGO_URI=mongodb://mongodb:27017/LibreChat
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- LIBRECHAT_TEMP_CREDENTIALS_PATH=/app/data/.env.temp
|
||||
- RAG_PORT=${RAG_PORT:-8000}
|
||||
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
|
||||
- PROXY=${PROXY:-}
|
||||
|
|
@ -35,6 +36,7 @@ services:
|
|||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
- ./skill:/app/skill
|
||||
- librechat-data:/app/data
|
||||
admin-panel:
|
||||
container_name: admin-panel
|
||||
image: registry.librechat.ai/clickhouse/librechat-admin-panel:latest
|
||||
|
|
@ -94,3 +96,4 @@ services:
|
|||
|
||||
volumes:
|
||||
pgdata2:
|
||||
librechat-data:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ In this Chart, LibreChat will only work with environment Variables. You can Spec
|
|||
## Setup
|
||||
|
||||
1. Generate Variables
|
||||
Generate `CREDS_KEY`, `JWT_SECRET`, `JWT_REFRESH_SECRET` and `MEILI_MASTER_KEY` using `openssl rand -hex 32` and `CREDS_IV` using openssl rand -hex 16.
|
||||
Generate unique values for `CREDS_KEY`, `JWT_SECRET`, `JWT_REFRESH_SECRET`, and `MEILI_MASTER_KEY` using `openssl rand -hex 32`, and `CREDS_IV` using `openssl rand -hex 16`. Store them in the existing Kubernetes Secret so every replica uses the same values.
|
||||
place them in a secret like this (If you want to change the secret name, remember to change it in your helm values):
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
|
|
@ -18,6 +18,7 @@ metadata:
|
|||
type: Opaque
|
||||
stringData:
|
||||
CREDS_KEY: <generated value>
|
||||
CREDS_IV: <generated value>
|
||||
JWT_SECRET: <generated value>
|
||||
JWT_REFRESH_SECRET: <generated value>
|
||||
MEILI_MASTER_KEY: <generated value>
|
||||
|
|
|
|||
|
|
@ -40,11 +40,8 @@ librechat:
|
|||
adminPanelUrl: ""
|
||||
|
||||
configEnv:
|
||||
# IMPORTANT -- GENERATE your own: openssl rand -hex 32 and openssl rand -hex 16 for CREDS_IV. Best Practise: Put into Secret. See global.librechat.existingSecretName
|
||||
CREDS_KEY: 9e95d9894da7e68dd69c0046caf5343c8b1e80c89609b5a1e40e6568b5b23ce6
|
||||
CREDS_IV: ac028c86ba23f4cd48165e0ca9f2c683
|
||||
JWT_SECRET: 16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
|
||||
JWT_REFRESH_SECRET: eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
|
||||
# Set unique, persistent values in global.librechat.existingSecretName before production use.
|
||||
# If omitted, LibreChat generates temporary values in .env.temp when the container filesystem is persistent.
|
||||
# Set Config Params here
|
||||
# ENV_NAME: env-value
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@
|
|||
"types": "./dist/telemetry.d.cts",
|
||||
"default": "./dist/telemetry.cjs"
|
||||
}
|
||||
},
|
||||
"./credentials": {
|
||||
"require": {
|
||||
"types": "./dist/credentials.d.cts",
|
||||
"default": "./dist/credentials.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { logger, webSearchKeys } from '@librechat/data-schemas';
|
||||
import { Constants, extractVariableName } from 'librechat-data-provider';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { CredentialFingerprintRecord } from '~/credentials';
|
||||
import {
|
||||
credentialMetadataCollection,
|
||||
credentialMetadataId,
|
||||
credentialNames,
|
||||
getCredentialFingerprints,
|
||||
getCredentialRuntimeState,
|
||||
getLegacyCredentialNames,
|
||||
} from '~/credentials';
|
||||
import { isEnabled, checkEmailConfig } from '~/utils';
|
||||
import { handleRateLimits } from './limits';
|
||||
|
||||
const secretDefaults = {
|
||||
CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0',
|
||||
CREDS_IV: 'e2341419ec3dd3d19b13a1a87fafcbfb',
|
||||
JWT_SECRET: '16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef',
|
||||
JWT_REFRESH_SECRET: 'eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418',
|
||||
};
|
||||
interface CredentialMetadata {
|
||||
_id: string;
|
||||
fingerprints: Partial<CredentialFingerprintRecord>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const deprecatedVariables = [
|
||||
{
|
||||
|
|
@ -106,26 +115,48 @@ function checkPasswordReset() {
|
|||
* @param {Function} options.checkEmailConfig - Function to check email configuration
|
||||
*/
|
||||
export function checkVariables(): void {
|
||||
let hasDefaultSecrets = false;
|
||||
for (const [key, value] of Object.entries(secretDefaults)) {
|
||||
if (process.env[key] === value) {
|
||||
logger.warn(`Default value for ${key} is being used.`);
|
||||
if (!hasDefaultSecrets) {
|
||||
hasDefaultSecrets = true;
|
||||
}
|
||||
}
|
||||
const legacyNames = getLegacyCredentialNames();
|
||||
for (const key of legacyNames) {
|
||||
logger.warn(
|
||||
`Legacy default value for ${key} is being used. Generate and configure a unique value.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasDefaultSecrets) {
|
||||
logger.info('Please replace any default secret values.');
|
||||
if (legacyNames.length > 0) {
|
||||
logger.info(
|
||||
'Replace legacy credential defaults before exposing this instance to untrusted users.',
|
||||
);
|
||||
logger.info(`\u200B
|
||||
|
||||
For your convenience, use this tool to generate your own secret values:
|
||||
Generate unique values with a cryptographically secure random source, for example:
|
||||
openssl rand -hex 32
|
||||
openssl rand -hex 16 for CREDS_IV
|
||||
|
||||
For more guidance, see:
|
||||
https://www.librechat.ai/toolkit/creds_generator
|
||||
|
||||
\u200B`);
|
||||
}
|
||||
|
||||
const runtimeState = getCredentialRuntimeState();
|
||||
if (runtimeState?.missingFromEnvironment.length && !runtimeState.persistenceFailed) {
|
||||
const temporaryNames = runtimeState.missingFromEnvironment.filter(
|
||||
(name) => runtimeState.sources[name] === 'temporary',
|
||||
);
|
||||
if (temporaryNames.length > 0) {
|
||||
logger.warn(
|
||||
`[credentials] No configured value was found for ${temporaryNames.join(', ')}. ` +
|
||||
`Temporary credentials from ${runtimeState.filePath} are being used.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeState?.persistenceFailed) {
|
||||
logger.warn(
|
||||
'[credentials] Temporary credentials could not be persisted. Existing sessions and encrypted data may become inaccessible after restart.',
|
||||
);
|
||||
}
|
||||
|
||||
deprecatedVariables.forEach(({ key, description }) => {
|
||||
if (process.env[key]) {
|
||||
logger.warn(`The \`${key}\` environment variable is deprecated. ${description}`);
|
||||
|
|
@ -135,6 +166,96 @@ export function checkVariables(): void {
|
|||
checkPasswordReset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares active credential fingerprints with the database marker. The marker contains hashes
|
||||
* only, allowing a new instance to establish its identity without storing secret values.
|
||||
*/
|
||||
export async function checkCredentialDatabase(): Promise<void> {
|
||||
if (mongoose.connection.readyState !== 1 || !mongoose.connection.db) {
|
||||
return;
|
||||
}
|
||||
|
||||
const User = mongoose.models.User;
|
||||
if (!User) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collection = mongoose.connection.db.collection<CredentialMetadata>(
|
||||
credentialMetadataCollection,
|
||||
);
|
||||
const [existingUser, existingMetadata] = await Promise.all([
|
||||
User.exists({}).exec(),
|
||||
collection.findOne({ _id: credentialMetadataId }),
|
||||
]);
|
||||
const hasUsers = existingUser !== null;
|
||||
let metadata = existingMetadata;
|
||||
|
||||
if (!metadata) {
|
||||
if (!hasUsers) {
|
||||
const activeFingerprints = getCredentialFingerprints();
|
||||
const result = await collection.updateOne(
|
||||
{ _id: credentialMetadataId },
|
||||
{
|
||||
$setOnInsert: {
|
||||
_id: credentialMetadataId,
|
||||
fingerprints: activeFingerprints,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
metadata = await collection.findOne({ _id: credentialMetadataId });
|
||||
if (result.upsertedCount === 1) {
|
||||
logger.info(
|
||||
'[credentials] New database detected. Credential fingerprints were recorded for future key-drift checks.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
'[credentials] Existing database has no credential fingerprint record. The active credentials may not match existing JWTs or encrypted records; provide the original values or use a controlled credential migration before rotating them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fingerprints = metadata?.fingerprints ?? {};
|
||||
const activeFingerprints = getCredentialFingerprints();
|
||||
const mismatchedNames: string[] = [];
|
||||
const matchingNames: string[] = [];
|
||||
for (const name of credentialNames) {
|
||||
if (!fingerprints[name]) {
|
||||
mismatchedNames.push(name);
|
||||
continue;
|
||||
}
|
||||
if (fingerprints[name] === activeFingerprints[name]) {
|
||||
matchingNames.push(name);
|
||||
} else {
|
||||
mismatchedNames.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatchedNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mismatchDetail = ' Another startup instance may be using different temporary credentials.';
|
||||
if (matchingNames.length > 0) {
|
||||
mismatchDetail = ` ${matchingNames.join(', ')} still match, which indicates mixed credential versions.`;
|
||||
} else if (hasUsers) {
|
||||
mismatchDetail = ' Existing encrypted records or JWTs may require the previous values.';
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[credentials] Active fingerprints for ${mismatchedNames.join(', ')} do not match the database credential record.` +
|
||||
mismatchDetail +
|
||||
' Do not overwrite the database marker; migrate the affected records and rotate all credentials together.',
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('[credentials] Unable to inspect database credential metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints.
|
||||
* Logs information or warning based on the API's availability and response.
|
||||
|
|
@ -227,6 +348,7 @@ export function checkInterfaceConfig(appConfig: AppConfig): void {
|
|||
*/
|
||||
export async function performStartupChecks(appConfig?: AppConfig): Promise<void> {
|
||||
checkVariables();
|
||||
await checkCredentialDatabase();
|
||||
if (appConfig?.endpoints?.azureOpenAI) {
|
||||
checkAzureVariables();
|
||||
}
|
||||
|
|
|
|||
117
packages/api/src/app/credentials.spec.ts
Normal file
117
packages/api/src/app/credentials.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type { Collection } from 'mongodb';
|
||||
import type { CredentialFingerprintRecord, CredentialName } from '~/credentials';
|
||||
import {
|
||||
credentialMetadataCollection,
|
||||
credentialMetadataId,
|
||||
credentialNames,
|
||||
getCredentialFingerprints,
|
||||
} from '~/credentials';
|
||||
import { checkCredentialDatabase } from './checks';
|
||||
|
||||
const configuredCredentials: Record<CredentialName, string> = {
|
||||
CREDS_KEY: 'a'.repeat(64),
|
||||
CREDS_IV: 'b'.repeat(32),
|
||||
JWT_SECRET: 'c'.repeat(64),
|
||||
JWT_REFRESH_SECRET: 'd'.repeat(64),
|
||||
};
|
||||
|
||||
interface CredentialMetadataDocument {
|
||||
_id: string;
|
||||
fingerprints: CredentialFingerprintRecord;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function getCredentialMetadataCollection(): Collection<CredentialMetadataDocument> {
|
||||
return mongoose.connection.db!.collection<CredentialMetadataDocument>(
|
||||
credentialMetadataCollection,
|
||||
);
|
||||
}
|
||||
|
||||
describe('checkCredentialDatabase', () => {
|
||||
const originalEnv = process.env;
|
||||
let mongoServer: MongoMemoryServer | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
mongoose.model('User', new mongoose.Schema({}, { collection: 'users', strict: false }));
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env = { ...originalEnv, ...configuredCredentials };
|
||||
await Promise.all([
|
||||
mongoose.models.User.deleteMany({}),
|
||||
getCredentialMetadataCollection().deleteMany({ _id: credentialMetadataId }),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer?.stop();
|
||||
});
|
||||
|
||||
it('records fingerprints for a new database', async () => {
|
||||
jest.spyOn(logger, 'info').mockImplementation();
|
||||
await checkCredentialDatabase();
|
||||
|
||||
const metadata = await getCredentialMetadataCollection().findOne({
|
||||
_id: credentialMetadataId,
|
||||
});
|
||||
expect(metadata?.fingerprints).toEqual(getCredentialFingerprints());
|
||||
});
|
||||
|
||||
it('does not establish a marker for a database that already has users', async () => {
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation();
|
||||
await mongoose.models.User.collection.insertOne({ email: 'existing@example.com' });
|
||||
|
||||
await checkCredentialDatabase();
|
||||
|
||||
const metadata = await getCredentialMetadataCollection().findOne({
|
||||
_id: credentialMetadataId,
|
||||
});
|
||||
expect(metadata).toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Existing database has no credential fingerprint record'),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns without overwriting a mismatched marker', async () => {
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation();
|
||||
const originalFingerprints = getCredentialFingerprints();
|
||||
await getCredentialMetadataCollection().insertOne({
|
||||
_id: credentialMetadataId,
|
||||
fingerprints: originalFingerprints,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
process.env.JWT_SECRET = 'e'.repeat(64);
|
||||
|
||||
await checkCredentialDatabase();
|
||||
|
||||
const metadata = await getCredentialMetadataCollection().findOne({
|
||||
_id: credentialMetadataId,
|
||||
});
|
||||
expect(metadata?.fingerprints).toEqual(originalFingerprints);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'));
|
||||
});
|
||||
|
||||
it('accepts a marker that matches all active credentials', async () => {
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation();
|
||||
await getCredentialMetadataCollection().insertOne({
|
||||
_id: credentialMetadataId,
|
||||
fingerprints: getCredentialFingerprints(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await checkCredentialDatabase();
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(credentialNames).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
138
packages/api/src/credentials.spec.ts
Normal file
138
packages/api/src/credentials.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CredentialRuntimeState } from './credentials';
|
||||
import {
|
||||
bootstrapCredentials,
|
||||
credentialNames,
|
||||
getCredentialFingerprints,
|
||||
getCredentialRuntimeState,
|
||||
isLegacyCredential,
|
||||
} from './credentials';
|
||||
|
||||
const credentialRuntimeKey = Symbol.for('librechat.credentials.runtime');
|
||||
|
||||
function resetCredentialRuntime(): void {
|
||||
const runtime = globalThis as typeof globalThis &
|
||||
Record<symbol, CredentialRuntimeState | undefined>;
|
||||
delete runtime[credentialRuntimeKey];
|
||||
}
|
||||
|
||||
describe('credentials', () => {
|
||||
const originalEnv = process.env;
|
||||
let tempDirectory: string;
|
||||
let tempFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'warn').mockImplementation();
|
||||
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'librechat-credentials-'));
|
||||
tempFile = path.join(tempDirectory, '.env.temp');
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
LIBRECHAT_TEMP_CREDENTIALS_PATH: tempFile,
|
||||
};
|
||||
for (const name of credentialNames) {
|
||||
delete process.env[name];
|
||||
}
|
||||
resetCredentialRuntime();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCredentialRuntime();
|
||||
process.env = originalEnv;
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('generates and persists temporary credentials when values are absent', () => {
|
||||
const state = bootstrapCredentials();
|
||||
|
||||
expect(state.generated).toEqual(credentialNames);
|
||||
expect(state.loadedFromFile).toEqual([]);
|
||||
expect(state.persistenceFailed).toBe(false);
|
||||
expect(getCredentialRuntimeState()).toEqual(state);
|
||||
expect(fs.statSync(tempFile).mode & 0o777).toBe(0o600);
|
||||
expect(getCredentialFingerprints()).toEqual(
|
||||
expect.objectContaining({
|
||||
CREDS_KEY: expect.any(String),
|
||||
CREDS_IV: expect.any(String),
|
||||
JWT_SECRET: expect.any(String),
|
||||
JWT_REFRESH_SECRET: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses persisted credentials on the next startup', () => {
|
||||
bootstrapCredentials();
|
||||
const originalValues = Object.fromEntries(
|
||||
credentialNames.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
resetCredentialRuntime();
|
||||
for (const name of credentialNames) {
|
||||
delete process.env[name];
|
||||
}
|
||||
|
||||
const state = bootstrapCredentials();
|
||||
|
||||
expect(state.generated).toEqual([]);
|
||||
expect(state.loadedFromFile).toEqual(credentialNames);
|
||||
for (const name of credentialNames) {
|
||||
expect(process.env[name]).toBe(originalValues[name]);
|
||||
}
|
||||
});
|
||||
|
||||
it('adopts a winning repair when an existing credential file is incomplete', () => {
|
||||
const winningValues = {
|
||||
CREDS_KEY: 'a'.repeat(64),
|
||||
CREDS_IV: 'b'.repeat(32),
|
||||
JWT_SECRET: 'c'.repeat(64),
|
||||
JWT_REFRESH_SECRET: 'd'.repeat(64),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
tempFile,
|
||||
`CREDS_KEY=${winningValues.CREDS_KEY}\nCREDS_IV=${winningValues.CREDS_IV}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${tempFile}.lock`,
|
||||
credentialNames.map((name) => `${name}=${winningValues[name]}`).join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const state = bootstrapCredentials();
|
||||
|
||||
expect(state.generated).toEqual([]);
|
||||
expect(state.loadedFromFile).toEqual(credentialNames);
|
||||
expect(state.persistenceFailed).toBe(false);
|
||||
expect(fs.existsSync(`${tempFile}.lock`)).toBe(false);
|
||||
for (const name of credentialNames) {
|
||||
expect(process.env[name]).toBe(winningValues[name]);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves explicitly configured JWT secrets for backward compatibility', () => {
|
||||
process.env.JWT_SECRET = 'short-but-explicit';
|
||||
process.env.JWT_REFRESH_SECRET = 'another-explicit-value';
|
||||
|
||||
const state = bootstrapCredentials();
|
||||
|
||||
expect(process.env.JWT_SECRET).toBe('short-but-explicit');
|
||||
expect(process.env.JWT_REFRESH_SECRET).toBe('another-explicit-value');
|
||||
expect(state.sources.JWT_SECRET).toBe('environment');
|
||||
expect(state.sources.JWT_REFRESH_SECRET).toBe('environment');
|
||||
expect(state.generated).toEqual(['CREDS_KEY', 'CREDS_IV']);
|
||||
});
|
||||
|
||||
it('does not overwrite an explicitly selected environment file', () => {
|
||||
const environmentFile = path.join(tempDirectory, '.env');
|
||||
process.env.LIBRECHAT_TEMP_CREDENTIALS_PATH = environmentFile;
|
||||
|
||||
const state = bootstrapCredentials();
|
||||
|
||||
expect(state.persistenceFailed).toBe(true);
|
||||
expect(fs.existsSync(environmentFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not classify an arbitrary credential as a legacy default', () => {
|
||||
expect(isLegacyCredential('JWT_SECRET', 'test-only-secret')).toBe(false);
|
||||
});
|
||||
});
|
||||
386
packages/api/src/credentials.ts
Normal file
386
packages/api/src/credentials.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const credentialNames = [
|
||||
'CREDS_KEY',
|
||||
'CREDS_IV',
|
||||
'JWT_SECRET',
|
||||
'JWT_REFRESH_SECRET',
|
||||
] as const;
|
||||
|
||||
export type CredentialName = (typeof credentialNames)[number];
|
||||
|
||||
export type CredentialSource = 'environment' | 'temporary' | 'legacy-default';
|
||||
|
||||
export interface CredentialRuntimeState {
|
||||
filePath: string;
|
||||
sources: Record<CredentialName, CredentialSource>;
|
||||
generated: CredentialName[];
|
||||
loadedFromFile: CredentialName[];
|
||||
missingFromEnvironment: CredentialName[];
|
||||
persistenceFailed: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialFingerprintRecord {
|
||||
CREDS_KEY: string;
|
||||
CREDS_IV: string;
|
||||
JWT_SECRET: string;
|
||||
JWT_REFRESH_SECRET: string;
|
||||
}
|
||||
|
||||
interface CredentialFileResult {
|
||||
values: Partial<Record<CredentialName, string>>;
|
||||
exists: boolean;
|
||||
readable: boolean;
|
||||
}
|
||||
|
||||
type CredentialFileWriteResult = 'written' | 'exists' | 'failed';
|
||||
|
||||
export const credentialMetadataId = 'primary';
|
||||
export const credentialMetadataCollection = 'librechatCredentialMetadata';
|
||||
|
||||
const credentialRuntimeKey = Symbol.for('librechat.credentials.runtime');
|
||||
const tempCredentialPathEnvironment = 'LIBRECHAT_TEMP_CREDENTIALS_PATH';
|
||||
|
||||
const legacyCredentialFingerprints: Partial<Record<CredentialName, string[]>> = {
|
||||
CREDS_KEY: [
|
||||
'7c1154b5152983978147ea87e4a2066d4768673e9d85d6a792cddc2f784efd2f',
|
||||
'6aa4b14dcf6d5094e8f3ed7ac00ba5f0a4d36af50940e870c987a101149a06b3',
|
||||
],
|
||||
CREDS_IV: [
|
||||
'06d2e911edb4d45985b9d6338ec3d4c6b4bde69f8143437797c187ab07085531',
|
||||
'be066640b0c70fcf71e9181eca99768f87da3e4e3764282dfc8063da2f4010ca',
|
||||
],
|
||||
JWT_SECRET: ['69024f21e9ad17594dcccd93e87399af24a5426ccfe6108d1787f0335966abc4'],
|
||||
JWT_REFRESH_SECRET: ['282ad5f60261639fefed381976b4d0dde52eab5527a1ab2ec75d5be1efa1165b'],
|
||||
};
|
||||
|
||||
function getRuntimeState(): CredentialRuntimeState | undefined {
|
||||
const runtime = globalThis as typeof globalThis &
|
||||
Record<symbol, CredentialRuntimeState | undefined>;
|
||||
return runtime[credentialRuntimeKey];
|
||||
}
|
||||
|
||||
function setRuntimeState(state: CredentialRuntimeState): void {
|
||||
const runtime = globalThis as typeof globalThis &
|
||||
Record<symbol, CredentialRuntimeState | undefined>;
|
||||
runtime[credentialRuntimeKey] = state;
|
||||
}
|
||||
|
||||
function getCredentialPath(): string {
|
||||
const configuredPath = process.env[tempCredentialPathEnvironment]?.trim();
|
||||
return path.resolve(configuredPath || path.join(process.cwd(), '.env.temp'));
|
||||
}
|
||||
|
||||
function parseCredentialFile(contents: string): Partial<Record<CredentialName, string>> {
|
||||
return contents.split(/\r?\n/).reduce<Partial<Record<CredentialName, string>>>((values, line) => {
|
||||
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
||||
if (!match || !credentialNames.includes(match[1] as CredentialName)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
const value = match[2].trim();
|
||||
values[match[1] as CredentialName] =
|
||||
value.length >= 2 && value.startsWith('"') && value.endsWith('"')
|
||||
? value.slice(1, -1)
|
||||
: value;
|
||||
return values;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readCredentialFile(filePath: string): CredentialFileResult {
|
||||
try {
|
||||
return {
|
||||
values: parseCredentialFile(fs.readFileSync(filePath, 'utf8')),
|
||||
exists: true,
|
||||
readable: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { values: {}, exists: false, readable: true };
|
||||
}
|
||||
|
||||
console.warn(`[credentials] Unable to read temporary credentials file at ${filePath}.`);
|
||||
return { values: {}, exists: true, readable: false };
|
||||
}
|
||||
}
|
||||
|
||||
function isConfiguredCredential(value: string | undefined): value is string {
|
||||
return Boolean(value?.trim());
|
||||
}
|
||||
|
||||
function isUsableTemporaryCredential(
|
||||
name: CredentialName,
|
||||
value: string | undefined,
|
||||
): value is string {
|
||||
if (!value?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name === 'CREDS_KEY') {
|
||||
return /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
if (name === 'CREDS_IV') {
|
||||
return /^[0-9a-f]{32}$/i.test(value);
|
||||
}
|
||||
|
||||
return value.length >= 32;
|
||||
}
|
||||
|
||||
function generateCredential(name: CredentialName): string {
|
||||
return crypto.randomBytes(name === 'CREDS_IV' ? 16 : 32).toString('hex');
|
||||
}
|
||||
|
||||
function isProtectedEnvironmentPath(filePath: string): boolean {
|
||||
const basename = path.basename(path.resolve(filePath));
|
||||
return basename === '.env' || basename === '.env.example';
|
||||
}
|
||||
|
||||
function serializeCredentialFile(values: Partial<Record<CredentialName, string>>): string {
|
||||
return [
|
||||
'# Automatically generated by LibreChat. Keep this file private and persistent.',
|
||||
...credentialNames.filter((name) => values[name]).map((name) => `${name}=${values[name]}`),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function hasUsableCredentialValues(filePath: string, names: CredentialName[]): boolean {
|
||||
const file = readCredentialFile(filePath);
|
||||
return (
|
||||
file.readable && names.every((name) => isUsableTemporaryCredential(name, file.values[name]))
|
||||
);
|
||||
}
|
||||
|
||||
function writeCredentialFile(
|
||||
filePath: string,
|
||||
values: Partial<Record<CredentialName, string>>,
|
||||
overwrite: boolean,
|
||||
generatedNames: CredentialName[],
|
||||
): CredentialFileWriteResult {
|
||||
if (isProtectedEnvironmentPath(filePath)) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
const lockPath = `${filePath}.lock`;
|
||||
let ownsLock = false;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(temporaryPath, serializeCredentialFile(values), { mode: 0o600 });
|
||||
|
||||
if (overwrite) {
|
||||
try {
|
||||
fs.linkSync(temporaryPath, lockPath);
|
||||
ownsLock = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (hasUsableCredentialValues(lockPath, generatedNames)) {
|
||||
try {
|
||||
fs.renameSync(lockPath, filePath);
|
||||
} catch {
|
||||
// The lock owner may have already promoted the same credential values.
|
||||
}
|
||||
}
|
||||
return hasUsableCredentialValues(filePath, generatedNames) ? 'exists' : 'failed';
|
||||
}
|
||||
|
||||
if (hasUsableCredentialValues(filePath, generatedNames)) {
|
||||
return 'exists';
|
||||
}
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
} else {
|
||||
try {
|
||||
fs.linkSync(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
return 'exists';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
return 'written';
|
||||
} catch {
|
||||
return 'failed';
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
} catch {
|
||||
// The temporary path is absent after a successful rename.
|
||||
}
|
||||
if (ownsLock) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
// A contender may have promoted the lock while adopting its values.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hardenCredentialFile(filePath: string): void {
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
console.warn(`[credentials] Unable to restrict permissions on ${filePath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function adoptCredentialFile(
|
||||
filePath: string,
|
||||
names: CredentialName[],
|
||||
sources: Record<CredentialName, CredentialSource>,
|
||||
loadedFromFile: CredentialName[],
|
||||
): boolean {
|
||||
const file = readCredentialFile(filePath);
|
||||
if (!file.readable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const value = file.values[name];
|
||||
if (!isUsableTemporaryCredential(name, value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
process.env[name] = file.values[name];
|
||||
sources[name] = 'temporary';
|
||||
loadedFromFile.push(name);
|
||||
}
|
||||
hardenCredentialFile(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bootstrapCredentials(): CredentialRuntimeState {
|
||||
const existingState = getRuntimeState();
|
||||
if (existingState) {
|
||||
return existingState;
|
||||
}
|
||||
|
||||
const filePath = getCredentialPath();
|
||||
const file = readCredentialFile(filePath);
|
||||
const sources = {} as Record<CredentialName, CredentialSource>;
|
||||
const generated: CredentialName[] = [];
|
||||
const loadedFromFile: CredentialName[] = [];
|
||||
const missingFromEnvironment: CredentialName[] = [];
|
||||
const temporaryValues: Partial<Record<CredentialName, string>> = {};
|
||||
|
||||
for (const name of credentialNames) {
|
||||
const environmentValue = process.env[name];
|
||||
if (isConfiguredCredential(environmentValue)) {
|
||||
sources[name] = isLegacyCredential(name, environmentValue) ? 'legacy-default' : 'environment';
|
||||
continue;
|
||||
}
|
||||
|
||||
missingFromEnvironment.push(name);
|
||||
const fileValue = file.values[name];
|
||||
if (isUsableTemporaryCredential(name, fileValue)) {
|
||||
process.env[name] = fileValue;
|
||||
sources[name] = 'temporary';
|
||||
loadedFromFile.push(name);
|
||||
temporaryValues[name] = fileValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const generatedValue = generateCredential(name);
|
||||
process.env[name] = generatedValue;
|
||||
sources[name] = 'temporary';
|
||||
generated.push(name);
|
||||
temporaryValues[name] = generatedValue;
|
||||
}
|
||||
|
||||
let persistenceFailed = false;
|
||||
if (generated.length > 0 && file.readable) {
|
||||
const writeResult = writeCredentialFile(filePath, temporaryValues, file.exists, generated);
|
||||
if (writeResult === 'exists') {
|
||||
const adopted = adoptCredentialFile(filePath, generated, sources, loadedFromFile);
|
||||
if (adopted) {
|
||||
generated.length = 0;
|
||||
} else {
|
||||
persistenceFailed = true;
|
||||
}
|
||||
} else {
|
||||
persistenceFailed = writeResult === 'failed';
|
||||
}
|
||||
} else if (generated.length > 0) {
|
||||
persistenceFailed = true;
|
||||
} else if (loadedFromFile.length > 0) {
|
||||
hardenCredentialFile(filePath);
|
||||
}
|
||||
|
||||
const state: CredentialRuntimeState = {
|
||||
filePath,
|
||||
sources,
|
||||
generated,
|
||||
loadedFromFile,
|
||||
missingFromEnvironment,
|
||||
persistenceFailed,
|
||||
};
|
||||
setRuntimeState(state);
|
||||
|
||||
if (generated.length > 0 && !persistenceFailed) {
|
||||
console.warn(
|
||||
`[credentials] Generated temporary credentials for ${generated.join(', ')}. ` +
|
||||
`They are stored in ${filePath}; configure permanent values before production use.`,
|
||||
);
|
||||
} else if (generated.length > 0) {
|
||||
console.warn(
|
||||
`[credentials] Generated process-local credentials for ${generated.join(', ')}. ` +
|
||||
'Configure permanent values before production use.',
|
||||
);
|
||||
} else if (loadedFromFile.length > 0) {
|
||||
console.warn(
|
||||
`[credentials] Using temporary credentials from ${filePath} for ${loadedFromFile.join(', ')}. ` +
|
||||
'Configure permanent values before production use.',
|
||||
);
|
||||
}
|
||||
|
||||
if (persistenceFailed) {
|
||||
console.warn(
|
||||
`[credentials] Could not persist temporary credentials to ${filePath}. ` +
|
||||
'The generated values will only remain valid for this process.',
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getCredentialRuntimeState(): CredentialRuntimeState | undefined {
|
||||
return getRuntimeState();
|
||||
}
|
||||
|
||||
function fingerprintCredential(value: string | undefined): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(value ?? '')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function getCredentialFingerprints(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): CredentialFingerprintRecord {
|
||||
return credentialNames.reduce<CredentialFingerprintRecord>((fingerprints, name) => {
|
||||
fingerprints[name] = fingerprintCredential(env[name]);
|
||||
return fingerprints;
|
||||
}, {} as CredentialFingerprintRecord);
|
||||
}
|
||||
|
||||
export function isLegacyCredential(name: CredentialName, value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return legacyCredentialFingerprints[name]?.includes(fingerprintCredential(value)) ?? false;
|
||||
}
|
||||
|
||||
export function getLegacyCredentialNames(env: NodeJS.ProcessEnv = process.env): CredentialName[] {
|
||||
return credentialNames.filter((name) => isLegacyCredential(name, env[name]));
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './app';
|
||||
export * from './credentials';
|
||||
/* Artifacts */
|
||||
export * from './artifacts';
|
||||
/* Admin */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default defineConfig({
|
|||
// `src/telemetry/index.ts` barrel: oxc emits declarations flat into outDir keyed
|
||||
// by source basename, so two `index.ts` entries would collide (index.d.cts +
|
||||
// index2.d.cts). Distinct basenames yield stable `index.*` / `telemetry.*` output.
|
||||
entry: ['src/index.ts', 'src/telemetry.ts'],
|
||||
entry: ['src/index.ts', 'src/telemetry.ts', 'src/credentials.ts'],
|
||||
format: ['cjs'],
|
||||
platform: 'node',
|
||||
dts: { oxc: true },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue