initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
/**
|
||||
* Big Graph Generator for Enterprise Authorization Testing
|
||||
* Generates realistic authorization graphs with complex multi-hop relationships
|
||||
*/
|
||||
export class BigGraphGenerator {
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
scale: 'small',
|
||||
seed: 12345,
|
||||
...config
|
||||
};
|
||||
|
||||
// Scale configurations - reduced for testing
|
||||
this.scaleConfigs = {
|
||||
small: {
|
||||
users: 100,
|
||||
documents: 500,
|
||||
relations: 1000,
|
||||
complexChains: 200, // Multi-hop authorization chains
|
||||
targetQPS: 100,
|
||||
maxMemoryMB: 128,
|
||||
maxLatencyMs: 50
|
||||
},
|
||||
medium: {
|
||||
users: 500,
|
||||
documents: 2000,
|
||||
relations: 5000,
|
||||
complexChains: 1000,
|
||||
targetQPS: 200,
|
||||
maxMemoryMB: 256,
|
||||
maxLatencyMs: 100
|
||||
},
|
||||
large: {
|
||||
users: 1000,
|
||||
documents: 5000,
|
||||
relations: 10000,
|
||||
complexChains: 2000,
|
||||
targetQPS: 500,
|
||||
maxMemoryMB: 512,
|
||||
maxLatencyMs: 200
|
||||
},
|
||||
enterprise: {
|
||||
users: 2000,
|
||||
documents: 10000,
|
||||
relations: 20000,
|
||||
complexChains: 5000,
|
||||
targetQPS: 1000,
|
||||
maxMemoryMB: 1024,
|
||||
maxLatencyMs: 500
|
||||
},
|
||||
million: {
|
||||
users: 100000,
|
||||
documents: 900000,
|
||||
relations: 2000000,
|
||||
complexChains: 10000, // Reduced complexity to avoid stack overflow
|
||||
targetQPS: 100,
|
||||
maxMemoryMB: 8192,
|
||||
maxLatencyMs: 1000
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize random number generator with seed
|
||||
this.rng = this._createSeededRNG(this.config.seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create seeded random number generator for reproducible results
|
||||
*/
|
||||
_createSeededRNG(seed) {
|
||||
let state = seed;
|
||||
return {
|
||||
next: () => {
|
||||
state = (state * 1664525 + 1013904223) % 4294967296;
|
||||
return state / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization graph for specified model
|
||||
*/
|
||||
generateGraph(model = 'enterprise') {
|
||||
const scale = this.scaleConfigs[this.config.scale];
|
||||
|
||||
const graphData = {
|
||||
users: [],
|
||||
documents: [],
|
||||
relations: [],
|
||||
enterprises: [],
|
||||
saasTenants: [],
|
||||
metadata: {
|
||||
model,
|
||||
scale: this.config.scale,
|
||||
generatedAt: new Date().toISOString(),
|
||||
seed: this.config.seed
|
||||
}
|
||||
};
|
||||
|
||||
// Generate users
|
||||
graphData.users = this._generateUsers(scale);
|
||||
|
||||
// Generate documents
|
||||
graphData.documents = this._generateDocuments(scale);
|
||||
|
||||
// Generate basic user-document relationships
|
||||
const basicRelations = this._generateBasicRelations(scale, graphData.users, graphData.documents);
|
||||
graphData.relations.push(...basicRelations);
|
||||
|
||||
// Generate complex authorization chains (multi-hop relationships)
|
||||
const { complexChains, roleNodes, departmentNodes } = this._generateComplexAuthorizationChains(scale, graphData.users, graphData.documents);
|
||||
graphData.relations.push(...complexChains);
|
||||
|
||||
// Add the generated nodes to the graph data
|
||||
graphData.roles = roleNodes;
|
||||
graphData.departments = departmentNodes;
|
||||
|
||||
// Generate organizational hierarchies
|
||||
const orgRelations = this._generateOrganizationalHierarchies(scale, graphData.users);
|
||||
graphData.relations.push(...orgRelations);
|
||||
|
||||
// Generate long organizational chains for multi-hop testing
|
||||
const longChains = this._generateLongOrganizationalChains(scale, graphData.users, graphData.documents);
|
||||
graphData.relations.push(...longChains);
|
||||
|
||||
// Generate role-based access chains
|
||||
const roleChains = this._generateRoleBasedChains(scale, graphData.users, graphData.documents);
|
||||
graphData.relations.push(...roleChains);
|
||||
|
||||
return graphData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load graph data into Arbiter instance
|
||||
*/
|
||||
loadIntoArbiter(graphData, options = {}) {
|
||||
const arbiter = new Arbiter({
|
||||
fastConstructionMode: true,
|
||||
...options
|
||||
});
|
||||
|
||||
// Add all nodes
|
||||
graphData.users.forEach(user => {
|
||||
arbiter.addNode(user.id, 'user');
|
||||
});
|
||||
|
||||
graphData.documents.forEach(doc => {
|
||||
arbiter.addNode(doc.id, 'document');
|
||||
});
|
||||
|
||||
// Add role nodes if they exist
|
||||
if (graphData.roles) {
|
||||
graphData.roles.forEach(role => {
|
||||
arbiter.addNode(role.id, 'role');
|
||||
});
|
||||
}
|
||||
|
||||
// Add department nodes if they exist
|
||||
if (graphData.departments) {
|
||||
graphData.departments.forEach(dept => {
|
||||
arbiter.addNode(dept.id, 'department');
|
||||
});
|
||||
}
|
||||
|
||||
// Add intermediate nodes for long chains
|
||||
const longChainRelations = graphData.relations.filter(r => r.metadata?.type === 'long_chain');
|
||||
const intermediateNodes = new Set();
|
||||
|
||||
longChainRelations.forEach(relation => {
|
||||
// Add all intermediate nodes (teams, departments, divisions, companies)
|
||||
if (relation.src.startsWith('team:') || relation.src.startsWith('dept:') ||
|
||||
relation.src.startsWith('division:') || relation.src.startsWith('company:')) {
|
||||
intermediateNodes.add(relation.src);
|
||||
}
|
||||
if (relation.dst.startsWith('team:') || relation.dst.startsWith('division:') || relation.dst.startsWith('company:')) {
|
||||
intermediateNodes.add(relation.dst);
|
||||
}
|
||||
});
|
||||
|
||||
intermediateNodes.forEach(nodeId => {
|
||||
const nodeType = nodeId.startsWith('team:') ? 'team' :
|
||||
nodeId.startsWith('dept:') ? 'department' :
|
||||
nodeId.startsWith('division:') ? 'division' : 'company';
|
||||
arbiter.addNode(nodeId, nodeType);
|
||||
});
|
||||
|
||||
// Add all relations
|
||||
graphData.relations.forEach(relation => {
|
||||
arbiter.addRelation(relation.src, relation.relation, relation.dst, relation.possibility);
|
||||
});
|
||||
|
||||
// Disable fast construction mode and build indices now that loading is complete
|
||||
arbiter.setFastConstructionMode(false);
|
||||
|
||||
// Configure relation types with proper ReBAC patterns
|
||||
const relationTypes = ['can_read', 'can_write', 'can_delete', 'can_share', 'can_admin'];
|
||||
relationTypes.forEach(relType => {
|
||||
// Configure direct relations
|
||||
arbiter.setRelationConfig(relType, { type: 'direct' });
|
||||
|
||||
// Configure role-based access using chain rule for proper role-based access
|
||||
const roleBasedRel = `${relType}_via_role`;
|
||||
arbiter.setRelationConfig(roleBasedRel, {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: relType, direction: 'out' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
// Configure membership relations as direct
|
||||
const membershipTypes = ['member_of', 'manager_of', 'reports_to'];
|
||||
membershipTypes.forEach(relType => {
|
||||
arbiter.setRelationConfig(relType, { type: 'direct' });
|
||||
});
|
||||
|
||||
// Configure department-based access using chain rule
|
||||
const departmentBasedRel = 'can_write_via_department';
|
||||
arbiter.setRelationConfig(departmentBasedRel, {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'can_write', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
// Configure multi-hop access for long chain traversal (computationally intensive)
|
||||
// Use chain rule for complex multi-step authorization
|
||||
arbiter.setRelationConfig('can_access_multi_hop', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'can_read', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate users
|
||||
*/
|
||||
_generateUsers(scale) {
|
||||
const users = [];
|
||||
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'];
|
||||
const surnames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez'];
|
||||
|
||||
for (let i = 0; i < scale.users; i++) {
|
||||
const firstName = names[Math.floor(this.rng.next() * names.length)];
|
||||
const lastName = surnames[Math.floor(this.rng.next() * surnames.length)];
|
||||
|
||||
users.push({
|
||||
id: `user:${firstName} ${lastName}-${i}`,
|
||||
type: 'employee',
|
||||
name: `${firstName} ${lastName}`,
|
||||
email: `${firstName.toLowerCase()} ${lastName.toLowerCase()}@company.com`,
|
||||
role: ['manager', 'employee', 'contractor', 'intern'][Math.floor(this.rng.next() * 4)],
|
||||
clearance: ['public', 'internal', 'confidential', 'secret', 'top_secret'][Math.floor(this.rng.next() * 5)],
|
||||
department: `dept:${this._generateDepartmentName()}-${i}`,
|
||||
organization: `org:company-${Math.floor(i / 50)}`,
|
||||
manager: null,
|
||||
startDate: new Date(Date.now() - Math.floor(this.rng.next() * 365 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate documents
|
||||
*/
|
||||
_generateDocuments(scale) {
|
||||
const documents = [];
|
||||
const docTypes = ['report', 'analysis', 'proposal', 'contract', 'policy', 'spec', 'budget'];
|
||||
|
||||
for (let i = 0; i < scale.documents; i++) {
|
||||
const docType = docTypes[Math.floor(this.rng.next() * docTypes.length)];
|
||||
|
||||
documents.push({
|
||||
id: `doc:${docType}-${i}`,
|
||||
type: 'document',
|
||||
name: `${docType}_${i}`,
|
||||
classification: ['public', 'internal', 'confidential', 'secret', 'top_secret'][Math.floor(this.rng.next() * 5)],
|
||||
department: `dept:${this._generateDepartmentName()}-${i}`,
|
||||
organization: `org:company-${Math.floor(i / 100)}`,
|
||||
requiredClearance: ['public', 'internal', 'confidential', 'secret', 'top_secret'][Math.floor(this.rng.next() * 5)],
|
||||
cost: Math.floor(this.rng.next() * 10000),
|
||||
createdDate: new Date(Date.now() - Math.floor(this.rng.next() * 365 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
lastModified: new Date(Date.now() - Math.floor(this.rng.next() * 30 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
size: Math.floor(this.rng.next() * 1000000),
|
||||
tags: this._generateTags()
|
||||
});
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate basic user-document relationships
|
||||
* FIXED: Reduce direct relations to avoid conflicts with chain relations
|
||||
*/
|
||||
_generateBasicRelations(scale, users, documents) {
|
||||
const relations = [];
|
||||
const operations = ['can_read', 'can_write', 'can_delete', 'can_share'];
|
||||
|
||||
// Reduce the number of direct relations to avoid conflicts with chain relations
|
||||
const reducedRelations = Math.floor(scale.relations * 0.3); // Only 30% of original
|
||||
|
||||
for (let i = 0; i < reducedRelations; i++) {
|
||||
const user = users[Math.floor(this.rng.next() * users.length)];
|
||||
const document = documents[Math.floor(this.rng.next() * documents.length)];
|
||||
const operation = operations[Math.floor(this.rng.next() * operations.length)];
|
||||
const possibility = 0.5 + (this.rng.next() * 0.5); // 0.5 to 1.0
|
||||
|
||||
relations.push({
|
||||
src: user.id,
|
||||
relation: operation,
|
||||
dst: document.id,
|
||||
possibility,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
clearance: user.clearance,
|
||||
department: user.department,
|
||||
grantedDate: new Date().toISOString(),
|
||||
expiresDate: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complex authorization chains (multi-hop relationships)
|
||||
* These create realistic enterprise authorization scenarios
|
||||
*/
|
||||
_generateComplexAuthorizationChains(scale, users, documents) {
|
||||
const relations = [];
|
||||
const roleNodes = [];
|
||||
const departmentNodes = [];
|
||||
const chainCount = scale.complexChains || Math.floor(scale.relations * 0.2);
|
||||
|
||||
// Create role nodes first
|
||||
const roleTypes = ['admin', 'manager', 'employee', 'contractor', 'intern'];
|
||||
const createdRoles = new Set();
|
||||
|
||||
for (let i = 0; i < chainCount; i++) {
|
||||
const user = users[Math.floor(this.rng.next() * users.length)];
|
||||
const document = documents[Math.floor(this.rng.next() * documents.length)];
|
||||
|
||||
// Chain 1: User -> Role -> Document (Role-based access)
|
||||
const roleType = roleTypes[Math.floor(this.rng.next() * roleTypes.length)];
|
||||
const roleId = `role:${roleType}-${Math.floor(this.rng.next() * 10)}`;
|
||||
|
||||
// Create role node if it doesn't exist
|
||||
if (!createdRoles.has(roleId)) {
|
||||
roleNodes.push({
|
||||
id: roleId,
|
||||
type: 'role',
|
||||
name: `${roleType} role`,
|
||||
roleType: roleType,
|
||||
permissions: ['can_read', 'can_write', 'can_share']
|
||||
});
|
||||
createdRoles.add(roleId);
|
||||
}
|
||||
|
||||
relations.push({
|
||||
src: user.id,
|
||||
relation: 'member_of',
|
||||
dst: roleId,
|
||||
possibility: 0.9,
|
||||
metadata: { type: 'role_membership' }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: roleId,
|
||||
relation: 'can_read',
|
||||
dst: document.id,
|
||||
possibility: 0.8,
|
||||
metadata: { type: 'role_permission' }
|
||||
});
|
||||
|
||||
// Chain 2: User -> Department -> Document (Department-based access)
|
||||
const deptName = this._generateDepartmentName();
|
||||
const deptId = `dept:${deptName}-${Math.floor(this.rng.next() * 20)}`;
|
||||
|
||||
// Create department node if it doesn't exist
|
||||
if (!createdRoles.has(deptId)) {
|
||||
departmentNodes.push({
|
||||
id: deptId,
|
||||
type: 'department',
|
||||
name: `${deptName} department`,
|
||||
departmentName: deptName,
|
||||
permissions: ['can_read', 'can_write']
|
||||
});
|
||||
createdRoles.add(deptId);
|
||||
}
|
||||
|
||||
relations.push({
|
||||
src: user.id,
|
||||
relation: 'member_of',
|
||||
dst: deptId,
|
||||
possibility: 0.95,
|
||||
metadata: { type: 'department_membership' }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: deptId,
|
||||
relation: 'can_write',
|
||||
dst: document.id,
|
||||
possibility: 0.7,
|
||||
metadata: { type: 'department_permission' }
|
||||
});
|
||||
|
||||
// Chain 3: User -> Manager -> Document (Manager approval chain)
|
||||
const manager = users[Math.floor(this.rng.next() * users.length)];
|
||||
relations.push({
|
||||
src: user.id,
|
||||
relation: 'reports_to',
|
||||
dst: manager.id,
|
||||
possibility: 0.9,
|
||||
metadata: { type: 'reporting_chain' }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: manager.id,
|
||||
relation: 'can_share',
|
||||
dst: document.id,
|
||||
possibility: 0.6,
|
||||
metadata: { type: 'manager_permission' }
|
||||
});
|
||||
}
|
||||
|
||||
return { complexChains: relations, roleNodes, departmentNodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate long organizational chains for multi-hop testing
|
||||
* These create deep hierarchies that will be computationally intensive to traverse
|
||||
*/
|
||||
_generateLongOrganizationalChains(scale, users, documents) {
|
||||
const relations = [];
|
||||
const chainCount = Math.floor(scale.users * 0.1); // 10% of users get long chains
|
||||
|
||||
for (let i = 0; i < chainCount; i++) {
|
||||
const user = users[Math.floor(this.rng.next() * users.length)];
|
||||
const document = documents[Math.floor(this.rng.next() * documents.length)];
|
||||
|
||||
// Create a long chain: User -> Team -> Department -> Division -> Company -> Document
|
||||
const teamId = `team:${this._generateTeamName()}-${Math.floor(this.rng.next() * 50)}`;
|
||||
const deptId = `dept:${this._generateDepartmentName()}-${Math.floor(this.rng.next() * 100)}`;
|
||||
const divisionId = `division:${this._generateDivisionName()}-${Math.floor(this.rng.next() * 20)}`;
|
||||
const companyId = `company:${this._generateCompanyName()}-${Math.floor(this.rng.next() * 10)}`;
|
||||
|
||||
// Create the long chain with decreasing possibility (realistic for deep hierarchies)
|
||||
relations.push({
|
||||
src: user.id,
|
||||
relation: 'member_of',
|
||||
dst: teamId,
|
||||
possibility: 0.95,
|
||||
metadata: { type: 'long_chain', step: 1, chainId: `chain-${i}` }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: teamId,
|
||||
relation: 'member_of',
|
||||
dst: deptId,
|
||||
possibility: 0.9,
|
||||
metadata: { type: 'long_chain', step: 2, chainId: `chain-${i}` }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: deptId,
|
||||
relation: 'member_of',
|
||||
dst: divisionId,
|
||||
possibility: 0.85,
|
||||
metadata: { type: 'long_chain', step: 3, chainId: `chain-${i}` }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: divisionId,
|
||||
relation: 'member_of',
|
||||
dst: companyId,
|
||||
possibility: 0.8,
|
||||
metadata: { type: 'long_chain', step: 4, chainId: `chain-${i}` }
|
||||
});
|
||||
|
||||
relations.push({
|
||||
src: companyId,
|
||||
relation: 'can_read',
|
||||
dst: document.id,
|
||||
possibility: 0.75,
|
||||
metadata: { type: 'long_chain', step: 5, chainId: `chain-${i}` }
|
||||
});
|
||||
}
|
||||
|
||||
return relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate organizational hierarchies
|
||||
*/
|
||||
_generateOrganizationalHierarchies(scale, users) {
|
||||
const relations = [];
|
||||
|
||||
// Create department hierarchies
|
||||
for (let i = 0; i < Math.floor(scale.users / 10); i++) {
|
||||
const deptId = `dept:${this._generateDepartmentName()}-${i}`;
|
||||
const parentDeptId = `dept:${this._generateDepartmentName()}-${Math.floor(i / 3)}`;
|
||||
|
||||
if (i > 0) {
|
||||
relations.push({
|
||||
src: deptId,
|
||||
relation: 'reports_to',
|
||||
dst: parentDeptId,
|
||||
possibility: 0.9,
|
||||
metadata: { type: 'department_hierarchy' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate role-based access chains
|
||||
*/
|
||||
_generateRoleBasedChains(scale, users, documents) {
|
||||
const relations = [];
|
||||
|
||||
// Create role hierarchies
|
||||
const roles = ['admin', 'manager', 'senior', 'employee', 'contractor', 'intern'];
|
||||
|
||||
for (let i = 0; i < roles.length - 1; i++) {
|
||||
const currentRole = `role:${roles[i]}`;
|
||||
const parentRole = `role:${roles[i + 1]}`;
|
||||
|
||||
relations.push({
|
||||
src: currentRole,
|
||||
relation: 'inherits_from',
|
||||
dst: parentRole,
|
||||
possibility: 0.8,
|
||||
metadata: { type: 'role_inheritance' }
|
||||
});
|
||||
}
|
||||
|
||||
return relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate department name
|
||||
*/
|
||||
_generateDepartmentName() {
|
||||
const departments = ['engineering', 'finance', 'hr', 'legal', 'operations', 'marketing', 'sales'];
|
||||
return departments[Math.floor(this.rng.next() * departments.length)];
|
||||
}
|
||||
|
||||
_generateTeamName() {
|
||||
const teams = ['Frontend', 'Backend', 'DevOps', 'QA', 'Design', 'Analytics', 'Security', 'Mobile'];
|
||||
return teams[Math.floor(this.rng.next() * teams.length)];
|
||||
}
|
||||
|
||||
_generateDivisionName() {
|
||||
const divisions = ['Product', 'Engineering', 'Sales', 'Marketing', 'Operations', 'Finance', 'Legal', 'HR'];
|
||||
return divisions[Math.floor(this.rng.next() * divisions.length)];
|
||||
}
|
||||
|
||||
_generateCompanyName() {
|
||||
const companies = ['AcmeCorp', 'TechGiant', 'InnovateLabs', 'DataFlow', 'CloudSystems', 'NextGen', 'FutureTech', 'SmartSolutions'];
|
||||
return companies[Math.floor(this.rng.next() * companies.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate document tags
|
||||
*/
|
||||
_generateTags() {
|
||||
const allTags = ['important', 'urgent', 'confidential', 'draft', 'final', 'reviewed', 'approved'];
|
||||
const tagCount = Math.floor(this.rng.next() * 3) + 1;
|
||||
const tags = [];
|
||||
|
||||
for (let i = 0; i < tagCount; i++) {
|
||||
const tag = allTags[Math.floor(this.rng.next() * allTags.length)];
|
||||
if (!tags.includes(tag)) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user