mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-15 23:43:06 +03:00
feat: Scope synced skill author to tenant and harden tenant-context sync
Addresses the latest Codex review on the per-source tenant change: - makeSourceAuthorId now folds tenantId into the synthetic author hash so the same source mirrored into different tenants gets distinct author ids (clearer audits, no cross-tenant author collisions). Single-tenant author ids stay stable (suffix omitted when tenantId is absent). - syncSourceInTenantContext uses an async callback per the tenant-context contract so the ALS store propagates across awaited Mongoose calls. - Tests: same-source/different-tenant yields distinct authors; mirror cleanup is scoped to the source and deletes only its absent-upstream skills.
This commit is contained in:
@@ -305,6 +305,76 @@ describe('createGitHubSkillSyncRunner', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes mirror cleanup to the current source and deletes only its absent upstream skills', async () => {
|
||||
const keptId = new Types.ObjectId();
|
||||
const staleId = new Types.ObjectId();
|
||||
const existingSkill = (upstreamId: string, _id: Types.ObjectId) =>
|
||||
({
|
||||
...makeSkill({
|
||||
name: 'research',
|
||||
description: 'Research things',
|
||||
author: new Types.ObjectId(),
|
||||
source: 'github',
|
||||
sourceMetadata: { provider: 'github', sourceId: 'librechat-skills', upstreamId },
|
||||
} as CreateSkillInput),
|
||||
_id,
|
||||
}) as ISkill & { _id: Types.ObjectId };
|
||||
const listSkillsBySource = jest.fn(async () => [
|
||||
existingSkill('librechat-skills:LibreChat/skills:skills/research', keptId),
|
||||
existingSkill('librechat-skills:LibreChat/skills:skills/removed', staleId),
|
||||
]);
|
||||
const deps = createDeps({ listSkillsBySource });
|
||||
const runner = createGitHubSkillSyncRunner(deps);
|
||||
const result = await runner.runOnce();
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(listSkillsBySource).toHaveBeenCalledWith({
|
||||
source: 'github',
|
||||
sourceId: 'librechat-skills',
|
||||
});
|
||||
expect(deps.deleteSkill).toHaveBeenCalledTimes(1);
|
||||
expect(deps.deleteSkill).toHaveBeenCalledWith(staleId.toString());
|
||||
});
|
||||
|
||||
it('derives distinct synthetic authors for the same source mirrored into different tenants', async () => {
|
||||
const authorForTenant = async (tenantId: string): Promise<string> => {
|
||||
let author = '';
|
||||
const deps = createDeps({
|
||||
getConfig: () => ({
|
||||
github: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
runOnStartup: false,
|
||||
sources: [
|
||||
{
|
||||
id: 'librechat-skills',
|
||||
owner: 'LibreChat',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
paths: ['skills'],
|
||||
credentialKey: 'github-skills-prod',
|
||||
tenantId,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createSkill: jest.fn(async (input: CreateSkillInput): Promise<CreateSkillResult> => {
|
||||
author = input.author.toString();
|
||||
return { skill: makeSkill(input), warnings: [] };
|
||||
}),
|
||||
});
|
||||
await createGitHubSkillSyncRunner(deps).runOnce();
|
||||
return author;
|
||||
};
|
||||
|
||||
const [authorA, authorB] = [
|
||||
await authorForTenant('tenant-a'),
|
||||
await authorForTenant('tenant-b'),
|
||||
];
|
||||
expect(authorA).not.toBe('');
|
||||
expect(authorA).not.toBe(authorB);
|
||||
});
|
||||
|
||||
it('uses distinct synthetic authors so same-named skills can sync from different sources', async () => {
|
||||
const seenNamesByAuthor = new Set<string>();
|
||||
const deps = createDeps({
|
||||
|
||||
@@ -217,11 +217,14 @@ function makeUpstreamId(source: SkillSyncGitHubSourceConfig, rootPath: string):
|
||||
}
|
||||
|
||||
function makeSourceAuthorId(source: SkillSyncGitHubSourceConfig): Types.ObjectId {
|
||||
const digest = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${PROVIDER}:${source.id}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
// Fold the tenant into the synthetic author so the same source mirrored into
|
||||
// different tenants gets distinct author ids (clearer audits, no cross-tenant
|
||||
// author collisions). The tenant suffix is omitted when absent so single-tenant
|
||||
// author ids stay stable.
|
||||
const seed = source.tenantId
|
||||
? `${PROVIDER}:${source.id}:${source.tenantId}`
|
||||
: `${PROVIDER}:${source.id}`;
|
||||
const digest = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 24);
|
||||
return new Types.ObjectId(digest);
|
||||
}
|
||||
|
||||
@@ -1108,8 +1111,12 @@ async function syncSource(params: {
|
||||
/**
|
||||
* Runs a source sync inside its tenant's async context when `tenantId` is set,
|
||||
* so the tenant-isolation mongoose hooks scope every skill/file/ACL read and
|
||||
* write to that tenant (required under strict isolation). Without a configured
|
||||
* write to that tenant (required under strict isolation). Storage writes also
|
||||
* receive the tenant explicitly via `skill.tenantId`. Without a configured
|
||||
* tenant the sync runs in the ambient context, preserving single-tenant behavior.
|
||||
*
|
||||
* The callback is `async` per the tenant-context contract so the ALS store
|
||||
* propagates across every awaited Mongoose operation in `syncSource`.
|
||||
*/
|
||||
function syncSourceInTenantContext(params: {
|
||||
deps: GitHubSkillSyncDeps;
|
||||
@@ -1120,7 +1127,7 @@ function syncSourceInTenantContext(params: {
|
||||
if (!params.source.tenantId) {
|
||||
return syncSource(params);
|
||||
}
|
||||
return tenantStorage.run({ tenantId: params.source.tenantId }, () => syncSource(params));
|
||||
return tenantStorage.run({ tenantId: params.source.tenantId }, async () => syncSource(params));
|
||||
}
|
||||
|
||||
function getGithubConfig(config: SkillSyncConfig | undefined): {
|
||||
|
||||
Reference in New Issue
Block a user