mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Automatically import skills from new GitHub project repositories and update enabled sources in the background. Preserve project-derived access, saved selections, immutable execution versions and explicit pins; add legacy adoption, refresh and connection repair in Sources. Include durable scheduling and bounded work credits, production Storybook journeys, real GitHub-to-routine acceptance receipts and final-head CI/security/Greptile qualification. Co-Authored-By: Paperclip <noreply@paperclip.ing>
100 lines
6.1 KiB
TypeScript
100 lines
6.1 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
import type { RequestHandler } from 'express';
|
|
import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
|
|
import { activityLog, companySkills, companySkillSources, projectSkillSources, projects, type Db } from '@paperclipai/db';
|
|
import { authorizationService, projectReadSqlCondition, type AuthorizationActor } from './authorization.js';
|
|
import { notFound } from '../errors.js';
|
|
|
|
// Carries the authenticated identity through shared services, including native API dispatch.
|
|
// Background operations must install their stored, revalidated setup identity explicitly.
|
|
const actors = new AsyncLocalStorage<AuthorizationActor>();
|
|
export const currentSkillActor = () => actors.getStore();
|
|
export function withSkillActor<T>(actor: AuthorizationActor, operation: () => T): T {
|
|
return actors.run(actor, operation);
|
|
}
|
|
export const skillAccessRequestContext: RequestHandler = (req, _res, next) => withSkillActor(req.actor, next);
|
|
|
|
export function skillAccessService(db: Db, actor = currentSkillActor()) {
|
|
function canReadCompany(companyId: string) {
|
|
return actor?.type !== 'none' && !(actor?.type === 'agent' && actor.companyId !== companyId)
|
|
&& !(actor?.type === 'board' && actor.companyIds && !actor.companyIds.includes(companyId)
|
|
&& actor.source !== 'local_implicit' && !actor.isInstanceAdmin);
|
|
}
|
|
async function projectIds(companyId: string, sourceId?: string) {
|
|
const bindings = await db.select({ sourceId: projectSkillSources.sourceId, projectId: projectSkillSources.projectId })
|
|
.from(projectSkillSources).where(and(eq(projectSkillSources.companyId, companyId), isNotNull(projectSkillSources.sourceId),
|
|
...(sourceId ? [eq(projectSkillSources.sourceId, sourceId)] : [])));
|
|
if (!actor) return bindings;
|
|
const access = authorizationService(db);
|
|
const decisions = new Map<string, boolean>();
|
|
for (const id of new Set(bindings.map(binding => binding.projectId))) {
|
|
decisions.set(id, (await access.decide({ actor, action: 'project:read',
|
|
resource: { type: 'project', companyId, projectId: id } })).allowed);
|
|
}
|
|
return bindings.filter(binding => decisions.get(binding.projectId));
|
|
}
|
|
async function visibleSourceIds(companyId: string) {
|
|
if (!canReadCompany(companyId)) return [];
|
|
if (!actor) return null;
|
|
const sources = await db.select({ id: companySkillSources.id, scope: companySkillSources.accessScope })
|
|
.from(companySkillSources).where(eq(companySkillSources.companyId, companyId));
|
|
const permitted = new Set((await projectIds(companyId)).map(binding => binding.sourceId));
|
|
return sources.filter(source => source.scope === 'company' || permitted.has(source.id)).map(source => source.id);
|
|
}
|
|
async function condition(companyId: string) {
|
|
if (!canReadCompany(companyId)) return sql`false`;
|
|
const ids = await visibleSourceIds(companyId);
|
|
if (ids === null) return sql`true`;
|
|
return or(isNull(companySkills.accessSourceId), ids.length ? inArray(companySkills.accessSourceId, ids) : sql`false`)!;
|
|
}
|
|
async function assertSkill(companyId: string, skillId: string) {
|
|
const [row] = await db.select({ id: companySkills.id }).from(companySkills)
|
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skillId), await condition(companyId)));
|
|
if (!row) throw notFound('Skill not found');
|
|
}
|
|
async function assertSource(companyId: string, sourceId: string) {
|
|
const ids = await visibleSourceIds(companyId);
|
|
if (ids !== null && !ids.includes(sourceId)) throw notFound('Skill source not found');
|
|
}
|
|
async function activityCondition(companyId: string) {
|
|
if (!actor) return sql<boolean>`true`;
|
|
const ids = await visibleSourceIds(companyId);
|
|
const skillCondition = await condition(companyId);
|
|
const projectCondition = await projectReadSqlCondition(db, actor);
|
|
return sql<boolean>`(
|
|
(${activityLog.entityType} != 'company_skill' or exists (
|
|
select 1 from ${companySkills} where ${companySkills.companyId} = ${companyId}
|
|
and ${companySkills.id}::text = ${activityLog.entityId} and ${skillCondition}))
|
|
and (${activityLog.entityType} != 'company_skill_source' or ${ids?.length ? inArray(activityLog.entityId, ids) : sql`false`})
|
|
and (${activityLog.entityType} != 'project' or exists (
|
|
select 1 from ${projects} where ${projects.companyId} = ${companyId}
|
|
and ${projects.id}::text = ${activityLog.entityId} and ${projectCondition})))`;
|
|
}
|
|
return { canReadCompany, condition, projectIds, visibleSourceIds, assertSkill, assertSource, activityCondition };
|
|
}
|
|
|
|
/** Independent editable forks retain the originating project set at creation.
|
|
* Membership/visibility remain dynamic; later repository attachments grant no access.
|
|
*/
|
|
export async function retainProjectSkillAccess(db: Db, companyId: string, sourceId: string | null | undefined) {
|
|
if (!sourceId) return null;
|
|
return db.transaction(async tx => {
|
|
const [source] = await tx.select().from(companySkillSources).where(and(
|
|
eq(companySkillSources.companyId, companyId), eq(companySkillSources.id, sourceId)));
|
|
if (!source || source.removedAt || source.accessScope !== 'projects') return sourceId;
|
|
const origins = await tx.select({ projectId: projectSkillSources.projectId }).from(projectSkillSources)
|
|
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, sourceId)));
|
|
const id = randomUUID();
|
|
await tx.insert(companySkillSources).values({ id, companyId, repositoryId: source.repositoryId,
|
|
repositoryUrl: source.repositoryUrl, fullName: source.fullName, trackingRef: source.trackingRef,
|
|
accessScope: 'projects', removedAt: new Date(), enabled: false, automaticUpdates: false });
|
|
const projectIds = [...new Set(origins.map(origin => origin.projectId))];
|
|
if (projectIds.length) await tx.insert(projectSkillSources).values(projectIds.map(projectId => ({
|
|
companyId, projectId, sourceId: id, repositoryUrl: source.repositoryUrl, trackingRef: source.trackingRef,
|
|
active: false, status: 'ready' as const,
|
|
})));
|
|
return id;
|
|
});
|
|
}
|