diff --git a/package-lock.json b/package-lock.json index 771cf514..a16d4c4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/api", - "version": "0.8.0-rc.61", + "version": "0.8.0-rc.62", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/api", - "version": "0.8.0-rc.61", + "version": "0.8.0-rc.62", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/core": "2.0.0", diff --git a/package.json b/package.json index d6dfeb25..ce08a03d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/api", - "version": "0.8.0-rc.61", + "version": "0.8.0-rc.62", "description": "TreeSeed control-plane API and operations runner.", "license": "AGPL-3.0-only", "repository": { diff --git a/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts b/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts index 84d7fe0a..ff04edd9 100644 --- a/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts +++ b/src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts @@ -1,7 +1,10 @@ +import { createHash } from 'node:crypto'; import { ControlPlaneStore,isoNow,parseJson } from '../../../../persistence/store.ts'; export const MANAGED_TEAM_PROJECT_SLUG='team'; export const MANAGED_TEAM_PROJECT_KIND='system-team-library'; +export const managedTeamLibraryRepositoryName=(teamId:string)=>`team-library-${createHash('sha256').update(teamId).digest('hex').slice(0,12)}`; +export const isManagedTeamLibraryRepositoryName=(teamId:string,name:string)=>name==='team-library'||name===managedTeamLibraryRepositoryName(teamId); export async function ensureManagedTeamLibraryProjectMethod(this:ControlPlaneStore,teamId:string){ await this.ensureInitialized(); @@ -10,8 +13,9 @@ export async function ensureManagedTeamLibraryProjectMethod(this:ControlPlaneSto if(existing.metadata?.kind!==MANAGED_TEAM_PROJECT_KIND||existing.metadata?.systemManaged!==true)throw new Error('The reserved team project slug is occupied by a user-managed project.'); return existing; } + const repositoryName=managedTeamLibraryRepositoryName(teamId); const details=await this.createProject(teamId,{slug:MANAGED_TEAM_PROJECT_SLUG,name:'Team Library',description:'System-managed shared knowledge for this team.',metadata:{kind:MANAGED_TEAM_PROJECT_KIND,systemManaged:true, - libraryOnly:true,library:{repositoryName:'team-library',defaultBranch:'main',integrationBranch:'staging',status:'provisioning'}, + libraryOnly:true,library:{repositoryName,defaultBranch:'main',integrationBranch:'staging',status:'provisioning'}, inventory:{status:'active'},provisioning:{state:'pending',requiredFiles:['README.md','objectives/core']}}}); const project=details?.project??details,teamRow=await this.first('SELECT metadata_json FROM teams WHERE id = ? LIMIT 1',[teamId]); const metadata=parseJson(teamRow?.metadata_json,{});metadata.teamLibrary={projectId:project.id,projectSlug:MANAGED_TEAM_PROJECT_SLUG,state:'provisioning'}; diff --git a/src/api/teams/managed-team-library-service.ts b/src/api/teams/managed-team-library-service.ts index 6067ee26..76b05e22 100644 --- a/src/api/teams/managed-team-library-service.ts +++ b/src/api/teams/managed-team-library-service.ts @@ -2,6 +2,7 @@ import { ensureProjectKnowledgeBinding } from '../../control-plane/seeds/apply-s import { reconcileLibraryProvider } from '../../control-plane/seeds/apply-support/projects/projects-core/library-provider-reconciliation.ts'; import { createR2PublicationClient } from '../providers/cloudflare/r2-publication-client.ts'; import { enqueueTreeDxCommitReplication } from '../capacity/services/treedx/repositories/treedx-commit-replication.ts'; +import { isManagedTeamLibraryRepositoryName,managedTeamLibraryRepositoryName } from '../store/teams/contracts/managed-library/ensure-managed-team-library-project.ts'; const text=(...values:unknown[])=>values.find((value)=>typeof value==='string'&&value.trim())?.toString().trim()??''; const record=(value:unknown):Record=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; @@ -35,8 +36,10 @@ export async function reconcileManagedTeamLibrary(store:any,teamId:string,env:No for(const candidate of projects)for(const repository of await store.listHubRepositories(String(candidate.id)))if(repository.owner){owner=String(repository.owner);break;} } if(!owner)throw new Error('A GitHub library owner must be configured before the managed Team Library can be provisioned.'); - const provider=await reconcileLibraryProvider({store,teamId,projectId:String(project.id),projectSlug:'team',owner,name:'team-library',visibility:'private',lifecycle:'create-or-adopt',env,fetchImpl:store.config?.fetchImpl,seedFiles}); - const binding=await ensureProjectKnowledgeBinding({store,projectId:String(project.id),teamId,projectSlug:'team',libraryRoot:'.',libraryRef:'refs/remotes/origin/staging',libraryRepositoryUrl:`https://github.com/${owner}/team-library.git`,libraryDefaultBranch:'main',libraryCredentialId:provider.credentialId,expectedUpstreamHeads:provider.heads,env}); + const projectLibrary=record(record(project.metadata).library),repositoryName=text(projectLibrary.repositoryName,managedTeamLibraryRepositoryName(teamId)); + if(!isManagedTeamLibraryRepositoryName(teamId,repositoryName))throw new Error('Managed Team Library repository identity does not match its owning team.'); + const provider=await reconcileLibraryProvider({store,teamId,projectId:String(project.id),projectSlug:'team',owner,name:repositoryName,visibility:'private',lifecycle:'create-or-adopt',env,fetchImpl:store.config?.fetchImpl,seedFiles}); + const binding=await ensureProjectKnowledgeBinding({store,projectId:String(project.id),teamId,projectSlug:'team',libraryRoot:'.',libraryRef:'refs/remotes/origin/staging',libraryRepositoryUrl:`https://github.com/${owner}/${repositoryName}.git`,libraryDefaultBranch:'main',libraryCredentialId:provider.credentialId,expectedUpstreamHeads:provider.heads,env}); const now=new Date().toISOString(); await enqueueTreeDxCommitReplication(store,{teamId,projectId:String(project.id),commitSha:binding.resolvedRef,sourceRef:binding.sourceRef,createdAt:now}); const replication=await store.first(`SELECT status,r2_status,r2_receipt_json FROM treedx_commit_replications @@ -46,11 +49,11 @@ export async function reconcileManagedTeamLibrary(store:any,teamId:string,env:No &&r2Receipt.schemaVersion==='treeseed.treedx-r2-file-mirror/v2'&&r2Receipt.commitSha===binding.resolvedRef; const provisioning=mirrorReady?{state:'known-good',completedAt:now,requiredFiles:['README.md','objectives/core']} :{state:'replicating',updatedAt:now,requiredFiles:['README.md','objectives/core']}; - const projectMetadata={...record(project.metadata),library:{...record(record(project.metadata).library),status:mirrorReady?'known-good':'replicating',owner,repositoryName:'team-library',heads:provider.heads},provisioning}; + const projectMetadata={...record(project.metadata),library:{...projectLibrary,status:mirrorReady?'known-good':'replicating',owner,repositoryName,heads:provider.heads},provisioning}; await store.run('UPDATE projects SET metadata_json = ?, updated_at = ? WHERE id = ?',[JSON.stringify(projectMetadata),now,project.id]); - const teamMetadata={...metadata,teamLibrary:{projectId:project.id,projectSlug:'team',state:mirrorReady?'known-good':'replicating',repository:`${owner}/team-library`,repositoryId:binding.repositoryId}}; + const teamMetadata={...metadata,teamLibrary:{projectId:project.id,projectSlug:'team',state:mirrorReady?'known-good':'replicating',repository:`${owner}/${repositoryName}`,repositoryName,repositoryId:binding.repositoryId}}; await store.run('UPDATE teams SET metadata_json = ?, updated_at = ? WHERE id = ?',[JSON.stringify(teamMetadata),now,teamId]); - return {teamId,projectId:project.id,repository:`${owner}/team-library`,state:mirrorReady?'known-good':'replicating',...binding}; + return {teamId,projectId:project.id,repository:`${owner}/${repositoryName}`,state:mirrorReady?'known-good':'replicating',...binding}; } export async function markManagedTeamLibraryMirrorKnownGood(store:any,input:{teamId:string;projectId:string;commitSha:string;r2Receipt:unknown}) { @@ -91,8 +94,8 @@ export async function deleteManagedTeamLibraryResources(input:{teamId:string;pro if(input.project.metadata?.kind!=='system-team-library'||input.project.metadata?.systemManaged!==true) throw new Error('Managed Team Library cleanup requires the protected system project.'); const env=input.env??process.env,fetchImpl=input.fetchImpl??fetch,library=record(input.project.metadata?.library); - const owner=text(library.owner),name=text(library.repositoryName,'team-library'),token=text(env.TREESEED_GITHUB_TOKEN); - if(!owner||name!=='team-library'||!token)throw new Error('Managed Team Library GitHub deletion authority is unavailable.'); + const owner=text(library.owner),name=text(library.repositoryName),token=text(env.TREESEED_GITHUB_TOKEN); + if(!owner||!isManagedTeamLibraryRepositoryName(input.teamId,name)||!token)throw new Error('Managed Team Library GitHub deletion authority is unavailable.'); const response=await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,{ method:'DELETE',headers:{accept:'application/vnd.github+json',authorization:`Bearer ${token}`,'user-agent':'treeseed-team-library-deleter','x-github-api-version':'2022-11-28'}, }); diff --git a/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts b/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts index 0ae040f5..9775eb26 100644 --- a/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts +++ b/tests/unit/control-plane/teams/managed-team-library-deletion.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { deleteManagedTeamLibraryResources } from '../../../../src/api/teams/managed-team-library-service.ts'; +import { isManagedTeamLibraryRepositoryName,managedTeamLibraryRepositoryName } from '../../../../src/api/store/teams/contracts/managed-library/ensure-managed-team-library-project.ts'; describe('managed Team Library deletion', () => { it('deletes the exact GitHub repository and both team-scoped R2 prefixes', async () => { + const repositoryName=managedTeamLibraryRepositoryName('team-1'); const requests:string[]=[]; const fetchImpl=vi.fn(async (input:string|URL|Request,init?:RequestInit) => { const url=String(input);requests.push(`${init?.method??'GET'} ${url}`); @@ -10,13 +12,24 @@ describe('managed Team Library deletion', () => { if((init?.method??'GET')==='GET')return new Response('false',{status:200}); return new Response(null,{status:204}); }); - const result=await deleteManagedTeamLibraryResources({teamId:'team-1',project:{id:'project-team',metadata:{kind:'system-team-library',systemManaged:true,library:{owner:'treeseed-ai',repositoryName:'team-library'}}}, + const result=await deleteManagedTeamLibraryResources({teamId:'team-1',project:{id:'project-team',metadata:{kind:'system-team-library',systemManaged:true,library:{owner:'treeseed-ai',repositoryName}}}, env:{TREESEED_GITHUB_TOKEN:'github-token',TREESEED_CLOUDFLARE_ACCOUNT_ID:'account',TREESEED_CONTENT_BUCKET_NAME:'treeseed-dev-library',TREESEED_R2_ACCESS_KEY_ID:'access',TREESEED_R2_SECRET_ACCESS_KEY:'secret'},fetchImpl:fetchImpl as typeof fetch}); - expect(result).toEqual(expect.objectContaining({teamId:'team-1',github:{repository:'treeseed-ai/team-library',deleted:true,alreadyAbsent:false},r2:expect.objectContaining({deletedObjects:0})})); - expect(requests.some((request)=>request.startsWith('DELETE https://api.github.com/repos/treeseed-ai/team-library'))).toBe(true); + expect(result).toEqual(expect.objectContaining({teamId:'team-1',github:{repository:`treeseed-ai/${repositoryName}`,deleted:true,alreadyAbsent:false},r2:expect.objectContaining({deletedObjects:0})})); + expect(requests.some((request)=>request.startsWith(`DELETE https://api.github.com/repos/treeseed-ai/${repositoryName}`))).toBe(true); expect(requests.filter((request)=>request.startsWith('GET https://account.r2.cloudflarestorage.com/treeseed-dev-library/')).length).toBe(2); }); + it('derives stable collision-free identities and retains only the recorded legacy exception', async () => { + const first=managedTeamLibraryRepositoryName('team-1'),second=managedTeamLibraryRepositoryName('team-2'); + expect(first).toMatch(/^team-library-[a-f0-9]{12}$/u); + expect(first).not.toBe(second); + expect(isManagedTeamLibraryRepositoryName('team-1',first)).toBe(true); + expect(isManagedTeamLibraryRepositoryName('team-1','team-library')).toBe(true); + expect(isManagedTeamLibraryRepositoryName('team-1',second)).toBe(false); + await expect(deleteManagedTeamLibraryResources({teamId:'team-1',project:{id:'project-team',metadata:{kind:'system-team-library',systemManaged:true,library:{owner:'treeseed-ai',repositoryName:second}}}, + env:{TREESEED_GITHUB_TOKEN:'github-token'}})).rejects.toThrow('GitHub deletion authority is unavailable'); + }); + it('refuses to delete a normal project', async () => { await expect(deleteManagedTeamLibraryResources({teamId:'team-1',project:{id:'project-1',metadata:{}},env:{}})) .rejects.toThrow('protected system project');